1use std::collections::{BTreeMap, BTreeSet};
46use std::io::{Read, Write};
47use std::path::Path;
48
49use super::tree::{read_repo_file, sha256_hex};
50use crate::actions::projects::IMPORT_MAX_BYTES;
51use crate::client::resources::normalize_descriptor;
52use crate::error::CoreError;
53
54const RESOURCE_JSON: &str = "resource.json";
56
57const PROJECT_JSON: &str = "project.json";
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct CanonicalDigest {
63 pub sha256: String,
66 pub members: BTreeMap<String, String>,
68}
69
70#[derive(Debug, Clone)]
72pub struct Artifact {
73 pub zip: Vec<u8>,
75 pub digest: CanonicalDigest,
77 pub members: usize,
79 pub excluded: Vec<String>,
82}
83
84pub const IGNEXCLUDE_FILE: &str = ".ignexclude";
86
87pub const BUILTIN_DENY: &[&str] = &[
92 ".git",
93 ".github",
94 ".ign",
95 "node_modules",
96 "__pycache__",
97 "*.pyc",
98];
99
100pub const ARTIFACT_MAX_ENTRIES: usize = 200_000;
102
103pub fn is_module_dir_name(name: &str) -> bool {
107 let mut parts = name.split('.');
108 let Some(first) = parts.next() else {
109 return false;
110 };
111 let tail_ok = |part: &str| {
112 part.bytes()
113 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
114 };
115 let mut chars = first.chars();
116 let head_ok = chars.next().is_some_and(|c| c.is_ascii_lowercase()) && tail_ok(chars.as_str());
117 let mut rest = 0usize;
118 for part in parts {
119 if part.is_empty() || !tail_ok(part) {
120 return false;
121 }
122 rest += 1;
123 }
124 head_ok && rest >= 1
125}
126
127fn segment_glob(pattern: &str, name: &str) -> bool {
130 let (p, n) = (pattern.as_bytes(), name.as_bytes());
131 let (mut pi, mut ni) = (0, 0);
132 let mut star: Option<(usize, usize)> = None;
133 while ni < n.len() {
134 if pi < p.len() && p[pi] == b'*' {
135 star = Some((pi, ni));
136 pi += 1;
137 } else if pi < p.len() && p[pi] == n[ni] {
138 pi += 1;
139 ni += 1;
140 } else if let Some((star_p, star_n)) = star {
141 pi = star_p + 1;
142 ni = star_n + 1;
143 star = Some((star_p, star_n + 1));
144 } else {
145 return false;
146 }
147 }
148 p[pi..].iter().all(|b| *b == b'*')
149}
150
151fn is_denied(name: &str) -> bool {
153 BUILTIN_DENY
154 .iter()
155 .any(|pattern| segment_glob(pattern, name))
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
160struct IgnRule {
161 segments: Vec<String>,
162 dir_only: bool,
164}
165
166fn is_format_char(c: char) -> bool {
171 matches!(
172 c,
173 '\u{00ad}'
174 | '\u{061c}'
175 | '\u{180e}'
176 | '\u{200b}'..='\u{200f}'
177 | '\u{2028}'..='\u{202e}'
178 | '\u{2060}'..='\u{2064}'
179 | '\u{2066}'..='\u{206f}'
180 | '\u{feff}'
181 | '\u{fff9}'..='\u{fffb}'
182 | '\u{e0001}'
183 | '\u{e0020}'..='\u{e007f}'
184 )
185}
186
187#[derive(Debug, Clone, Default, PartialEq, Eq)]
196pub struct IgnExclude {
197 rules: Vec<IgnRule>,
198}
199
200impl IgnExclude {
201 pub fn parse(bytes: &[u8]) -> Result<Self, CoreError> {
203 let text = std::str::from_utf8(bytes)
204 .map_err(|_| invalid(IGNEXCLUDE_FILE, "is not valid UTF-8"))?;
205 let mut rules = Vec::new();
206 for (index, raw) in text.split('\n').enumerate() {
207 let at = |message: &str| {
208 CoreError::deploy_tree_invalid(
209 IGNEXCLUDE_FILE,
210 Some(index + 1),
211 Some(1),
212 message.to_string(),
213 )
214 };
215 let line = raw.strip_suffix('\r').unwrap_or(raw);
216 let line = line.trim_matches(|c| c == ' ' || c == '\t');
217 if line.is_empty() || line.starts_with('#') {
218 continue;
219 }
220 if line.chars().any(char::is_control) {
221 return Err(at("holds a control character"));
222 }
223 if line.chars().any(is_format_char) {
224 return Err(at(
225 "holds a non-printing format character (a byte order mark?); save the file as plain UTF-8",
226 ));
227 }
228 if line.starts_with('!') {
229 return Err(at("negation (`!`) is not supported"));
230 }
231 if line.contains("**") {
232 return Err(at("`**` is not supported; `*` matches within one segment"));
233 }
234 if line.starts_with('/') {
235 return Err(at(
236 "an absolute path is not supported; use a path relative to the repo root",
237 ));
238 }
239 if line.contains('\\') {
240 return Err(at(
241 "a backslash is not supported; separate segments with `/`",
242 ));
243 }
244 let dir_only = line.ends_with('/');
245 let segments: Vec<String> = line
246 .trim_end_matches('/')
247 .split('/')
248 .map(str::to_string)
249 .collect();
250 if segments.iter().any(|segment| segment == "..") {
251 return Err(at("`..` is not supported; paths stay inside the repo"));
252 }
253 if segments.iter().any(|s| s.is_empty() || s == ".") {
254 return Err(at("holds an empty or `.` path segment"));
255 }
256 rules.push(IgnRule { segments, dir_only });
257 }
258 Ok(Self { rules })
259 }
260
261 pub fn matches(&self, rel: &str, is_dir: bool) -> bool {
265 let parts: Vec<&str> = rel.split('/').collect();
266 self.rules.iter().any(|rule| {
267 (is_dir || !rule.dir_only)
268 && rule.segments.len() == parts.len()
269 && rule
270 .segments
271 .iter()
272 .zip(&parts)
273 .all(|(pattern, part)| segment_glob(pattern, part))
274 })
275 }
276}
277
278fn load_ignexclude(repo: &Path) -> Result<IgnExclude, CoreError> {
280 match std::fs::symlink_metadata(repo.join(IGNEXCLUDE_FILE)) {
281 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(IgnExclude::default()),
282 Err(e) => Err(invalid(
283 IGNEXCLUDE_FILE,
284 format!("cannot inspect ({})", e.kind()),
285 )),
286 Ok(meta) if meta.file_type().is_file() => {
287 IgnExclude::parse(&read_repo_file(repo, IGNEXCLUDE_FILE, None)?)
288 }
289 Ok(_) => Err(invalid(
290 IGNEXCLUDE_FILE,
291 "is not a regular file; symlinks are refused",
292 )),
293 }
294}
295
296fn excluded_entry(rel: &str, is_dir: bool) -> String {
298 if is_dir {
299 format!("{rel}/")
300 } else {
301 rel.to_string()
302 }
303}
304
305fn top_level_allowed(name: &str) -> bool {
306 name == "ignition" || is_module_dir_name(name)
307}
308
309fn sort_files_array(value: &mut serde_json::Value) {
313 if let Some(files) = value
314 .as_object_mut()
315 .and_then(|object| object.get_mut("files"))
316 .and_then(|files| files.as_array_mut())
317 && files.iter().all(|f| f.is_string())
318 {
319 files.sort_by(|a, b| {
320 a.as_str()
321 .unwrap_or_default()
322 .as_bytes()
323 .cmp(b.as_str().unwrap_or_default().as_bytes())
324 });
325 }
326}
327
328fn drop_empty_attributes(value: &mut serde_json::Value) {
333 if let Some(object) = value.as_object_mut()
334 && object
335 .get("attributes")
336 .and_then(serde_json::Value::as_object)
337 .is_some_and(serde_json::Map::is_empty)
338 {
339 object.remove("attributes");
340 }
341}
342
343fn fill_defaults(
347 object: &mut serde_json::Map<String, serde_json::Value>,
348 defaults: &[(&str, serde_json::Value)],
349) {
350 for (key, default) in defaults {
351 if object.get(*key).is_none_or(serde_json::Value::is_null) {
352 object.insert((*key).to_string(), default.clone());
353 }
354 }
355}
356
357fn canonical_resource_json(bytes: &[u8]) -> Vec<u8> {
363 let Some(normalized) = normalize_descriptor(bytes) else {
364 return bytes.to_vec();
365 };
366 let Ok(mut value) = serde_json::from_slice::<serde_json::Value>(&normalized) else {
367 return bytes.to_vec();
368 };
369 drop_empty_attributes(&mut value);
370 sort_files_array(&mut value);
371 if let Some(object) = value.as_object_mut() {
372 fill_defaults(
373 object,
374 &[
375 ("version", serde_json::Value::from(1)),
376 ("restricted", serde_json::Value::Bool(false)),
377 ("overridable", serde_json::Value::Bool(true)),
378 ],
379 );
380 }
381 serde_json::to_vec(&value).unwrap_or_else(|_| bytes.to_vec())
383}
384
385fn canonical_project_json(bytes: &[u8]) -> Vec<u8> {
390 let Ok(mut value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
391 return bytes.to_vec();
392 };
393 let Some(object) = value.as_object_mut() else {
394 return bytes.to_vec();
395 };
396 fill_defaults(
397 object,
398 &[
399 ("description", serde_json::Value::String(String::new())),
400 ("parent", serde_json::Value::String(String::new())),
401 ("inheritable", serde_json::Value::Bool(false)),
402 ],
403 );
404 serde_json::to_vec(&value).unwrap_or_else(|_| bytes.to_vec())
405}
406
407fn canonical_member_bytes(key: &str, bytes: &[u8]) -> Vec<u8> {
409 if key == PROJECT_JSON {
410 canonical_project_json(bytes)
411 } else if key.rsplit('/').next() == Some(RESOURCE_JSON) {
412 canonical_resource_json(bytes)
413 } else {
414 bytes.to_vec()
415 }
416}
417
418fn digest_of(members: &BTreeMap<String, String>) -> String {
420 let mut listing = Vec::new();
421 for (key, hash) in members {
422 listing.extend_from_slice(key.as_bytes());
423 listing.push(0);
424 listing.extend_from_slice(hash.as_bytes());
425 listing.push(b'\n');
426 }
427 sha256_hex(&listing)
428}
429
430pub fn canonical_digest(zip_bytes: &[u8]) -> Result<CanonicalDigest, CoreError> {
435 let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes))
436 .map_err(|err| CoreError::Internal(format!("project zip is not readable: {err}")))?;
437 let mut members = BTreeMap::new();
438 for index in 0..archive.len() {
439 let mut entry = archive
440 .by_index(index)
441 .map_err(|err| CoreError::Internal(format!("cannot walk project zip: {err}")))?;
442 if entry.is_dir() {
443 continue;
444 }
445 let key = entry.name().to_string();
446 let mut bytes = Vec::new();
447 entry
448 .read_to_end(&mut bytes)
449 .map_err(|err| CoreError::Internal(format!("cannot decompress a zip member: {err}")))?;
450 members.insert(
451 key.clone(),
452 sha256_hex(&canonical_member_bytes(&key, &bytes)),
453 );
454 }
455 if members.is_empty() {
456 return Err(CoreError::deploy_tree_invalid(
457 PROJECT_JSON,
458 None,
459 None,
460 "the project holds no members; an import of nothing would empty the project",
461 ));
462 }
463 Ok(CanonicalDigest {
464 sha256: digest_of(&members),
465 members,
466 })
467}
468
469fn invalid(rel: &str, message: impl Into<String>) -> CoreError {
470 CoreError::deploy_tree_invalid(rel, None, None, message)
471}
472
473struct Walked {
475 name: String,
476 rel: String,
477 kind: Kind,
478}
479
480#[derive(Clone, Copy, PartialEq, Eq)]
481enum Kind {
482 Dir,
483 File,
484}
485
486struct Cx<'a> {
489 repo: &'a Path,
490 ignore: &'a IgnExclude,
491 files: BTreeMap<String, Vec<u8>>,
492 excluded: Vec<String>,
493 visited: usize,
494 max_entries: usize,
495}
496
497impl Cx<'_> {
498 fn visit(&mut self, rel: &str) -> Result<(), CoreError> {
500 self.visited += 1;
501 if self.visited > self.max_entries {
502 return Err(invalid(
503 rel,
504 format!(
505 "the walk passed {} entries; the checkout is too large to deploy",
506 self.max_entries
507 ),
508 ));
509 }
510 Ok(())
511 }
512}
513
514fn utf8_name(rel: &str, name: &std::ffi::OsStr) -> Result<String, CoreError> {
517 name.to_str().map(str::to_string).ok_or_else(|| {
518 invalid(
519 &format!("{rel}/{}", name.to_string_lossy()),
520 "the name is not valid UTF-8",
521 )
522 })
523}
524
525fn read_children(cx: &mut Cx<'_>, rel: &str) -> Result<Vec<Walked>, CoreError> {
530 let unreadable =
531 |e: std::io::Error| invalid(rel, format!("cannot read the directory ({})", e.kind()));
532 let mut names = Vec::new();
533 for entry in std::fs::read_dir(cx.repo.join(rel)).map_err(unreadable)? {
534 let entry = entry.map_err(unreadable)?;
535 let name = utf8_name(rel, &entry.file_name())?;
536 names.push((name, entry));
537 }
538 names.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
539 let mut out = Vec::new();
540 for (name, entry) in names {
541 let child = format!("{rel}/{name}");
542 cx.visit(&child)?;
543 let listed_as_dir = entry.file_type().is_ok_and(|t| t.is_dir());
544 if is_denied(&name) {
545 cx.excluded.push(excluded_entry(&child, listed_as_dir));
546 continue;
547 }
548 if name.contains('\\') || name.contains('\0') {
549 return Err(invalid(
550 &child,
551 "the name holds a backslash or NUL; zip paths use `/` only",
552 ));
553 }
554 if cx.ignore.matches(&child, listed_as_dir) {
555 cx.excluded.push(excluded_entry(&child, listed_as_dir));
556 continue;
557 }
558 let meta = std::fs::symlink_metadata(entry.path())
559 .map_err(|e| invalid(&child, format!("cannot inspect ({})", e.kind())))?;
560 let file_type = meta.file_type();
561 if file_type.is_symlink() {
562 return Err(invalid(&child, "is a symlink; symlinks are refused"));
563 }
564 let kind = if file_type.is_dir() {
565 Kind::Dir
566 } else if file_type.is_file() {
567 Kind::File
568 } else {
569 return Err(invalid(&child, "is not a regular file or directory"));
570 };
571 out.push(Walked {
572 name,
573 rel: child,
574 kind,
575 });
576 }
577 Ok(out)
578}
579
580fn listed_files(rel: &str, bytes: &[u8]) -> Result<Vec<String>, CoreError> {
582 let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|_| {
583 invalid(
584 rel,
585 "is not valid JSON; the gateway would drop the resource",
586 )
587 })?;
588 let Some(object) = value.as_object() else {
589 return Err(invalid(rel, "must be a JSON object"));
590 };
591 let Some(files) = object.get("files") else {
592 return Ok(Vec::new());
593 };
594 let Some(files) = files.as_array() else {
595 return Err(invalid(rel, "`files` must be an array of file names"));
596 };
597 let mut out = Vec::new();
598 for (i, item) in files.iter().enumerate() {
599 let Some(name) = item.as_str() else {
600 return Err(invalid(rel, format!("files[{i}] must be a string")));
601 };
602 if name.is_empty()
603 || name == "."
604 || name == ".."
605 || name.contains('/')
606 || name.contains('\\')
607 || name.contains('\0')
608 {
609 return Err(invalid(
610 rel,
611 format!("files[{i}] must be a plain file name in the same folder"),
612 ));
613 }
614 if name != RESOURCE_JSON && !out.iter().any(|n| n == name) {
615 out.push(name.to_string());
616 }
617 }
618 Ok(out)
619}
620
621fn walk(cx: &mut Cx<'_>, rel: &str) -> Result<(), CoreError> {
625 let children = read_children(cx, rel)?;
626 let descriptor = children
627 .iter()
628 .find(|c| c.name == RESOURCE_JSON && c.kind == Kind::File);
629 if let Some(descriptor) = descriptor {
630 if let Some(dir) = children.iter().find(|c| c.kind == Kind::Dir) {
632 return Err(invalid(
633 &dir.rel,
634 "is a directory inside a resource folder; the gateway would drop the whole resource",
635 ));
636 }
637 let descriptor_bytes = read_repo_file(cx.repo, &descriptor.rel, None)?;
638 let listed = listed_files(&descriptor.rel, &descriptor_bytes)?;
639 for name in &listed {
640 let Some(child) = children
641 .iter()
642 .find(|c| &c.name == name && c.kind == Kind::File)
643 else {
644 let why = if cx.ignore.matches(&format!("{rel}/{name}"), false) {
645 format!(
646 "lists `{name}`, which {IGNEXCLUDE_FILE} leaves out; the gateway would drop the whole resource"
647 )
648 } else {
649 format!("lists `{name}`, which is not a file in the folder")
650 };
651 return Err(invalid(&descriptor.rel, why));
652 };
653 let bytes = read_repo_file(cx.repo, &child.rel, None)?;
654 cx.files.insert(child.rel.clone(), bytes);
655 }
656 for child in &children {
657 if child.name != RESOURCE_JSON && !listed.contains(&child.name) {
658 cx.excluded.push(child.rel.clone());
659 }
660 }
661 cx.files.insert(descriptor.rel.clone(), descriptor_bytes);
662 return Ok(());
663 }
664 for child in &children {
665 match child.kind {
666 Kind::Dir => descend(cx, &child.rel)?,
667 Kind::File => cx.excluded.push(child.rel.clone()),
668 }
669 }
670 Ok(())
671}
672
673fn descend(cx: &mut Cx<'_>, rel: &str) -> Result<(), CoreError> {
676 let mark = cx.excluded.len();
677 let before = cx.files.len();
678 walk(cx, rel)?;
679 if cx.files.len() == before {
680 cx.excluded.truncate(mark);
681 cx.excluded.push(excluded_entry(rel, true));
682 }
683 Ok(())
684}
685
686fn root_dirs(cx: &mut Cx<'_>) -> Result<Vec<String>, CoreError> {
692 let read = std::fs::read_dir(cx.repo)
693 .map_err(|e| invalid(".", format!("cannot read the directory ({})", e.kind())))?;
694 let mut names = Vec::new();
695 for entry in read {
696 let entry =
697 entry.map_err(|e| invalid(".", format!("cannot read the directory ({})", e.kind())))?;
698 let lossy = entry.file_name().to_string_lossy().into_owned();
699 if lossy == PROJECT_JSON {
700 continue;
701 }
702 cx.visit(&lossy)?;
703 let meta = std::fs::symlink_metadata(entry.path());
704 let is_dir = meta.as_ref().is_ok_and(|m| m.file_type().is_dir());
705 let name = match entry.file_name().to_str() {
706 Some(name) if top_level_allowed(name) && !is_denied(name) => name.to_string(),
707 _ => {
708 cx.excluded.push(excluded_entry(&lossy, is_dir));
709 continue;
710 }
711 };
712 if cx.ignore.matches(&name, is_dir) {
713 cx.excluded.push(excluded_entry(&name, is_dir));
714 continue;
715 }
716 let meta = meta.map_err(|e| invalid(&name, format!("cannot inspect ({})", e.kind())))?;
717 if meta.file_type().is_symlink() {
718 return Err(invalid(&name, "is a symlink; symlinks are refused"));
719 }
720 if is_dir {
723 names.push(name);
724 } else {
725 cx.excluded.push(name);
726 }
727 }
728 names.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
729 Ok(names)
730}
731
732fn entry_options(directory: bool) -> zip::write::SimpleFileOptions {
734 zip::write::SimpleFileOptions::default()
735 .compression_method(zip::CompressionMethod::Deflated)
736 .last_modified_time(zip::DateTime::default())
737 .unix_permissions(if directory { 0o755 } else { 0o644 })
738}
739
740fn write_zip(files: &BTreeMap<String, Vec<u8>>) -> Result<Vec<u8>, CoreError> {
743 let mut dirs = BTreeSet::new();
744 for path in files.keys() {
745 let mut end = 0;
746 while let Some(at) = path[end..].find('/') {
747 end += at + 1;
748 dirs.insert(path[..end].to_string());
749 }
750 }
751 let mut entries: Vec<(String, Option<&Vec<u8>>)> = Vec::new();
752 entries.extend(dirs.into_iter().map(|d| (d, None)));
753 entries.extend(files.iter().map(|(p, b)| (p.clone(), Some(b))));
754 entries.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
755
756 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
757 let zip_err = |err: &dyn std::fmt::Display| {
758 CoreError::Internal(format!("cannot write the project zip: {err}"))
759 };
760 for (path, bytes) in entries {
761 match bytes {
762 None => writer
763 .add_directory(path, entry_options(true))
764 .map_err(|e| zip_err(&e))?,
765 Some(bytes) => {
766 writer
767 .start_file(path, entry_options(false))
768 .map_err(|e| zip_err(&e))?;
769 writer.write_all(bytes).map_err(|e| zip_err(&e))?;
770 }
771 }
772 }
773 Ok(writer.finish().map_err(|e| zip_err(&e))?.into_inner())
774}
775
776#[derive(Debug, Clone, Copy)]
780struct Limits {
781 max_entries: usize,
782 max_zip_bytes: usize,
783}
784
785impl Limits {
786 const REAL: Limits = Limits {
787 max_entries: ARTIFACT_MAX_ENTRIES,
788 max_zip_bytes: IMPORT_MAX_BYTES,
789 };
790}
791
792pub fn build_artifact(repo: &Path) -> Result<Artifact, CoreError> {
794 build_artifact_limited(repo, Limits::REAL)
795}
796
797fn build_artifact_limited(repo: &Path, limits: Limits) -> Result<Artifact, CoreError> {
798 match std::fs::symlink_metadata(repo.join(PROJECT_JSON)) {
799 Ok(meta) if meta.file_type().is_file() => {}
800 Ok(meta) if meta.file_type().is_symlink() => {
801 return Err(invalid(PROJECT_JSON, "is a symlink; symlinks are refused"));
802 }
803 _ => {
804 return Err(invalid(
805 PROJECT_JSON,
806 "does not exist (a project repo holds project.json at its root)",
807 ));
808 }
809 }
810 let ignore = load_ignexclude(repo)?;
811 let mut cx = Cx {
812 repo,
813 ignore: &ignore,
814 files: BTreeMap::new(),
815 excluded: Vec::new(),
816 visited: 0,
817 max_entries: limits.max_entries,
818 };
819 cx.files.insert(
820 PROJECT_JSON.to_string(),
821 read_repo_file(repo, PROJECT_JSON, None)?,
822 );
823 for name in root_dirs(&mut cx)? {
824 descend(&mut cx, &name)?;
825 }
826 let Cx {
827 files,
828 mut excluded,
829 ..
830 } = cx;
831 if files.len() <= 1 {
832 return Err(invalid(
835 PROJECT_JSON,
836 "the checkout holds no adoptable member besides project.json; an import would empty the project",
837 ));
838 }
839 excluded.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
840 excluded.dedup();
841 let zip = write_zip(&files)?;
842 if zip.len() > limits.max_zip_bytes {
843 return Err(invalid(
844 PROJECT_JSON,
845 format!(
846 "the project zip is {} bytes, over the {} byte import limit",
847 zip.len(),
848 limits.max_zip_bytes
849 ),
850 ));
851 }
852 let digest = canonical_digest(&zip)?;
853 Ok(Artifact {
854 members: files.len(),
855 zip,
856 digest,
857 excluded,
858 })
859}
860
861#[cfg(test)]
862mod tests {
863 use super::*;
864
865 fn put(root: &Path, rel: &str, bytes: &[u8]) {
866 let path = root.join(rel);
867 std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
868 std::fs::write(path, bytes).expect("write");
869 }
870
871 fn checkout() -> tempfile::TempDir {
874 let dir = tempfile::tempdir().expect("tempdir");
875 let r = dir.path();
876 put(r, "project.json", br#"{"title":"T","enabled":true}"#);
877 put(
878 r,
879 "ignition/script-python/cap/resource.json",
880 br#"{"scope":"A","files":["code.py"],"attributes":{"lastModification":{"actor":"x"}}}"#,
881 );
882 put(
883 r,
884 "ignition/script-python/cap/code.py",
885 b"def f():\n pass\n",
886 );
887 put(
888 r,
889 "com.inductiveautomation.perspective/views/V/resource.json",
890 br#"{"files":["view.json"]}"#,
891 );
892 put(
893 r,
894 "com.inductiveautomation.perspective/views/V/view.json",
895 b"{}",
896 );
897 put(r, "ignition/script-python/cap/notes.txt", b"unlisted");
899 put(r, "docs/readme.md", b"root entry outside the allowlist");
900 put(r, ".github/workflows/x.yml", b"ci");
901 put(r, "deploy/project.yaml", b"schema_version: 1\n");
902 put(
903 r,
904 "ignition/script-python/loose/code.py",
905 b"no resource.json",
906 );
907 dir
908 }
909
910 fn zip_of(entries: &[(&str, &[u8])]) -> Vec<u8> {
911 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
912 for (name, bytes) in entries {
913 writer
914 .start_file(*name, zip::write::SimpleFileOptions::default())
915 .expect("start");
916 writer.write_all(bytes).expect("write");
917 }
918 writer.finish().expect("finish").into_inner()
919 }
920
921 #[test]
922 fn module_dir_names() {
923 for ok in [
924 "com.inductiveautomation.perspective",
925 "com.inductiveautomation.alarm-notification",
926 "a.b",
927 "a1_b-c.d2",
928 ] {
929 assert!(is_module_dir_name(ok), "{ok}");
930 }
931 for bad in [
932 "ignition", "", ".", "a.", ".a", "a..b", "A.b", "1a.b", "a.B", "deploy", "docs",
933 "a b.c",
934 ] {
935 assert!(!is_module_dir_name(bad), "{bad:?}");
936 }
937 }
938
939 #[test]
940 fn digest_counts_checkout_layout_members() {
941 let zip = zip_of(&[
942 ("project.json", br#"{"title":"T"}"#),
943 (
944 "ignition/script-python/a/resource.json",
945 br#"{"files":["code.py"]}"#,
946 ),
947 ("ignition/script-python/a/code.py", b"x"),
948 ]);
949 let digest = canonical_digest(&zip).expect("digest");
950 assert_eq!(digest.members.len(), 3, "never an empty set");
951 assert_eq!(digest.sha256.len(), 64);
952 assert!(
953 digest
954 .members
955 .contains_key("ignition/script-python/a/code.py")
956 );
957 }
958
959 #[test]
960 fn digest_ignores_entry_order_directories_and_volatile_fields() {
961 let a = zip_of(&[
962 ("project.json", br#"{"title":"T"}"#),
963 (
964 "ignition/s/resource.json",
965 br#"{"files":["b.py","a.py"],"attributes":{"lastModification":{"t":1},"lastModificationSignature":"s"}}"#,
966 ),
967 ("ignition/s/a.py", b"1"),
968 ("ignition/s/b.py", b"2"),
969 ]);
970 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
971 let opt = zip::write::SimpleFileOptions::default();
972 writer.add_directory("ignition/", opt).expect("dir");
973 for (name, bytes) in [
974 ("ignition/s/b.py", b"2".as_slice()),
975 ("ignition/s/a.py", b"1".as_slice()),
976 (
977 "ignition/s/resource.json",
978 br#"{"attributes":{"lastModification":{"t":99}},"files":["a.py","b.py"]}"#
979 .as_slice(),
980 ),
981 (
982 "project.json",
983 br#"{"parent":"","description":"","title":"T"}"#.as_slice(),
984 ),
985 ] {
986 writer.start_file(name, opt).expect("start");
987 writer.write_all(bytes).expect("write");
988 }
989 let b = writer.finish().expect("finish").into_inner();
990 assert_eq!(
991 canonical_digest(&a).expect("a"),
992 canonical_digest(&b).expect("b")
993 );
994 }
995
996 #[test]
997 fn an_empty_attributes_object_does_not_make_a_descriptor_changed() {
998 let authored = zip_of(&[
1001 ("project.json", b"{}"),
1002 ("ignition/s/resource.json", br#"{"files":["a.py"]}"#),
1003 ("ignition/s/a.py", b"1"),
1004 ]);
1005 let exported = zip_of(&[
1006 ("project.json", b"{}"),
1007 (
1008 "ignition/s/resource.json",
1009 br#"{"files":["a.py"],"attributes":{"lastModification":{"t":1},"lastModificationSignature":"s"}}"#,
1010 ),
1011 ("ignition/s/a.py", b"1"),
1012 ]);
1013 assert_eq!(
1014 canonical_digest(&authored).expect("authored"),
1015 canonical_digest(&exported).expect("exported")
1016 );
1017 }
1018
1019 #[test]
1020 fn defaults_the_gateway_adds_on_create_do_not_make_a_project_changed() {
1021 let authored = zip_of(&[
1025 ("project.json", br#"{"title":"CAPUAT","enabled":true}"#),
1026 (
1027 "ignition/script-python/uat/resource.json",
1028 br#"{"scope":"A","files":["code.py"],"attributes":{"enabled":true}}"#,
1029 ),
1030 ("ignition/script-python/uat/code.py", b"1\n"),
1031 ]);
1032 let exported = zip_of(&[
1033 (
1034 "project.json",
1035 b"{\n \"title\": \"CAPUAT\",\n \"description\": \"\",\n \"enabled\": true,\n \"inheritable\": false,\n \"parent\": \"\"\n}",
1036 ),
1037 (
1038 "ignition/script-python/uat/resource.json",
1039 b"{\n \"scope\": \"A\",\n \"version\": 1,\n \"restricted\": false,\n \"overridable\": true,\n \"files\": [\n \"code.py\"\n ],\n \"attributes\": {\n \"enabled\": true\n }\n}",
1040 ),
1041 ("ignition/script-python/uat/code.py", b"1\n"),
1042 ]);
1043 assert_eq!(
1044 canonical_digest(&authored).expect("authored"),
1045 canonical_digest(&exported).expect("exported")
1046 );
1047 }
1048
1049 #[test]
1050 fn a_default_written_with_another_value_still_counts() {
1051 let base = zip_of(&[
1052 ("project.json", b"{}"),
1053 ("ignition/s/resource.json", br#"{"files":[]}"#),
1054 ]);
1055 for (project, resource) in [
1056 (r#"{"inheritable":true}"#, r#"{"files":[]}"#),
1057 ("{}", r#"{"files":[],"version":2}"#),
1058 ("{}", r#"{"files":[],"restricted":true}"#),
1059 ("{}", r#"{"files":[],"overridable":false}"#),
1060 ] {
1061 let other = zip_of(&[
1062 ("project.json", project.as_bytes()),
1063 ("ignition/s/resource.json", resource.as_bytes()),
1064 ]);
1065 assert_ne!(
1066 canonical_digest(&base).expect("base").sha256,
1067 canonical_digest(&other).expect("other").sha256,
1068 "{project} {resource}"
1069 );
1070 }
1071 }
1072
1073 #[test]
1074 fn a_non_empty_attributes_object_still_counts() {
1075 let plain = zip_of(&[
1076 ("project.json", b"{}"),
1077 ("ignition/s/resource.json", br#"{"files":[]}"#),
1078 ]);
1079 let flagged = zip_of(&[
1080 ("project.json", b"{}"),
1081 (
1082 "ignition/s/resource.json",
1083 br#"{"files":[],"attributes":{"enabled":true}}"#,
1084 ),
1085 ]);
1086 assert_ne!(
1087 canonical_digest(&plain).expect("plain").sha256,
1088 canonical_digest(&flagged).expect("flagged").sha256
1089 );
1090 }
1091
1092 #[test]
1093 fn digest_sees_a_real_change() {
1094 let one = zip_of(&[("project.json", b"{}"), ("ignition/a/code.py", b"1")]);
1095 let two = zip_of(&[("project.json", b"{}"), ("ignition/a/code.py", b"2")]);
1096 assert_ne!(
1097 canonical_digest(&one).expect("one").sha256,
1098 canonical_digest(&two).expect("two").sha256
1099 );
1100 }
1101
1102 #[test]
1103 fn empty_member_set_is_refused() {
1104 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
1105 writer
1106 .add_directory("ignition/", zip::write::SimpleFileOptions::default())
1107 .expect("dir");
1108 let only_dirs = writer.finish().expect("finish").into_inner();
1109 let err = canonical_digest(&only_dirs).expect_err("empty");
1110 assert_eq!(err.code(), "deploy_tree_invalid");
1111 }
1112
1113 #[test]
1114 fn build_is_byte_identical_twice() {
1115 let dir = checkout();
1116 let first = build_artifact(dir.path()).expect("first");
1117 let second = build_artifact(dir.path()).expect("second");
1118 assert_eq!(first.zip, second.zip);
1119 assert_eq!(first.digest, second.digest);
1120 }
1121
1122 #[test]
1123 fn non_module_root_dir_is_left_out() {
1124 let dir = checkout();
1125 let artifact = build_artifact(dir.path()).expect("artifact");
1126 let keys: Vec<&str> = artifact.digest.members.keys().map(String::as_str).collect();
1127 assert_eq!(
1128 keys,
1129 [
1130 "com.inductiveautomation.perspective/views/V/resource.json",
1131 "com.inductiveautomation.perspective/views/V/view.json",
1132 "ignition/script-python/cap/code.py",
1133 "ignition/script-python/cap/resource.json",
1134 "project.json",
1135 ]
1136 );
1137 assert_eq!(artifact.members, 5);
1138 }
1139
1140 #[test]
1141 fn build_digest_equals_the_digest_of_an_equivalent_export() {
1142 let dir = checkout();
1143 let artifact = build_artifact(dir.path()).expect("artifact");
1144 let export = zip_of(&[
1145 (
1146 "project.json",
1147 br#"{"title":"T","enabled":true,"description":"","parent":""}"#,
1148 ),
1149 (
1150 "ignition/script-python/cap/resource.json",
1151 br#"{"scope":"A","files":["code.py"],"attributes":{"lastModification":{"actor":"y"},"lastModificationSignature":"z"}}"#,
1152 ),
1153 ("ignition/script-python/cap/code.py", b"def f():\n pass\n"),
1154 (
1155 "com.inductiveautomation.perspective/views/V/resource.json",
1156 br#"{"files":["view.json"]}"#,
1157 ),
1158 ("com.inductiveautomation.perspective/views/V/view.json", b"{}"),
1159 ]);
1160 assert_eq!(
1161 artifact.digest,
1162 canonical_digest(&export).expect("export digest")
1163 );
1164 }
1165
1166 fn excluded_of(dir: &tempfile::TempDir) -> Vec<String> {
1167 build_artifact(dir.path()).expect("artifact").excluded
1168 }
1169
1170 #[test]
1171 fn segment_globs() {
1172 assert!(segment_glob("*.pyc", "code.cpython-311.pyc"));
1173 assert!(segment_glob("*.pyc", ".pyc"));
1174 assert!(!segment_glob("*.pyc", "code.pyc.txt"));
1175 assert!(segment_glob("a*b*c", "aXXbYYc"));
1176 assert!(segment_glob("a*b*c", "abc"));
1177 assert!(!segment_glob("a*b*c", "aXXbYY"));
1178 assert!(segment_glob("*", ""));
1179 assert!(segment_glob("exact", "exact"));
1180 assert!(!segment_glob("exact", "exacts"));
1181 }
1182
1183 #[test]
1184 fn ignexclude_grammar() {
1185 let rules = IgnExclude::parse(
1186 b"# a comment\n\nfoo/\r\n a/*.txt \nignition/script-python/scratch\nb/*/c.py\n",
1187 )
1188 .expect("parse");
1189 assert!(rules.matches("foo", true));
1191 assert!(
1192 !rules.matches("foo", false),
1193 "a trailing / names a directory"
1194 );
1195 assert!(!rules.matches("foo/x", true), "the walk skips the subtree");
1196 assert!(rules.matches("a/one.txt", false));
1198 assert!(!rules.matches("a/b/one.txt", false));
1199 assert!(!rules.matches("a/one.py", false));
1200 assert!(rules.matches("ignition/script-python/scratch", true));
1202 assert!(rules.matches("ignition/script-python/scratch", false));
1203 assert!(rules.matches("b/x/c.py", false));
1205 assert!(!rules.matches("b/c.py", false));
1206 assert_eq!(
1208 IgnExclude::parse(b"foo/\r\nbar\r\n").expect("crlf"),
1209 IgnExclude::parse(b"foo/\nbar\n").expect("lf")
1210 );
1211 assert!(
1212 !IgnExclude::parse(b"# only\n\n\n")
1213 .expect("empty")
1214 .matches("only", false)
1215 );
1216 }
1217
1218 #[test]
1219 fn ignexclude_unsupported_constructs_refuse_with_the_line() {
1220 for (text, line) in [
1221 ("ok\n!x\n", 2),
1222 ("**/x\n", 1),
1223 ("a/**\n", 1),
1224 ("\n\n/abs\n", 3),
1225 ("../x\n", 1),
1226 ("a/../b\n", 1),
1227 ("a//b\n", 1),
1228 ("./a\n", 1),
1229 ("a\\b\n", 1),
1230 ("a\u{7}b\n", 1),
1231 ("a\tb\n", 1),
1232 ("\u{feff}ignition/\n", 1),
1234 ("ok\n\u{feff}ignition/\n", 2),
1235 ("a\u{200b}b\n", 1),
1236 ("a\u{202e}b\n", 1),
1237 ] {
1238 let err = IgnExclude::parse(text.as_bytes()).expect_err(text);
1239 assert_eq!(err.code(), "deploy_tree_invalid", "{text:?}");
1240 let shown = err.to_string();
1241 assert!(
1242 shown.contains(&format!(" {IGNEXCLUDE_FILE}:{line}:1:")),
1243 "{text:?}: {shown}"
1244 );
1245 }
1246 let err = IgnExclude::parse(&[0xff, 0xfe]).expect_err("not utf-8");
1247 assert!(err.to_string().contains(IGNEXCLUDE_FILE), "{err}");
1248 }
1249
1250 #[test]
1251 fn builtin_deny_any_depth() {
1252 let dir = checkout();
1253 let r = dir.path();
1254 for rel in [
1255 "ignition/.git/HEAD",
1256 "ignition/.github/x.yml",
1257 "ignition/script-python/.ign/deploy/gw/run/export/x.zip",
1258 "ignition/script-python/cap/__pycache__/code.cpython-311.pyc",
1259 "ignition/script-python/cap/stale.pyc",
1260 "ignition/node_modules/m/index.js",
1261 "com.inductiveautomation.perspective/.ign/run.json",
1262 "com.inductiveautomation.perspective/views/__pycache__/x.pyc",
1263 ] {
1264 put(r, rel, b"x");
1265 }
1266 let artifact = build_artifact(r).expect("artifact");
1267 for key in artifact.digest.members.keys() {
1268 for part in key.split('/') {
1269 assert!(!is_denied(part), "{key} holds the denied name {part}");
1270 }
1271 }
1272 for want in [
1273 "ignition/.git/",
1274 "ignition/.github/",
1275 "ignition/script-python/.ign/",
1276 "ignition/script-python/cap/__pycache__/",
1277 "ignition/script-python/cap/stale.pyc",
1278 "ignition/node_modules/",
1279 "com.inductiveautomation.perspective/.ign/",
1280 "com.inductiveautomation.perspective/views/__pycache__/",
1281 ] {
1282 assert!(
1283 artifact.excluded.iter().any(|e| e == want),
1284 "{want} missing from {:?}",
1285 artifact.excluded
1286 );
1287 }
1288 assert_eq!(artifact.members, 5);
1290 }
1291
1292 #[test]
1293 fn a_deploy_directory_below_a_module_directory_is_kept() {
1294 let dir = checkout();
1295 put(
1296 dir.path(),
1297 "ignition/script-python/deploy/resource.json",
1298 br#"{"files":["code.py"]}"#,
1299 );
1300 put(dir.path(), "ignition/script-python/deploy/code.py", b"pass");
1301 let artifact = build_artifact(dir.path()).expect("artifact");
1302 assert!(
1303 artifact
1304 .digest
1305 .members
1306 .contains_key("ignition/script-python/deploy/code.py")
1307 );
1308 }
1309
1310 #[test]
1311 fn excluded_lists_everything_left_out_byte_sorted() {
1312 let dir = checkout();
1313 let r = dir.path();
1314 put(r, "tags/t.json", b"{}");
1315 put(r, "images/i.png", b"x");
1316 put(r, ".ign/deploy/gw/run/export/x.zip", b"z");
1317 put(r, "README.md", b"root file");
1318 put(r, "tags.json", b"{}");
1319 assert_eq!(
1320 excluded_of(&dir),
1321 [
1322 ".github/",
1323 ".ign/",
1324 "README.md",
1325 "deploy/",
1326 "docs/",
1327 "ignition/script-python/cap/notes.txt",
1328 "ignition/script-python/loose/",
1329 "images/",
1330 "tags.json",
1331 "tags/",
1332 ]
1333 );
1334 }
1335
1336 #[test]
1337 fn a_folder_without_a_descriptor_is_listed_once_not_by_contents() {
1338 let dir = checkout();
1339 put(
1340 dir.path(),
1341 "ignition/script-python/loose/deep/more/a.py",
1342 b"x",
1343 );
1344 let excluded = excluded_of(&dir);
1345 assert!(excluded.contains(&"ignition/script-python/loose/".to_string()));
1346 assert!(excluded.iter().all(|e| !e.contains("loose/deep")));
1347 }
1348
1349 #[test]
1350 fn ignexclude_removes_paths_and_changes_the_digest() {
1351 let dir = checkout();
1352 let r = dir.path();
1353 put(
1354 r,
1355 "ignition/script-python/scratch/resource.json",
1356 br#"{"files":["s.py"]}"#,
1357 );
1358 put(r, "ignition/script-python/scratch/s.py", b"pass");
1359 let with_scratch = build_artifact(r).expect("without .ignexclude");
1360 assert!(
1361 with_scratch
1362 .digest
1363 .members
1364 .contains_key("ignition/script-python/scratch/s.py")
1365 );
1366
1367 put(
1368 r,
1369 ".ignexclude",
1370 b"# scratch\nignition/script-python/scratch/\n",
1371 );
1372 let without = build_artifact(r).expect("with .ignexclude");
1373 assert!(
1374 without
1375 .digest
1376 .members
1377 .keys()
1378 .all(|k| !k.contains("scratch"))
1379 );
1380 assert!(
1381 without
1382 .excluded
1383 .contains(&"ignition/script-python/scratch/".to_string())
1384 );
1385 assert!(without.excluded.contains(&".ignexclude".to_string()));
1386 assert_ne!(with_scratch.digest.sha256, without.digest.sha256);
1387 }
1388
1389 #[test]
1390 fn ignexclude_reaches_module_directories_and_single_files() {
1391 let dir = checkout();
1392 let r = dir.path();
1393 put(r, ".ignexclude", b"com.inductiveautomation.perspective/\n");
1394 let artifact = build_artifact(r).expect("artifact");
1395 assert!(
1396 artifact
1397 .digest
1398 .members
1399 .keys()
1400 .all(|k| !k.starts_with("com.inductiveautomation.perspective"))
1401 );
1402 assert!(
1403 artifact
1404 .excluded
1405 .contains(&"com.inductiveautomation.perspective/".to_string())
1406 );
1407 }
1408
1409 #[test]
1410 fn a_bad_ignexclude_refuses_naming_the_file_and_line() {
1411 let dir = checkout();
1412 put(dir.path(), ".ignexclude", b"ok/\n!no\n");
1413 let err = build_artifact(dir.path()).expect_err("negation");
1414 assert_eq!(err.code(), "deploy_tree_invalid");
1415 assert!(err.to_string().contains(" .ignexclude:2:1:"), "{err}");
1416 }
1417
1418 #[test]
1419 fn ignexclude_leaving_out_a_listed_file_refuses() {
1420 let dir = checkout();
1421 put(
1422 dir.path(),
1423 ".ignexclude",
1424 b"ignition/script-python/cap/code.py\n",
1425 );
1426 let err = build_artifact(dir.path()).expect_err("listed file removed");
1427 assert_eq!(err.code(), "deploy_tree_invalid");
1428 let text = err.to_string();
1429 assert!(text.contains("`code.py`"), "{text}");
1430 assert!(text.contains(IGNEXCLUDE_FILE), "{text}");
1431 }
1432
1433 #[cfg(unix)]
1434 #[test]
1435 fn a_symlinked_ignexclude_is_refused() {
1436 let dir = checkout();
1437 let outside = tempfile::tempdir().expect("outside");
1438 std::fs::write(outside.path().join("x"), b"a\n").expect("write");
1439 std::os::unix::fs::symlink(outside.path().join("x"), dir.path().join(".ignexclude"))
1440 .expect("symlink");
1441 let err = build_artifact(dir.path()).expect_err("symlink");
1442 assert!(err.to_string().contains(IGNEXCLUDE_FILE), "{err}");
1443 }
1444
1445 #[cfg(unix)]
1446 #[test]
1447 fn an_ignexcluded_symlink_is_left_out_not_refused() {
1448 let dir = checkout();
1449 let outside = tempfile::tempdir().expect("outside");
1450 std::fs::write(outside.path().join("secret"), b"s").expect("write");
1451 std::os::unix::fs::symlink(
1452 outside.path().join("secret"),
1453 dir.path().join("ignition/script-python/cap/link.py"),
1454 )
1455 .expect("symlink");
1456 put(
1457 dir.path(),
1458 ".ignexclude",
1459 b"ignition/script-python/cap/link.py\n",
1460 );
1461 let artifact = build_artifact(dir.path()).expect("artifact");
1462 assert!(
1463 artifact
1464 .excluded
1465 .contains(&"ignition/script-python/cap/link.py".to_string())
1466 );
1467 }
1468
1469 #[test]
1470 fn entry_cap_refuses() {
1471 let dir = checkout();
1472 let tight = Limits {
1473 max_entries: 3,
1474 ..Limits::REAL
1475 };
1476 let err = build_artifact_limited(dir.path(), tight).expect_err("cap");
1477 assert_eq!(err.code(), "deploy_tree_invalid");
1478 assert!(err.to_string().contains("too large"), "{err}");
1479 build_artifact(dir.path()).expect("under the real cap");
1480 }
1481
1482 #[test]
1483 fn size_cap_refuses() {
1484 let dir = checkout();
1485 let built = build_artifact(dir.path()).expect("artifact");
1486 let at_limit = Limits {
1487 max_zip_bytes: built.zip.len(),
1488 ..Limits::REAL
1489 };
1490 build_artifact_limited(dir.path(), at_limit).expect("exactly at the limit passes");
1491 let one_under = Limits {
1492 max_zip_bytes: built.zip.len() - 1,
1493 ..Limits::REAL
1494 };
1495 let err = build_artifact_limited(dir.path(), one_under).expect_err("size cap");
1496 assert_eq!(err.code(), "deploy_tree_invalid");
1497 assert!(err.to_string().contains("import limit"), "{err}");
1498 }
1499
1500 #[test]
1501 fn the_real_limits_are_the_documented_ones() {
1502 assert_eq!(Limits::REAL.max_entries, 200_000);
1503 assert_eq!(Limits::REAL.max_zip_bytes, 512 * 1024 * 1024);
1504 }
1505
1506 #[cfg(unix)]
1507 #[test]
1508 fn symlink_is_refused() {
1509 let dir = checkout();
1510 let outside = tempfile::tempdir().expect("outside");
1511 std::fs::write(outside.path().join("secret"), b"s").expect("write");
1512 std::os::unix::fs::symlink(
1513 outside.path().join("secret"),
1514 dir.path().join("ignition/script-python/cap/link.py"),
1515 )
1516 .expect("symlink");
1517 let err = build_artifact(dir.path()).expect_err("symlink");
1518 assert_eq!(err.code(), "deploy_tree_invalid");
1519 let text = err.to_string();
1520 assert!(
1521 text.contains("ignition/script-python/cap/link.py"),
1522 "{text}"
1523 );
1524 assert!(text.contains("symlink"), "{text}");
1525 }
1526
1527 #[cfg(unix)]
1528 #[test]
1529 fn symlink_among_excluded_root_entries_is_left_out() {
1530 let dir = checkout();
1531 let outside = tempfile::tempdir().expect("outside");
1532 std::os::unix::fs::symlink(outside.path(), dir.path().join("docs-link")).expect("symlink");
1533 let artifact = build_artifact(dir.path()).expect("an excluded root symlink is not walked");
1534 assert_eq!(artifact.members, 5);
1535 }
1536
1537 #[cfg(unix)]
1538 #[test]
1539 fn symlinked_allowed_top_level_directory_is_refused() {
1540 let dir = checkout();
1541 let outside = tempfile::tempdir().expect("outside");
1542 std::os::unix::fs::symlink(outside.path(), dir.path().join("com.example.mod"))
1543 .expect("symlink");
1544 let err = build_artifact(dir.path()).expect_err("symlinked module dir");
1545 assert!(err.to_string().contains("com.example.mod"), "{err}");
1546 }
1547
1548 #[cfg(unix)]
1549 #[test]
1550 fn symlinked_project_json_is_refused() {
1551 let dir = checkout();
1552 let outside = tempfile::tempdir().expect("outside");
1553 std::fs::write(outside.path().join("p.json"), b"{}").expect("write");
1554 std::fs::remove_file(dir.path().join("project.json")).expect("rm");
1555 std::os::unix::fs::symlink(
1556 outside.path().join("p.json"),
1557 dir.path().join("project.json"),
1558 )
1559 .expect("symlink");
1560 assert!(build_artifact(dir.path()).is_err());
1561 }
1562
1563 #[test]
1564 fn missing_project_json_refuses() {
1565 let dir = checkout();
1566 std::fs::remove_file(dir.path().join("project.json")).expect("rm");
1567 let err = build_artifact(dir.path()).expect_err("no project.json");
1568 assert_eq!(err.code(), "deploy_tree_invalid");
1569 assert!(
1570 err.to_string()
1571 .contains("a project repo holds project.json at its root"),
1572 "{err}"
1573 );
1574 }
1575
1576 #[test]
1577 fn directory_inside_a_resource_folder_refuses() {
1578 let dir = checkout();
1579 put(dir.path(), "ignition/script-python/cap/sub/x.txt", b"x");
1580 let err = build_artifact(dir.path()).expect_err("nested dir");
1581 let text = err.to_string();
1582 assert!(text.contains("ignition/script-python/cap/sub"), "{text}");
1583 assert!(text.contains("inside a resource folder"), "{text}");
1584 }
1585
1586 #[test]
1587 fn denylisted_directory_inside_a_resource_folder_is_left_out() {
1588 let dir = checkout();
1589 put(
1590 dir.path(),
1591 "ignition/script-python/cap/__pycache__/code.cpython-311.pyc",
1592 b"x",
1593 );
1594 let artifact = build_artifact(dir.path()).expect("artifact");
1595 assert!(
1596 artifact
1597 .digest
1598 .members
1599 .keys()
1600 .all(|k| !k.contains("__pycache__"))
1601 );
1602 }
1603
1604 #[test]
1605 fn listed_but_missing_file_refuses() {
1606 let dir = checkout();
1607 std::fs::remove_file(dir.path().join("ignition/script-python/cap/code.py")).expect("rm");
1608 let err = build_artifact(dir.path()).expect_err("listed missing");
1609 assert_eq!(err.code(), "deploy_tree_invalid");
1610 assert!(err.to_string().contains("`code.py`"), "{err}");
1611 }
1612
1613 #[test]
1614 fn listed_missing_refuses() {
1615 let dir = checkout();
1616 put(
1617 dir.path(),
1618 "ignition/script-python/cap/resource.json",
1619 br#"{"scope":"A","files":["code.py","extra.py"]}"#,
1620 );
1621 let err = build_artifact(dir.path()).expect_err("extra.py is absent");
1622 assert_eq!(err.code(), "deploy_tree_invalid");
1623 let text = err.to_string();
1624 assert!(text.contains("`extra.py`"), "{text}");
1625 assert!(
1626 text.contains("ignition/script-python/cap/resource.json"),
1627 "the location is repo-relative: {text}"
1628 );
1629 assert!(
1630 !text.contains("def f()"),
1631 "no file content in the refusal: {text}"
1632 );
1633 }
1634
1635 #[test]
1636 fn only_project_json_refuses() {
1637 let dir = tempfile::tempdir().expect("tempdir");
1638 put(dir.path(), "project.json", br#"{"title":"T"}"#);
1639 let err = build_artifact(dir.path()).expect_err("project.json alone");
1640 assert_eq!(err.code(), "deploy_tree_invalid");
1641 assert!(err.to_string().contains("besides project.json"), "{err}");
1642
1643 put(dir.path(), "ignition/script-python/loose/code.py", b"x");
1645 put(dir.path(), "docs/readme.md", b"x");
1646 let err = build_artifact(dir.path()).expect_err("nothing adoptable");
1647 assert_eq!(err.code(), "deploy_tree_invalid");
1648
1649 put(
1651 dir.path(),
1652 "ignition/script-python/ok/resource.json",
1653 br#"{"files":[]}"#,
1654 );
1655 assert_eq!(build_artifact(dir.path()).expect("one member").members, 2);
1656 }
1657
1658 #[test]
1659 fn an_ignexclude_that_removes_every_member_refuses() {
1660 let dir = checkout();
1661 put(dir.path(), ".ignexclude", b"ignition/\ncom.*/\n");
1662 let err = build_artifact(dir.path()).expect_err("nothing left");
1663 assert_eq!(err.code(), "deploy_tree_invalid");
1664 }
1665
1666 #[cfg(unix)]
1667 #[test]
1668 fn non_utf8_name_refuses() {
1669 use std::os::unix::ffi::OsStrExt;
1670 let bad = std::ffi::OsStr::from_bytes(b"bad-\xff.py");
1671 let err = utf8_name("ignition/script-python/cap", bad).expect_err("non-UTF-8");
1672 assert_eq!(err.code(), "deploy_tree_invalid");
1673 let text = err.to_string();
1674 assert!(text.contains("not valid UTF-8"), "{text}");
1675 assert!(text.contains("ignition/script-python/cap/bad-"), "{text}");
1676 assert_eq!(
1677 utf8_name("a", std::ffi::OsStr::new("ok.py")).expect("utf-8"),
1678 "ok.py"
1679 );
1680
1681 let dir = checkout();
1683 let path = dir.path().join("ignition/script-python/cap").join(bad);
1684 if std::fs::write(&path, b"x").is_ok() {
1685 let err = build_artifact(dir.path()).expect_err("walk refuses");
1686 assert_eq!(err.code(), "deploy_tree_invalid");
1687 assert!(err.to_string().contains("not valid UTF-8"), "{err}");
1688 } else {
1689 assert!(!path.exists());
1692 }
1693 }
1694
1695 #[test]
1696 fn unlisted_and_dot_files_are_left_out_but_listed_dot_files_ride() {
1697 let dir = checkout();
1698 put(dir.path(), "ignition/script-python/cap/.gitkeep", b"");
1699 put(
1700 dir.path(),
1701 "ignition/script-python/cap/resource.json",
1702 br#"{"files":["code.py",".gitkeep"]}"#,
1703 );
1704 let artifact = build_artifact(dir.path()).expect("artifact");
1705 let keys: Vec<&String> = artifact.digest.members.keys().collect();
1706 assert!(keys.iter().any(|k| k.ends_with("cap/.gitkeep")));
1707 assert!(keys.iter().all(|k| !k.ends_with("notes.txt")));
1708 }
1709
1710 #[test]
1711 fn a_webdev_resources_directory_keeps_its_name() {
1712 let dir = checkout();
1713 put(
1714 dir.path(),
1715 "com.inductiveautomation.webdev/resources/route/resource.json",
1716 br#"{"files":["doGet.py"]}"#,
1717 );
1718 put(
1719 dir.path(),
1720 "com.inductiveautomation.webdev/resources/route/doGet.py",
1721 b"pass",
1722 );
1723 let artifact = build_artifact(dir.path()).expect("artifact");
1724 assert!(
1725 artifact
1726 .digest
1727 .members
1728 .contains_key("com.inductiveautomation.webdev/resources/route/doGet.py")
1729 );
1730 }
1731
1732 #[test]
1733 fn zip_holds_a_directory_entry_for_every_ancestor_in_byte_order() {
1734 let dir = checkout();
1735 let artifact = build_artifact(dir.path()).expect("artifact");
1736 let mut archive =
1737 zip::ZipArchive::new(std::io::Cursor::new(artifact.zip.as_slice())).expect("zip");
1738 let names: Vec<String> = (0..archive.len())
1739 .map(|i| archive.by_index(i).expect("entry").name().to_string())
1740 .collect();
1741 assert!(names.contains(&"ignition/".to_string()));
1742 assert!(names.contains(&"ignition/script-python/cap/".to_string()));
1743 let mut sorted = names.clone();
1744 sorted.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
1745 assert_eq!(names, sorted);
1746 }
1747
1748 use proptest::prelude::*;
1751
1752 #[derive(Debug, Clone)]
1755 struct Shape {
1756 files: Vec<(String, Vec<u8>)>,
1757 order: Vec<u64>,
1758 files_order: Vec<u64>,
1759 key_order: Vec<u64>,
1760 deflate: bool,
1761 with_attrs: bool,
1762 stamp: (u32, String, String),
1763 }
1764
1765 fn shape() -> impl Strategy<Value = Shape> {
1766 (
1767 proptest::collection::btree_map(
1768 "[a-z]{1,6}\\.py",
1769 proptest::collection::vec(any::<u8>(), 1..24),
1770 1..6,
1771 ),
1772 proptest::collection::vec(any::<u64>(), 16),
1773 proptest::collection::vec(any::<u64>(), 16),
1774 proptest::collection::vec(any::<u64>(), 3),
1775 any::<bool>(),
1776 any::<bool>(),
1777 (any::<u32>(), "[a-zA-Z0-9]{0,12}", "[a-zA-Z0-9+/=]{0,24}"),
1778 )
1779 .prop_map(
1780 |(files, order, files_order, key_order, deflate, with_attrs, stamp)| Shape {
1781 files: files.into_iter().collect(),
1782 order,
1783 files_order,
1784 key_order,
1785 deflate,
1786 with_attrs,
1787 stamp,
1788 },
1789 )
1790 }
1791
1792 fn shuffled<T: Clone>(items: &[T], keys: &[u64]) -> Vec<T> {
1794 let mut keyed: Vec<(u64, usize)> = (0..items.len())
1795 .map(|i| (keys[i % keys.len()].rotate_left(i as u32 % 64), i))
1796 .collect();
1797 keyed.sort();
1798 keyed.into_iter().map(|(_, i)| items[i].clone()).collect()
1799 }
1800
1801 fn descriptor(shape: &Shape, names: &[String]) -> Vec<u8> {
1804 let listed = shuffled(names, &shape.files_order);
1805 let files = serde_json::to_string(&listed).expect("files");
1806 let (millis, actor, signature) = &shape.stamp;
1807 let mut parts = vec![format!("\"files\":{files}"), "\"scope\":\"A\"".to_string()];
1808 if shape.with_attrs {
1809 parts.push(format!(
1810 "\"attributes\":{{\"lastModificationSignature\":{sig},\"lastModification\":{{\"actor\":{act},\"timestamp\":\"{millis}\"}}}}",
1811 sig = serde_json::to_string(signature).expect("sig"),
1812 act = serde_json::to_string(actor).expect("actor"),
1813 ));
1814 }
1815 let parts = shuffled(&parts, &shape.key_order);
1816 format!("{{{}}}", parts.join(",")).into_bytes()
1817 }
1818
1819 fn project_zip(shape: &Shape, rename: Option<usize>, flip: Option<usize>) -> Vec<u8> {
1822 let mut files = shape.files.clone();
1823 if let Some(i) = rename {
1824 let i = i % files.len();
1825 files[i].0 = format!("{}_r.py", files[i].0.trim_end_matches(".py"));
1826 }
1827 if let Some(i) = flip {
1828 let i = i % files.len();
1829 let at = i % files[i].1.len();
1830 files[i].1[at] ^= 0x5a;
1831 }
1832 let names: Vec<String> = files.iter().map(|(n, _)| n.clone()).collect();
1833 let mut entries: Vec<(String, Vec<u8>)> = vec![
1834 ("project.json".to_string(), br#"{"title":"T"}"#.to_vec()),
1835 (
1836 "ignition/script-python/p/resource.json".to_string(),
1837 descriptor(shape, &names),
1838 ),
1839 ];
1840 for (name, bytes) in files {
1841 entries.push((format!("ignition/script-python/p/{name}"), bytes));
1842 }
1843 let entries = shuffled(&entries, &shape.order);
1844 let method = if shape.deflate {
1845 zip::CompressionMethod::Deflated
1846 } else {
1847 zip::CompressionMethod::Stored
1848 };
1849 let options = zip::write::SimpleFileOptions::default().compression_method(method);
1850 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
1851 for (name, bytes) in entries {
1852 writer.start_file(name, options).expect("start");
1853 writer.write_all(&bytes).expect("write");
1854 }
1855 writer.finish().expect("finish").into_inner()
1856 }
1857
1858 fn reference(shape: &Shape) -> Shape {
1861 Shape {
1862 order: vec![0],
1863 files_order: vec![0],
1864 key_order: vec![0],
1865 deflate: false,
1866 with_attrs: false,
1867 stamp: (0, String::new(), String::new()),
1868 ..shape.clone()
1869 }
1870 }
1871
1872 proptest! {
1873 #![proptest_config(ProptestConfig::with_cases(256))]
1874
1875 #[test]
1880 fn digest_invariants(shape in shape()) {
1881 let base = canonical_digest(&project_zip(&reference(&shape), None, None)).expect("base");
1882 let variant = canonical_digest(&project_zip(&shape, None, None)).expect("variant");
1883 prop_assert_eq!(&base.sha256, &variant.sha256);
1884 prop_assert_eq!(&base.members, &variant.members);
1885 }
1886
1887 #[test]
1889 fn digest_sees_a_byte_change_and_a_rename(shape in shape(), pick in 0usize..64) {
1890 let base = canonical_digest(&project_zip(&shape, None, None)).expect("base");
1891 let flipped = canonical_digest(&project_zip(&shape, None, Some(pick))).expect("flipped");
1892 prop_assert_ne!(&base.sha256, &flipped.sha256);
1893 let renamed = canonical_digest(&project_zip(&shape, Some(pick), None)).expect("renamed");
1894 prop_assert_ne!(&base.sha256, &renamed.sha256);
1895 }
1896 }
1897}