Skip to main content

ignition_core/deploy/
project_artifact.rs

1//! The project artifact and its canonical digest (Phase 25, D-01, D-09).
2//!
3//! [`build_artifact`] zips a project repo's checkout into the zip an
4//! overwrite import would upload. [`canonical_digest`] reduces any project
5//! zip (the artifact, or the gateway's export of the live project) to one
6//! sha256 that does not depend on zip entry order, directory entries or the
7//! volatile fields the gateway rewrites on import, so two zips of the same
8//! project compare equal and a real change does not.
9//!
10//! ## Why the artifact is narrower than "the repo"
11//!
12//! An overwrite import replaces the whole project tree and the gateway drops,
13//! without saying so, anything it does not adopt (live captures C8a, C8d). So
14//! the artifact holds exactly what the gateway keeps (contract K1 to K4):
15//!
16//! - the layout is the checkout layout, verbatim; the canonical key of a
17//!   member is its zip path and no `resources` segment is ever stripped by
18//!   name (`com.inductiveautomation.webdev/resources` is a real checkout
19//!   directory);
20//! - the root `project.json`, plus the top-level directories named
21//!   `ignition` or shaped like a module id ([`is_module_dir_name`]); every
22//!   other root entry is left out;
23//! - below those, every directory holding a `resource.json` contributes that
24//!   file and exactly the files its `files` array lists. A listed file that
25//!   is missing, or a directory inside a resource folder, refuses
26//!   `deploy_tree_invalid`: the gateway would drop the whole resource;
27//! - `.git`, `.github`, `.ign`, `node_modules`, `__pycache__` and `*.pyc` are
28//!   never part of the walk, at any depth ([`BUILTIN_DENY`]). `deploy` is
29//!   denied at the repo root only, and the root allowlist already does that;
30//! - an optional `.ignexclude` at the repo root removes more paths
31//!   ([`IgnExclude`]);
32//! - everything left out is reported in [`Artifact::excluded`], so a reviewer
33//!   sees what stays out of the uploaded project;
34//! - a symlink or special file in the walk refuses `deploy_tree_invalid`
35//!   naming its repo-relative path, and is never followed;
36//! - a name inside the walk that is not valid UTF-8, a walk past
37//!   [`ARTIFACT_MAX_ENTRIES`] entries, a built zip over `IMPORT_MAX_BYTES`
38//!   and an artifact holding no member besides `project.json` each refuse
39//!   `deploy_tree_invalid` too, before any gateway contact.
40//!
41//! The build is deterministic: entries in byte order of their zip path, one
42//! fixed timestamp and permission bits, Deflate. Two builds of one checkout
43//! are byte-identical.
44
45use 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
54/// The folder descriptor every resource folder carries.
55const RESOURCE_JSON: &str = "resource.json";
56
57/// The project descriptor at the root.
58const PROJECT_JSON: &str = "project.json";
59
60/// The canonical form of a project zip.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct CanonicalDigest {
63    /// Lowercase hex sha256 over the `key NUL member-sha256 LF` lines, in
64    /// byte order of the key.
65    pub sha256: String,
66    /// Member key (the zip path) to the sha256 of its canonical bytes.
67    pub members: BTreeMap<String, String>,
68}
69
70/// A built artifact.
71#[derive(Debug, Clone)]
72pub struct Artifact {
73    /// The zip bytes an import would upload.
74    pub zip: Vec<u8>,
75    /// Its canonical digest.
76    pub digest: CanonicalDigest,
77    /// File members in the zip (directory entries are not counted).
78    pub members: usize,
79    /// Repo-relative paths left out of the artifact, byte-sorted. A directory
80    /// is listed once with a trailing `/`, never its contents.
81    pub excluded: Vec<String>,
82}
83
84/// The repo-root file listing extra paths to leave out of the artifact.
85pub const IGNEXCLUDE_FILE: &str = ".ignexclude";
86
87/// Names left out of the artifact at any depth (K3). A pattern may hold `*`,
88/// which matches within one path segment. `deploy` is not here: it is denied
89/// at the repo root only, which the root allowlist already does, so a script
90/// package or view named `deploy` below a module directory is kept.
91pub const BUILTIN_DENY: &[&str] = &[
92    ".git",
93    ".github",
94    ".ign",
95    "node_modules",
96    "__pycache__",
97    "*.pyc",
98];
99
100/// Most entries the walk may visit before the build refuses (T-25-17).
101pub const ARTIFACT_MAX_ENTRIES: usize = 200_000;
102
103/// Whether `name` is a module directory name: `^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)+$`
104/// (`com.inductiveautomation.perspective`). `ignition` is the one top-level
105/// directory that does not match and is allowed by name instead (K3).
106pub 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
127/// Whether `name` matches `pattern`, where `*` matches any run of characters.
128/// Both are single path segments, so `*` never crosses a `/`.
129fn 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
151/// Whether a walked name is on the any-depth denylist (K3).
152fn is_denied(name: &str) -> bool {
153    BUILTIN_DENY
154        .iter()
155        .any(|pattern| segment_glob(pattern, name))
156}
157
158/// One `.ignexclude` line: a repo-relative path, one glob per segment.
159#[derive(Debug, Clone, PartialEq, Eq)]
160struct IgnRule {
161    segments: Vec<String>,
162    /// A trailing `/`: matches directories only.
163    dir_only: bool,
164}
165
166/// Whether `c` is a non-printing format, line or paragraph separator
167/// character (Unicode Cf, Zl, Zp) that `char::is_control` does not catch: a
168/// byte order mark, a zero-width space, a bidi override. Such a character in a
169/// rule would make its segment match nothing, silently.
170fn 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/// The parsed `.ignexclude` (D-09, K3). One repo-relative path per line; a
188/// trailing `/` names a directory subtree; `*` matches within one segment;
189/// `#` starts a comment line; blank lines are ignored; spaces and tabs around
190/// a line are trimmed; one trailing CR per line is tolerated. There is no
191/// negation: `!`, `**`, an absolute path, a `.` or `..` component, a
192/// backslash, a control character or a non-printing format character (a byte
193/// order mark) refuse `deploy_tree_invalid` naming
194/// `.ignexclude` and the line.
195#[derive(Debug, Clone, Default, PartialEq, Eq)]
196pub struct IgnExclude {
197    rules: Vec<IgnRule>,
198}
199
200impl IgnExclude {
201    /// Parse the bytes of a `.ignexclude`.
202    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    /// Whether the repo-relative path `rel` is excluded. A match on a
262    /// directory leaves out its whole subtree, because the walk never
263    /// descends into it.
264    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
278/// Read the repo's `.ignexclude`; absent is no rules.
279fn 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
296/// `rel` as an `excluded` entry: a directory carries a trailing `/`.
297fn 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
309/// Sort the top-level `files` array of a `resource.json` document in place
310/// (K4 row 1): the gateway re-sorts it on import, so order carries no
311/// meaning.
312fn 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
328/// Drop an `attributes` key whose value is an empty object. The gateway adds
329/// `attributes` (holding only the two volatile stamps) to a descriptor that
330/// had none; once the stamps are normalised away an empty object is all that
331/// is left, and it must not make a hand-authored descriptor read as changed.
332fn 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
343/// Insert each `(key, default)` the object lacks (or holds as null). The
344/// gateway writes these defaults into a descriptor on import, so an absent key
345/// and its default must hash the same; any other value still counts.
346fn 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
357/// K4 row 1: `normalize_descriptor`, then an empty `attributes` object
358/// dropped, the `files` array sorted, and an absent `version`, `restricted`
359/// or `overridable` read as `1`, `false` and `true`, the values the gateway
360/// adds on import (UAT run 40755290379). Bytes that are not a JSON object
361/// hash raw.
362fn 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::Map` is a BTreeMap here, so keys stay sorted.
382    serde_json::to_vec(&value).unwrap_or_else(|_| bytes.to_vec())
383}
384
385/// K4 row 2: `project.json` parsed, an absent (or null) `description` or
386/// `parent` read as the empty string and an absent `inheritable` as `false`,
387/// keys sorted. The gateway adds all three keys on first import. Bytes that
388/// are not a JSON object hash raw.
389fn 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
407/// The canonical bytes of one member, chosen by its basename and place.
408fn 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
418/// The digest over a member map: `key NUL hash LF` lines in key byte order.
419fn 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
430/// Reduce a project zip to its canonical digest (K4). Every file entry is
431/// hashed under its zip path; directory entries and entry order are ignored.
432/// An empty member set is `deploy_tree_invalid`: an import of nothing would
433/// empty the project (C8a), so it must never compare equal to anything.
434pub 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
473/// One directory entry of the walk, with its repo-relative path.
474struct 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
486/// What the walk carries: the repo, the `.ignexclude` rules, the members
487/// found so far, the paths left out and the entries visited.
488struct 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    /// Count one visited entry; refuse past the cap (T-25-17).
499    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
514/// `name` as UTF-8, or a refusal naming its place below `rel` (lossily, so
515/// the message is printable). Zip paths are UTF-8.
516fn 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
525/// The sorted children of `rel` (a directory inside an allowed top-level
526/// directory). Denylisted and `.ignexclude`d names are left out and recorded
527/// in `cx.excluded`, never examined further. A symlink, a special file or a
528/// name that is not a plain UTF-8 component refuses.
529fn 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
580/// The `files` array of a `resource.json` as plain single-component names.
581fn 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
621/// Walk `rel` (a directory inside an allowed top-level directory), adding the
622/// members of every resource folder to `cx.files` and everything else to
623/// `cx.excluded`.
624fn 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        // A resource folder: exactly the descriptor and the files it lists.
631        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
673/// [`walk`] a directory; when nothing below it is a member, report the
674/// directory once instead of what it holds.
675fn 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
686/// Sort out the repo root (K3): the allowed top-level directories are walked
687/// (byte-sorted), every other root entry is recorded in `cx.excluded`. Root
688/// entries outside the allowlist are not examined, so what they are (a
689/// symlink, a file, a name that is not UTF-8) cannot refuse. An allowed name
690/// that is a symlink refuses; one that is a file is left out.
691fn 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        // `tags.json` matches the module-id shape: a file is simply not a
721        // module directory.
722        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
732/// Fixed entry options: Deflate, the zip epoch, fixed permission bits.
733fn 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
740/// Zip `files` (zip path to bytes) with a directory entry for every ancestor
741/// folder, all entries in byte order of their zip path.
742fn 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/// The limits a build enforces. The real ones are [`ARTIFACT_MAX_ENTRIES`] and
777/// `IMPORT_MAX_BYTES`; a test passes small ones so it can reach the refusals
778/// without a half-gigabyte checkout.
779#[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
792/// Build the artifact from the checkout at `repo` (D-09, K1 to K3).
793pub 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        // An overwrite import of project.json alone would empty the project
833        // (C8a): nothing below the module directories is adoptable.
834        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    /// A checkout: root project.json, one script folder, one view folder, and
872    /// noise the artifact must leave out.
873    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        // Noise.
898        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        // Hand-authored: no `attributes`. The gateway's export of it carries
999        // `attributes` holding only the two stamps.
1000        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        // The UAT run of 2026-10-28 (run 40755290379): a hand-written project
1022        // created on an 8.3 gateway reads back with `inheritable`, `version`,
1023        // `restricted` and `overridable` added, byte for byte as below.
1024        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        // `foo/` is a directory subtree.
1190        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        // `*` stays within one segment.
1197        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        // A path without a trailing slash names a file or a directory.
1201        assert!(rules.matches("ignition/script-python/scratch", true));
1202        assert!(rules.matches("ignition/script-python/scratch", false));
1203        // A star in the middle segment.
1204        assert!(rules.matches("b/x/c.py", false));
1205        assert!(!rules.matches("b/c.py", false));
1206        // Comments and blank lines add nothing; CRLF parses like LF.
1207        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            // A BOM at the start, a zero-width space and a bidi override.
1233            ("\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        // The same five members as without the noise.
1289        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        // Module directories that hold nothing adoptable are the same case.
1644        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        // One adopted resource is enough.
1650        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        // Through the walk, on a filesystem that lets the name exist.
1682        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            // APFS refuses to create the name (EILSEQ), so it cannot be in a
1690            // checkout there; the pure check above is the coverage.
1691            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    // -- digest_invariants -------------------------------------------------
1749
1750    use proptest::prelude::*;
1751
1752    /// One generated project: file names with contents, and the knobs a
1753    /// second runner or a gateway round trip may turn.
1754    #[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    /// `items` sorted by the random keys, cycling when there are fewer keys.
1793    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    /// The `resource.json` text: top-level keys in a shuffled order, the
1802    /// `files` array in a shuffled order, volatile stamps when asked.
1803    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    /// The project as a zip. `rename` swaps one member's file name; `flip`
1820    /// changes one byte of one file's content.
1821    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    /// The reference form: sorted everything, stored, no stamps, no
1859    /// attributes.
1860    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        /// DPRJ-03: the same members give the same digest in any entry
1876        /// order, with either compression method, with any
1877        /// `lastModification` and signature values (or none), with any key
1878        /// order and `files` order inside `resource.json`.
1879        #[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        /// A one-byte content change and a member rename each change the digest.
1888        #[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}