Skip to main content

text_mirror/convert/
provider.rs

1//! The deployment-owned provider configuration: the opt-in surface that
2//! wires an external rasterizer into the svg leg.
3//!
4//! Two halves meet here. The expectations, a BLAKE3, an exact version
5//! string, and an aggregate digest over the enumerated closure, are
6//! versioned rules data, pinned per generic role label in the
7//! `[image_ocr.svg_provider]` section exactly as the audio and image
8//! runtimes pin theirs. The paths are deployment data: a deployment
9//! writes one TOML file naming, per role label, the absolute path of the
10//! executable that fills it and the two jail parameters its runtime
11//! needs. The crate ships no path, no product name, and no default: with
12//! no configuration the svg leg does not exist, and the jailed worker
13//! re-hashes every supplied file against the rules-pinned expectation
14//! before every execution.
15//!
16//! The schema is deliberately narrow. It carries exactly one provider,
17//! `svg`, with exactly two roles, and `deny_unknown_fields` refuses
18//! anything else, so a configuration that names another provider or
19//! another role fails the run before traversal rather than quietly
20//! enabling a capability this release never validated.
21
22use std::collections::BTreeMap;
23use std::path::{Path, PathBuf};
24
25use serde::{Deserialize, Serialize};
26
27use crate::{Error, Result};
28
29/// The schema identifier every provider configuration must carry.
30pub const PROVIDER_SCHEMA: &str = "text-mirror/provider@1";
31
32/// The rasterizer role: the executable that renders the source to a
33/// raster. Generic label only.
34pub const PROVIDER_ROLE_RASTER: &str = "raster-browser";
35
36/// The encoder role: the executable that flattens the raster onto an
37/// opaque background. Generic label only.
38pub const PROVIDER_ROLE_ENCODER: &str = "raster-magick";
39
40/// The two roles one complete svg provider is made of. Both must be
41/// present, or the configuration is refused.
42pub const PROVIDER_ROLES: [&str; 2] = [PROVIDER_ROLE_RASTER, PROVIDER_ROLE_ENCODER];
43
44/// Version of the svg provider adapter: the geometry derivation, the
45/// fixed argument vectors, the per-exec assertion order, and the
46/// closure verification. It is part of the effective rules version, so
47/// a change here re-runs every provider-derived child.
48pub const PROVIDER_ADAPTER_VERSION: &str = "1.0.0";
49
50/// The per-axis geometry tolerance, in pixels, applied to both
51/// cross-axis checks.
52pub const PROVIDER_GEOMETRY_TOLERANCE_PX: i64 = 1;
53
54/// The ordered flatten arguments, one constant so production and test
55/// executions cannot drift apart. The trailing chunk exclusion is part
56/// of the settled tuple: without it the encoder writes creation-time
57/// chunks and the raster stops being byte-stable across cold starts.
58pub const PROVIDER_FLATTEN_ARGS: [&str; 8] = [
59    "-background",
60    "white",
61    "-alpha",
62    "remove",
63    "-alpha",
64    "off",
65    "-define",
66    "png:exclude-chunks=date,time",
67];
68
69/// The rules-pinned expectations for one provider role: what the
70/// jailed worker re-asserts about the executable a deployment supplies
71/// for it, immediately before every execution.
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73#[serde(deny_unknown_fields)]
74pub struct ProviderPin {
75    /// Expected lowercase hex BLAKE3 of the executable's bytes.
76    pub blake3: String,
77    /// Exact version string the executable must report. Compared for
78    /// equality against a token of its own version output; the raw
79    /// output never leaves the worker.
80    pub version: String,
81    /// Aggregate closure digest over the role's enumerated closure: for
82    /// each entry, the sorted relative path and BLAKE3 of every regular
83    /// file under it with the pinned launcher excluded, a symlink by
84    /// the target it names, then the sorted per-entry digests combined.
85    /// Every regular file counts, not only the executables, because a
86    /// policy file or a resource decides behavior as much as code does. Asserted before every
87    /// execution beside the launcher hash, so a dependency that drifts
88    /// while the launcher stands still is refused. Required for any
89    /// role whose configuration enumerates a closure.
90    #[serde(default)]
91    pub closure_blake3: Option<String>,
92}
93
94/// One configured role: the deployment's path and the jail parameters
95/// its runtime needs. Everything the worker asserts about the file
96/// comes from the rules, never from here.
97#[derive(Debug, Clone, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct ProviderRole {
100    /// Absolute path of the executable filling this role. Deployment
101    /// data: it never enters the versioned rules, a manifest record, an
102    /// error message, or the effective rules version.
103    pub path: PathBuf,
104    /// The closure this role additionally needs to read and
105    /// execute, enumerated: the helper and framework tree beside a
106    /// launcher, or the specific library directories and files an
107    /// encoder loads. Each entry is granted as its own subpath in the
108    /// provider jail, so a directory grants its subtree and a file
109    /// grants itself.
110    ///
111    /// Enumerate the measured dependencies, never a package store or
112    /// any other shared root: everything under an entry is readable and
113    /// executable inside the jail, and a shared root would put every
114    /// unrelated package, and every other version of this one, inside
115    /// the boundary. The rules-pinned aggregate digest over these
116    /// entries is the integrity control that stands in for finer
117    /// granularity, and it is required whenever this list is non-empty.
118    #[serde(default)]
119    pub closure_roots: Vec<PathBuf>,
120    /// The service namespace the provider jail scopes registration to,
121    /// when the role's runtime registers any. Deployment data, because
122    /// the namespace names the installed product. Its shape is a closed
123    /// grammar, not a free expression: see [`is_service_namespace`].
124    #[serde(default)]
125    pub jail_service_prefix: Option<String>,
126    /// Name of an additional environment variable the worker points at
127    /// the jail's temp directory, when the role's runtime reads one.
128    /// Deployment data for the same reason, and a bounded identifier.
129    #[serde(default)]
130    pub jail_temp_env: Option<String>,
131}
132
133/// The svg provider: both roles, or no provider at all.
134#[derive(Debug, Clone, Deserialize)]
135#[serde(deny_unknown_fields)]
136struct RawProviders {
137    #[serde(default)]
138    svg: Option<BTreeMap<String, ProviderRole>>,
139}
140
141#[derive(Debug, Deserialize)]
142#[serde(deny_unknown_fields)]
143struct RawProviderConfig {
144    schema: String,
145    #[serde(default)]
146    providers: Option<RawProviders>,
147}
148
149/// A validated provider configuration.
150///
151/// `svg` is `Some` only when both roles were supplied and every field
152/// validated. Every other input is either an empty configuration, which
153/// leaves svg on its passthrough-only path, or a run configuration
154/// error raised before traversal.
155#[derive(Debug, Clone, Default)]
156pub struct ProviderConfig {
157    svg: Option<SvgProvider>,
158}
159
160/// The complete svg provider: the rasterizer and the encoder.
161#[derive(Debug, Clone)]
162pub struct SvgProvider {
163    /// The rasterizer role.
164    pub raster: ProviderRole,
165    /// The encoder role.
166    pub encoder: ProviderRole,
167}
168
169fn rules_error(message: String) -> Error {
170    Error::Rules {
171        name: "provider-config".to_string(),
172        message,
173    }
174}
175
176/// Whether a string is a lowercase hex BLAKE3.
177fn is_lower_hex_256(value: &str) -> bool {
178    value.len() == 64
179        && value
180            .chars()
181            .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
182}
183
184/// Whether a version string is safe to compare and to name in a
185/// configuration: printable ASCII without quotes or whitespace, so it
186/// can never carry a path, a banner line, or a host identity.
187fn is_plain_version(value: &str) -> bool {
188    !value.is_empty()
189        && value.len() <= 64
190        && value
191            .chars()
192            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'))
193}
194
195/// Whether a value is a service namespace in the one shape the provider
196/// jail accepts.
197///
198/// The grammar is closed and anchored: a caret, then one to eight
199/// dot-separated segments of ASCII letters, digits, hyphen, or
200/// underscore, each written with the dot escaped as `\.`, ending in
201/// one escaped dot. `^a\.b\.C\.` is the whole language. It is a
202/// literal namespace prefix, not an expression: no alternation, no
203/// classes, no quantifiers, no unescaped metacharacter, so a supplied
204/// value can widen the registration allowance to exactly the names
205/// under that prefix and to nothing else. A deployment supplying any
206/// other shape is refused before the walk starts.
207pub fn is_service_namespace(value: &str) -> bool {
208    const MAX_SEGMENTS: usize = 8;
209    const MAX_SEGMENT: usize = 64;
210    if value.len() > 128 {
211        return false;
212    }
213    let Some(rest) = value.strip_prefix('^') else {
214        return false;
215    };
216    let Some(body) = rest.strip_suffix("\\.") else {
217        return false;
218    };
219    let segments: Vec<&str> = body.split("\\.").collect();
220    if segments.is_empty() || segments.len() > MAX_SEGMENTS {
221        return false;
222    }
223    segments.iter().all(|segment| {
224        !segment.is_empty()
225            && segment.len() <= MAX_SEGMENT
226            && segment
227                .chars()
228                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
229    })
230}
231
232/// The directories many unrelated programs live in. Granting one whole
233/// would put every one of them, and every other version of the pinned
234/// component, inside the jail, which is the opposite of an enumerated
235/// closure. A configuration naming one is refused before the walk.
236///
237/// The list is not a security boundary on its own: the boundary is the
238/// enumeration a deployment writes down and the aggregate digest the
239/// rules pin over it. This refuses the mistakes that would make both
240/// meaningless.
241const SHARED_ROOTS: &[&str] = &[
242    "/",
243    "/Applications",
244    "/Library",
245    "/System",
246    "/Users",
247    "/bin",
248    "/etc",
249    "/home",
250    "/nix/store",
251    "/opt",
252    "/opt/homebrew",
253    "/opt/homebrew/Cellar",
254    "/opt/homebrew/bin",
255    "/opt/homebrew/lib",
256    "/opt/homebrew/opt",
257    "/private/tmp",
258    "/private/var",
259    "/sbin",
260    "/tmp",
261    "/usr",
262    "/usr/bin",
263    "/usr/lib",
264    "/usr/local",
265    "/usr/local/bin",
266    "/usr/local/lib",
267    "/usr/sbin",
268    "/usr/share",
269    "/var",
270];
271
272/// Whether a closure entry names one of those shared roots, or is so
273/// shallow it can only be one. Judged on the path as written and, when
274/// it exists, on the tree it really names, so an alias through `..` or
275/// a symlink whose target is a shared root is refused the same way.
276pub fn is_shared_root(path: &Path) -> bool {
277    if names_shared_root(path) {
278        return true;
279    }
280    match std::fs::canonicalize(path) {
281        Ok(real) => names_shared_root(&real),
282        Err(_) => false,
283    }
284}
285
286fn names_shared_root(path: &Path) -> bool {
287    let text = path.to_string_lossy();
288    let trimmed = text.trim_end_matches('/');
289    let normalized = if trimmed.is_empty() { "/" } else { trimmed };
290    if SHARED_ROOTS.contains(&normalized) {
291        return true;
292    }
293    // A single top-level directory nobody listed is still a shared root
294    // by shape.
295    path.components().count() < 3
296}
297
298/// Whether a path is absolute and in normal form: the root, then plain
299/// components only, no `.` and no `..`. A path that is not cannot be
300/// compared with anything by its text.
301pub fn is_normal_form(path: &Path) -> bool {
302    use std::path::Component;
303    path.is_absolute()
304        && path
305            .components()
306            .all(|c| matches!(c, Component::RootDir | Component::Normal(_)))
307}
308
309/// Whether a path's last component is itself a symlink. An enumerated
310/// entry must be a real directory or a real regular file: the grant
311/// is rendered on the path, and the digest never follows a link, so a
312/// link entry would grant one tree and measure another.
313pub fn is_symlink_entry(path: &Path) -> bool {
314    std::fs::symlink_metadata(path)
315        .map(|m| m.file_type().is_symlink())
316        .unwrap_or(false)
317}
318
319/// Whether a path names a real directory or a real regular file after
320/// `symlink_metadata`: not a link, and not a FIFO, a socket, or a
321/// device, none of which is a tree to grant or a file to hash.
322pub fn is_real_entry(path: &Path) -> bool {
323    std::fs::symlink_metadata(path)
324        .map(|m| m.file_type().is_dir() || m.file_type().is_file())
325        .unwrap_or(false)
326}
327
328/// The tree a path really names, or `None` when it cannot be resolved.
329/// Every containment decision and every rendered grant is made on this
330/// form, never on the text as written.
331pub fn canonical(path: &Path) -> Option<PathBuf> {
332    std::fs::canonicalize(path).ok()
333}
334
335/// Whether one canonical path lies under another, component-wise.
336pub fn lies_inside(real_path: &Path, real_root: &Path) -> bool {
337    real_path == real_root || real_path.starts_with(real_root)
338}
339
340/// Whether an environment variable name is a bounded identifier: an
341/// upper-case letter or underscore, then up to sixty-three upper-case
342/// letters, digits, or underscores.
343pub fn is_env_name(value: &str) -> bool {
344    let mut chars = value.chars();
345    let Some(first) = chars.next() else {
346        return false;
347    };
348    (first.is_ascii_uppercase() || first == '_')
349        && value.len() <= 64
350        && chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
351}
352
353impl ProviderPin {
354    /// Validates one rules pin: hashes are lowercase hex and the version
355    /// is a plain token.
356    pub(crate) fn validate(&self, role: &str) -> std::result::Result<(), String> {
357        if !is_lower_hex_256(&self.blake3) {
358            return Err(format!(
359                "[image_ocr.svg_provider] role {role:?} blake3 is not a lowercase hex BLAKE3"
360            ));
361        }
362        if !is_plain_version(&self.version) {
363            return Err(format!(
364                "[image_ocr.svg_provider] role {role:?} version is not a plain version string"
365            ));
366        }
367        if let Some(digest) = &self.closure_blake3
368            && !is_lower_hex_256(digest)
369        {
370            return Err(format!(
371                "[image_ocr.svg_provider] role {role:?} closure_blake3 is not a lowercase hex BLAKE3"
372            ));
373        }
374        Ok(())
375    }
376}
377
378/// Checks one configured role against its rules pin: the enumerated
379/// closure and its pinned digest are a pair. A closure with no digest
380/// would grant a subtree nothing asserts the contents of, and a digest
381/// with no closure asserts nothing at all.
382#[cfg(feature = "svg-provider")]
383pub(crate) fn check_closure_is_pinned(
384    role: &str,
385    configured: &ProviderRole,
386    pin: Option<&ProviderPin>,
387) -> std::result::Result<(), String> {
388    let pinned = pin.and_then(|pin| pin.closure_blake3.as_ref()).is_some();
389    let enumerated = !configured.closure_roots.is_empty();
390    match (enumerated, pinned) {
391        (true, false) => Err(format!(
392            "provider role {role:?} enumerates a closure that [image_ocr.svg_provider] does not pin"
393        )),
394        (false, true) => Err(format!(
395            "provider role {role:?} has a pinned closure digest but enumerates no closure to compute it over"
396        )),
397        _ => Ok(()),
398    }
399}
400
401/// Validates the `[image_ocr.svg_provider]` rules section: the two
402/// roles and no other, both present when any is, every value in shape.
403pub(crate) fn validate_pins(
404    pins: &BTreeMap<String, ProviderPin>,
405) -> std::result::Result<(), String> {
406    for role in pins.keys() {
407        if !PROVIDER_ROLES.contains(&role.as_str()) {
408            return Err(format!(
409                "[image_ocr.svg_provider] names unknown role {role:?}"
410            ));
411        }
412    }
413    if !pins.is_empty() {
414        for role in PROVIDER_ROLES {
415            if !pins.contains_key(role) {
416                return Err(format!(
417                    "[image_ocr.svg_provider] is missing role {role:?}: both roles form one complete provider"
418                ));
419            }
420        }
421    }
422    for (role, pin) in pins {
423        pin.validate(role)?;
424    }
425    Ok(())
426}
427
428impl ProviderRole {
429    /// Validates one role's fields. Paths must be absolute and the jail
430    /// parameters must fit their closed shapes; the file itself is
431    /// re-checked and re-hashed in the jailed worker before every
432    /// execution, so nothing here is trusted later.
433    fn validated(&self, role: &str) -> Result<ProviderRole> {
434        if !self.path.is_absolute() {
435            return Err(rules_error(format!(
436                "provider role {role:?} needs an absolute path"
437            )));
438        }
439        if !is_normal_form(&self.path) {
440            return Err(rules_error(format!(
441                "provider role {role:?} path is not in normal form: write the real absolute path without . or .. components"
442            )));
443        }
444        // The executable and every entry are resolved to the trees
445        // they really name, and everything after this point, the
446        // shared-root refusal, containment, the rendered grants, and
447        // the worker's own checks, sees only those. A path that cannot
448        // be resolved is refused here rather than carried forward as
449        // text nothing can vouch for.
450        let real_path = canonical(&self.path).ok_or_else(|| {
451            rules_error(format!(
452                "provider role {role:?} path {} cannot be resolved",
453                self.path.display()
454            ))
455        })?;
456        if !std::fs::symlink_metadata(&real_path).is_ok_and(|m| m.file_type().is_file()) {
457            return Err(rules_error(format!(
458                "provider role {role:?} path {} is not a regular file",
459                self.path.display()
460            )));
461        }
462        let mut real_roots = Vec::with_capacity(self.closure_roots.len());
463        for root in &self.closure_roots {
464            if !root.is_absolute() {
465                return Err(rules_error(format!(
466                    "provider role {role:?} needs absolute closure_roots"
467                )));
468            }
469            if !is_normal_form(root) {
470                return Err(rules_error(format!(
471                    "provider role {role:?} closure_roots entry {} is not in normal form: write the real absolute path without . or .. components",
472                    root.display()
473                )));
474            }
475            if is_symlink_entry(root) {
476                return Err(rules_error(format!(
477                    "provider role {role:?} closure_roots entry {} is a symlink: enumerate the real path it names",
478                    root.display()
479                )));
480            }
481            if is_shared_root(root) {
482                return Err(rules_error(format!(
483                    "provider role {role:?} closure_roots entry {} is a shared root: enumerate the measured dependencies instead",
484                    root.display()
485                )));
486            }
487            let real_root = canonical(root).ok_or_else(|| {
488                rules_error(format!(
489                    "provider role {role:?} closure_roots entry {} cannot be resolved",
490                    root.display()
491                ))
492            })?;
493            // A directory or a regular file, judged on the entry's own
494            // metadata: a FIFO, a socket, or a device is neither a
495            // tree to grant nor a file to hash.
496            if !is_real_entry(root) {
497                return Err(rules_error(format!(
498                    "provider role {role:?} closure_roots entry {} is not a directory or a regular file",
499                    root.display()
500                )));
501            }
502            if is_shared_root(&real_root) {
503                return Err(rules_error(format!(
504                    "provider role {role:?} closure_roots entry {} is a shared root: enumerate the measured dependencies instead",
505                    root.display()
506                )));
507            }
508            real_roots.push(real_root);
509        }
510        // A role that enumerates a closure must sit inside it: the
511        // executable is what the closure exists for, and an executable
512        // outside every entry would be authorized by its literal grant
513        // alone while its own tree stays unmeasured. Inside is decided
514        // on the resolved paths, component-wise, so neither `..` nor a
515        // symlinked ancestor can place an executable in a closure it
516        // does not really lie in.
517        if !real_roots.is_empty() && !real_roots.iter().any(|root| lies_inside(&real_path, root)) {
518            return Err(rules_error(format!(
519                "provider role {role:?} executable lies outside every closure_roots entry"
520            )));
521        }
522        if let Some(prefix) = &self.jail_service_prefix
523            && !is_service_namespace(prefix)
524        {
525            return Err(rules_error(format!(
526                "provider-parameter-invalid: provider role {role:?} jail_service_prefix is not an anchored dotted namespace"
527            )));
528        }
529        if let Some(name) = &self.jail_temp_env
530            && !is_env_name(name)
531        {
532            return Err(rules_error(format!(
533                "provider-parameter-invalid: provider role {role:?} jail_temp_env is not a bounded identifier"
534            )));
535        }
536        Ok(ProviderRole {
537            path: real_path,
538            closure_roots: real_roots,
539            jail_service_prefix: self.jail_service_prefix.clone(),
540            jail_temp_env: self.jail_temp_env.clone(),
541        })
542    }
543}
544
545impl ProviderConfig {
546    /// A configuration with no provider: the shape a run has when no
547    /// configuration file is supplied.
548    pub fn empty() -> ProviderConfig {
549        ProviderConfig::default()
550    }
551
552    /// The svg provider, when the configuration carries a complete one.
553    pub fn svg(&self) -> Option<&SvgProvider> {
554        self.svg.as_ref()
555    }
556
557    /// Parses and validates one configuration text.
558    pub fn parse(text: &str) -> Result<ProviderConfig> {
559        let raw: RawProviderConfig =
560            toml::from_str(text).map_err(|e| rules_error(e.to_string()))?;
561        if raw.schema != PROVIDER_SCHEMA {
562            return Err(rules_error(format!(
563                "unrecognized provider schema {:?}, expected {PROVIDER_SCHEMA:?}",
564                raw.schema
565            )));
566        }
567        let Some(providers) = raw.providers else {
568            return Ok(ProviderConfig::empty());
569        };
570        let Some(roles) = providers.svg else {
571            return Ok(ProviderConfig::empty());
572        };
573        for name in roles.keys() {
574            if !PROVIDER_ROLES.contains(&name.as_str()) {
575                return Err(rules_error(format!(
576                    "[providers.svg] names unknown role {name:?}"
577                )));
578            }
579        }
580        for role in PROVIDER_ROLES {
581            if !roles.contains_key(role) {
582                return Err(rules_error(format!(
583                    "[providers.svg] is missing role {role:?}: both roles form one complete provider"
584                )));
585            }
586        }
587        let mut resolved = Vec::new();
588        for role in PROVIDER_ROLES {
589            resolved.push(roles[role].validated(role)?);
590        }
591        let encoder = resolved.pop().expect("both roles resolved");
592        let raster = resolved.pop().expect("both roles resolved");
593        Ok(ProviderConfig {
594            svg: Some(SvgProvider { raster, encoder }),
595        })
596    }
597
598    /// Loads a configuration from a TOML file.
599    pub fn load(path: &Path) -> Result<ProviderConfig> {
600        let text = std::fs::read_to_string(path).map_err(|e| Error::io("read", path, e))?;
601        ProviderConfig::parse(&text)
602    }
603
604    /// The material the effective rules version digests for a configured
605    /// provider: presence, the generic role labels, the rules-pinned
606    /// hashes and versions, the adapter version, the geometry and
607    /// flatten options, and the jail identity, which is the profile
608    /// template's BLAKE3 together with the two parameter values the
609    /// deployment supplied.
610    ///
611    /// Absolute paths are deliberately excluded, so moving an identical
612    /// pinned provider to another path re-runs nothing. The parameter
613    /// values enter as digest material only, so they never appear in a
614    /// record: two runs under different values, or under an edited
615    /// template, are different effective versions and never checkpoint
616    /// against each other.
617    pub fn digest_material(
618        pins: &BTreeMap<String, ProviderPin>,
619        jail: &ProviderJailIdentity,
620    ) -> Option<String> {
621        let mut material = String::from("provider=svg\n");
622        for label in PROVIDER_ROLES {
623            let pin = pins.get(label)?;
624            material.push_str(&format!("role={label}\n"));
625            material.push_str(&format!("blake3={}\n", pin.blake3));
626            material.push_str(&format!("version={}\n", pin.version));
627            if let Some(digest) = &pin.closure_blake3 {
628                material.push_str(&format!("closure={digest}\n"));
629            }
630        }
631        material.push_str(&format!("adapter={PROVIDER_ADAPTER_VERSION}\n"));
632        material.push_str(&format!(
633            "geometry-tolerance-px={PROVIDER_GEOMETRY_TOLERANCE_PX}\n"
634        ));
635        material.push_str(&format!("flatten={}\n", PROVIDER_FLATTEN_ARGS.join(" ")));
636        material.push_str(&format!(
637            "area-cap={}\n",
638            crate::convert::IMAGE_OCR_MAX_AREA_PX
639        ));
640        material.push_str(&format!(
641            "template={}\n",
642            jail.template_blake3.as_deref().unwrap_or("none")
643        ));
644        material.push_str(&format!(
645            "service-prefix={}\n",
646            jail.service_prefix.as_deref().unwrap_or_default()
647        ));
648        material.push_str(&format!(
649            "temp-env={}\n",
650            jail.temp_env.as_deref().unwrap_or_default()
651        ));
652        Some(material)
653    }
654
655    /// The effective-version suffix a configured provider contributes
656    /// under the given pins and jail identity, or `None` when the pins
657    /// are incomplete. The form is `svg.<12 hex>`, appended to the
658    /// numeric rules version with a `+`.
659    pub fn version_suffix(
660        pins: &BTreeMap<String, ProviderPin>,
661        jail: &ProviderJailIdentity,
662    ) -> Option<String> {
663        let material = Self::digest_material(pins, jail)?;
664        let digest = crate::hash::hash_bytes(material.as_bytes());
665        Some(format!("svg.{}", &digest[..12]))
666    }
667
668    /// The jail identity this configuration runs under: the profile
669    /// template's BLAKE3 on this platform and the two parameter values
670    /// the rasterizer role supplied.
671    pub fn jail_identity(&self) -> ProviderJailIdentity {
672        let raster = self.svg.as_ref().map(|svg| &svg.raster);
673        ProviderJailIdentity {
674            template_blake3: crate::runner_template_blake3(),
675            service_prefix: raster.and_then(|role| role.jail_service_prefix.clone()),
676            temp_env: raster.and_then(|role| role.jail_temp_env.clone()),
677        }
678    }
679}
680
681/// The parts of a provider's identity that come from the jail rather
682/// than the pins: which profile template renders it, and the two
683/// parameter values it was rendered with.
684#[derive(Debug, Clone, Default, PartialEq, Eq)]
685pub struct ProviderJailIdentity {
686    /// BLAKE3 of the provider profile template, `None` where no
687    /// provider profile is measured.
688    pub template_blake3: Option<String>,
689    /// The service namespace the deployment supplied.
690    pub service_prefix: Option<String>,
691    /// The temp-directory variable the deployment supplied.
692    pub temp_env: Option<String>,
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698
699    fn config(body: &str) -> String {
700        format!("schema = \"{PROVIDER_SCHEMA}\"\n{body}")
701    }
702
703    /// A deployment on disk: both executables and a dependency in real
704    /// directories, because every path is resolved at parse and a path
705    /// that cannot be resolved is refused.
706    struct Deploy {
707        _dir: tempfile::TempDir,
708        root: PathBuf,
709    }
710
711    impl Deploy {
712        fn new() -> Deploy {
713            let dir = tempfile::tempdir().unwrap();
714            let root = dir.path().canonicalize().unwrap().join("deploy");
715            for rel in [
716                "raster/bin/raster",
717                "encoder/bin/encoder",
718                "dependency/lib/dep.dylib",
719                "elsewhere/bin/raster",
720            ] {
721                let path = root.join(rel);
722                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
723                std::fs::write(&path, b"program").unwrap();
724            }
725            Deploy { _dir: dir, root }
726        }
727
728        fn at(&self, rel: &str) -> String {
729            self.root.join(rel).display().to_string()
730        }
731
732        fn raster_path_line(&self) -> String {
733            format!("path = \"{}\"", self.at("raster/bin/raster"))
734        }
735
736        fn raster_roots_line(&self) -> String {
737            format!("closure_roots = [\"{}\"]", self.at("raster"))
738        }
739
740        fn encoder_path_line(&self) -> String {
741            format!("path = \"{}\"", self.at("encoder/bin/encoder"))
742        }
743
744        fn encoder_roots_line(&self) -> String {
745            format!(
746                "closure_roots = [\"{}\", \"{}\"]",
747                self.at("encoder"),
748                self.at("dependency/lib/dep.dylib")
749            )
750        }
751
752        fn complete(&self) -> String {
753            config(&format!(
754                "\n[providers.svg.\"{PROVIDER_ROLE_RASTER}\"]\n{}\n{}\n\
755                 jail_service_prefix = \"^ex\\\\.ample\\\\.Product\\\\.\"\n\
756                 jail_temp_env = \"EXAMPLE_TMPDIR\"\n\n\
757                 [providers.svg.\"{PROVIDER_ROLE_ENCODER}\"]\n{}\n{}\n",
758                self.raster_path_line(),
759                self.raster_roots_line(),
760                self.encoder_path_line(),
761                self.encoder_roots_line(),
762            ))
763        }
764    }
765
766    fn pins() -> BTreeMap<String, ProviderPin> {
767        let mut pins = BTreeMap::new();
768        pins.insert(
769            PROVIDER_ROLE_RASTER.to_string(),
770            ProviderPin {
771                blake3: "a".repeat(64),
772                version: "151.0.7922.174".to_string(),
773                closure_blake3: Some("b".repeat(64)),
774            },
775        );
776        pins.insert(
777            PROVIDER_ROLE_ENCODER.to_string(),
778            ProviderPin {
779                blake3: "c".repeat(64),
780                version: "7.1.2-29".to_string(),
781                closure_blake3: None,
782            },
783        );
784        pins
785    }
786
787    #[test]
788    fn a_complete_configuration_resolves_both_roles() {
789        let deploy = Deploy::new();
790        let parsed = ProviderConfig::parse(&deploy.complete()).unwrap();
791        let svg = parsed.svg().expect("both roles present");
792        assert_eq!(svg.raster.path, deploy.root.join("raster/bin/raster"));
793        assert_eq!(
794            svg.raster.jail_service_prefix.as_deref(),
795            Some(r"^ex\.ample\.Product\.")
796        );
797        assert_eq!(svg.raster.jail_temp_env.as_deref(), Some("EXAMPLE_TMPDIR"));
798        assert_eq!(
799            svg.encoder.closure_roots,
800            vec![
801                deploy.root.join("encoder"),
802                deploy.root.join("dependency/lib/dep.dylib")
803            ]
804        );
805        assert!(svg.encoder.jail_service_prefix.is_none());
806    }
807
808    #[test]
809    fn an_empty_or_provider_free_configuration_carries_no_provider() {
810        assert!(ProviderConfig::empty().svg().is_none());
811        assert!(ProviderConfig::parse(&config("")).unwrap().svg().is_none());
812        assert!(
813            ProviderConfig::parse(&config("[providers]\n"))
814                .unwrap()
815                .svg()
816                .is_none()
817        );
818    }
819
820    #[test]
821    fn an_incomplete_provider_block_is_a_configuration_error() {
822        let one_role = config(&format!(
823            "[providers.svg.\"{PROVIDER_ROLE_RASTER}\"]\npath = \"/deploy/raster\"\n"
824        ));
825        let error = ProviderConfig::parse(&one_role).unwrap_err();
826        assert!(error.to_string().contains("missing role"), "{error}");
827    }
828
829    #[test]
830    fn the_schema_carries_no_other_provider_and_no_other_role() {
831        // HEIC has no provider in this schema, so a configuration that
832        // names one is refused rather than silently ignored, and the
833        // deferred routing cannot be reached through this file.
834        let heic = config("[providers.heic.\"raster-browser\"]\npath = \"/x\"\n");
835        let error = ProviderConfig::parse(&heic).unwrap_err();
836        assert!(error.to_string().contains("unknown field"), "{error}");
837        // A third role that was never validated for this leg is
838        // refused by name, so the two-role provider cannot grow one
839        // through configuration.
840        let extra = config("[providers.svg.\"raster-transcoder\"]\npath = \"/x\"\n");
841        let error = ProviderConfig::parse(&extra).unwrap_err();
842        assert!(error.to_string().contains("unknown role"), "{error}");
843        // The expectations are rules data and have no place here.
844        let pinned = config(&format!(
845            "[providers.svg.\"{PROVIDER_ROLE_RASTER}\"]\npath = \"/x\"\nblake3 = \"{}\"\n",
846            "a".repeat(64)
847        ));
848        assert!(ProviderConfig::parse(&pinned).is_err());
849    }
850
851    #[test]
852    fn a_wrong_schema_and_a_malformed_file_are_configuration_errors() {
853        let error = ProviderConfig::parse("schema = \"text-mirror/provider@2\"\n").unwrap_err();
854        assert!(error.to_string().contains("provider schema"), "{error}");
855        assert!(ProviderConfig::parse("not = toml = at all").is_err());
856        assert!(ProviderConfig::parse("").is_err());
857    }
858
859    #[test]
860    fn paths_are_validated_before_a_run_starts() {
861        let deploy = Deploy::new();
862        let relative = deploy
863            .complete()
864            .replace(&deploy.raster_path_line(), "path = \"relative/raster\"");
865        assert!(
866            ProviderConfig::parse(&relative)
867                .unwrap_err()
868                .to_string()
869                .contains("absolute path")
870        );
871        let relative_closure = deploy.complete().replace(
872            &deploy.encoder_roots_line(),
873            "closure_roots = [\"relative/lib\"]",
874        );
875        assert!(
876            ProviderConfig::parse(&relative_closure)
877                .unwrap_err()
878                .to_string()
879                .contains("closure_roots")
880        );
881        // A shared package or application root is refused: everything
882        // under an entry is readable and executable in the jail, so a
883        // store root would carry every unrelated package, and every
884        // other version of this one, inside the boundary.
885        for shared in [
886            "/",
887            "/opt",
888            "/opt/homebrew",
889            "/opt/homebrew/Cellar",
890            "/opt/homebrew/opt",
891            "/usr/lib",
892            "/usr/local/lib",
893            "/Applications",
894            "/Library",
895            "/System",
896            "/bin",
897            "/opt/homebrew/Cellar/",
898        ] {
899            let text = deploy.complete().replace(
900                &deploy.encoder_roots_line(),
901                &format!("closure_roots = [\"{shared}\"]"),
902            );
903            let error = ProviderConfig::parse(&text).unwrap_err().to_string();
904            assert!(error.contains("shared root"), "{shared}: {error}");
905        }
906        // A specific versioned artifact under one of those roots is
907        // exactly what an enumeration should name, so its shape is
908        // accepted by the predicate.
909        for named in [
910            "/opt/homebrew/Cellar/example/1.2.3",
911            "/opt/homebrew/Cellar/example/1.2.3/lib/libexample.dylib",
912            "/Applications/Example.app",
913        ] {
914            assert!(!is_shared_root(Path::new(named)), "{named}");
915        }
916        // A component or an entry that does not exist cannot be
917        // resolved, and is refused rather than carried as text.
918        let missing = deploy.complete().replace(
919            &deploy.raster_path_line(),
920            &format!("path = \"{}\"", deploy.at("raster/bin/absent")),
921        );
922        let error = ProviderConfig::parse(&missing).unwrap_err().to_string();
923        assert!(error.contains("cannot be resolved"), "{error}");
924        let missing_entry = deploy.complete().replace(
925            &deploy.encoder_roots_line(),
926            &format!("closure_roots = [\"{}\"]", deploy.at("encoder-absent")),
927        );
928        let error = ProviderConfig::parse(&missing_entry)
929            .unwrap_err()
930            .to_string();
931        assert!(error.contains("cannot be resolved"), "{error}");
932        // A configuration that enumerates a closure the rules do not
933        // pin is refused where the two meet, at registry construction.
934        let parsed = ProviderConfig::parse(&deploy.complete()).unwrap();
935        let svg = parsed.svg().unwrap();
936        assert!(
937            check_closure_is_pinned(
938                PROVIDER_ROLE_RASTER,
939                &svg.raster,
940                pins().get(PROVIDER_ROLE_RASTER)
941            )
942            .is_ok()
943        );
944        let unpinned = ProviderPin {
945            blake3: "a".repeat(64),
946            version: "1".to_string(),
947            closure_blake3: None,
948        };
949        let error = check_closure_is_pinned(PROVIDER_ROLE_RASTER, &svg.raster, Some(&unpinned))
950            .unwrap_err();
951        assert!(error.contains("does not pin"), "{error}");
952        assert!(check_closure_is_pinned(PROVIDER_ROLE_RASTER, &svg.raster, None).is_err());
953    }
954
955    #[test]
956    fn the_service_namespace_grammar_is_closed_and_anchored() {
957        // The whole language: a caret, escaped-dot-separated segments,
958        // a trailing escaped dot.
959        for good in [
960            r"^a\.",
961            r"^com\.example\.",
962            r"^com\.example\.Product\.",
963            r"^org-name\.some_thing\.X9\.",
964        ] {
965            assert!(is_service_namespace(good), "{good}");
966        }
967        // Everything else is refused: a free expression, an unanchored
968        // pattern, alternation, classes, quantifiers, an unescaped dot,
969        // an empty segment, a missing trailing dot, and an over-long
970        // value.
971        for bad in [
972            "",
973            "^",
974            r"^\.",
975            r"com\.example\.",
976            r"^com\.example",
977            r"^com.example\.",
978            r"^(org\.a|com\.b)\.",
979            r"^com\.[a-z]+\.",
980            r"^com\..*\.",
981            r"^com\.\.example\.",
982            r"^com\.exa mple\.",
983            r#"^com\.ex"ample\."#,
984            r".*",
985            "^$",
986        ] {
987            assert!(!is_service_namespace(bad), "{bad:?}");
988        }
989        let long = format!("^{}\\.", "a".repeat(130));
990        assert!(!is_service_namespace(&long));
991        let many = format!("^{}\\.", ["a"; 9].join("\\."));
992        assert!(!is_service_namespace(&many));
993        let eight = format!("^{}\\.", ["a"; 8].join("\\."));
994        assert!(is_service_namespace(&eight));
995    }
996
997    #[test]
998    fn the_temp_variable_name_is_a_bounded_identifier() {
999        for good in ["X", "_X", "MY_TMPDIR", "A1_B2", &"A".repeat(64)] {
1000            assert!(is_env_name(good), "{good}");
1001        }
1002        for bad in [
1003            "",
1004            "lower",
1005            "1ABC",
1006            "HAS-DASH",
1007            "HAS SPACE",
1008            "HAS=EQ",
1009            &"A".repeat(65),
1010        ] {
1011            assert!(!is_env_name(bad), "{bad:?}");
1012        }
1013    }
1014
1015    #[test]
1016    fn an_out_of_shape_parameter_is_refused_by_name_before_any_run() {
1017        let deploy = Deploy::new();
1018        let free = deploy.complete().replace(
1019            r#"jail_service_prefix = "^ex\\.ample\\.Product\\.""#,
1020            r#"jail_service_prefix = "^(com\\.a|org\\.b)\\.""#,
1021        );
1022        let error = ProviderConfig::parse(&free).unwrap_err().to_string();
1023        assert!(error.contains("provider-parameter-invalid"), "{error}");
1024        assert!(error.contains("jail_service_prefix"), "{error}");
1025        let unanchored = deploy.complete().replace(
1026            r#"jail_service_prefix = "^ex\\.ample\\.Product\\.""#,
1027            r#"jail_service_prefix = "ex\\.ample\\.""#,
1028        );
1029        assert!(ProviderConfig::parse(&unanchored).is_err());
1030        let long_name = deploy.complete().replace(
1031            "jail_temp_env = \"EXAMPLE_TMPDIR\"",
1032            &format!("jail_temp_env = \"{}\"", "T".repeat(65)),
1033        );
1034        let error = ProviderConfig::parse(&long_name).unwrap_err().to_string();
1035        assert!(error.contains("provider-parameter-invalid"), "{error}");
1036        assert!(error.contains("jail_temp_env"), "{error}");
1037        let lower = deploy.complete().replace(
1038            "jail_temp_env = \"EXAMPLE_TMPDIR\"",
1039            "jail_temp_env = \"lowercase\"",
1040        );
1041        assert!(ProviderConfig::parse(&lower).is_err());
1042    }
1043
1044    #[test]
1045    fn the_rules_pins_are_validated_as_a_pair() {
1046        assert!(validate_pins(&BTreeMap::new()).is_ok());
1047        assert!(validate_pins(&pins()).is_ok());
1048        let mut one = pins();
1049        one.remove(PROVIDER_ROLE_ENCODER);
1050        assert!(validate_pins(&one).unwrap_err().contains("missing role"));
1051        let mut extra = pins();
1052        extra.insert(
1053            "raster-transcoder".to_string(),
1054            ProviderPin {
1055                blake3: "a".repeat(64),
1056                version: "1".to_string(),
1057                closure_blake3: None,
1058            },
1059        );
1060        assert!(validate_pins(&extra).unwrap_err().contains("unknown role"));
1061        let mut bad_hash = pins();
1062        bad_hash.get_mut(PROVIDER_ROLE_RASTER).unwrap().blake3 = "SHOUTING".to_string();
1063        assert!(validate_pins(&bad_hash).unwrap_err().contains("BLAKE3"));
1064        let mut bad_version = pins();
1065        bad_version.get_mut(PROVIDER_ROLE_ENCODER).unwrap().version =
1066            "151 (build 7922)".to_string();
1067        assert!(validate_pins(&bad_version).unwrap_err().contains("version"));
1068        let mut bad_closure = pins();
1069        bad_closure
1070            .get_mut(PROVIDER_ROLE_RASTER)
1071            .unwrap()
1072            .closure_blake3 = Some("zz".to_string());
1073        assert!(
1074            validate_pins(&bad_closure)
1075                .unwrap_err()
1076                .contains("closure_blake3")
1077        );
1078    }
1079
1080    fn identity() -> ProviderJailIdentity {
1081        ProviderJailIdentity {
1082            template_blake3: Some("t".repeat(64)),
1083            service_prefix: Some(r"^ex\.ample\.".to_string()),
1084            temp_env: Some("EXAMPLE_TMPDIR".to_string()),
1085        }
1086    }
1087
1088    #[test]
1089    fn the_effective_version_suffix_covers_identity_and_excludes_paths() {
1090        let suffix = ProviderConfig::version_suffix(&pins(), &identity()).expect("complete pins");
1091        assert!(suffix.starts_with("svg."), "{suffix}");
1092        assert_eq!(suffix.len(), "svg.".len() + 12, "{suffix}");
1093        // Incomplete pins yield no suffix at all.
1094        let mut one = pins();
1095        one.remove(PROVIDER_ROLE_ENCODER);
1096        assert!(ProviderConfig::version_suffix(&one, &identity()).is_none());
1097        // Every pinned identity changes it.
1098        for mutate in [
1099            |p: &mut BTreeMap<String, ProviderPin>| {
1100                p.get_mut(PROVIDER_ROLE_RASTER).unwrap().blake3 = "1".repeat(64)
1101            },
1102            |p: &mut BTreeMap<String, ProviderPin>| {
1103                p.get_mut(PROVIDER_ROLE_RASTER).unwrap().closure_blake3 = Some("2".repeat(64))
1104            },
1105            |p: &mut BTreeMap<String, ProviderPin>| {
1106                p.get_mut(PROVIDER_ROLE_ENCODER).unwrap().blake3 = "3".repeat(64)
1107            },
1108            |p: &mut BTreeMap<String, ProviderPin>| {
1109                p.get_mut(PROVIDER_ROLE_ENCODER).unwrap().version = "7.1.2-30".to_string()
1110            },
1111        ] {
1112            let mut changed = pins();
1113            mutate(&mut changed);
1114            assert_ne!(
1115                ProviderConfig::version_suffix(&changed, &identity()),
1116                Some(suffix.clone())
1117            );
1118        }
1119        // The material names the generic labels, the adapter version,
1120        // the geometry and flatten options, the template, and the
1121        // parameters, and no path at all.
1122        let material = ProviderConfig::digest_material(&pins(), &identity()).unwrap();
1123        assert!(material.contains(PROVIDER_ROLE_RASTER));
1124        assert!(material.contains(PROVIDER_ROLE_ENCODER));
1125        assert!(material.contains(PROVIDER_ADAPTER_VERSION));
1126        assert!(material.contains("png:exclude-chunks=date,time"));
1127        assert!(material.contains("geometry-tolerance-px=1"));
1128        assert!(material.contains(&"t".repeat(64)));
1129        assert!(material.contains("EXAMPLE_TMPDIR"));
1130        assert!(!material.contains('/'), "{material}");
1131    }
1132
1133    #[test]
1134    fn the_jail_identity_is_part_of_the_effective_version() {
1135        // The approved identity is the template plus the two parameter
1136        // values, so a different value for either, or an edited
1137        // template, is a different effective version: two such runs
1138        // never checkpoint against each other.
1139        let base = ProviderConfig::version_suffix(&pins(), &identity()).unwrap();
1140        let mut other_namespace = identity();
1141        other_namespace.service_prefix = Some(r"^an\.other\.".to_string());
1142        assert_ne!(
1143            ProviderConfig::version_suffix(&pins(), &other_namespace),
1144            Some(base.clone())
1145        );
1146        let mut other_variable = identity();
1147        other_variable.temp_env = Some("OTHER_TMPDIR".to_string());
1148        assert_ne!(
1149            ProviderConfig::version_suffix(&pins(), &other_variable),
1150            Some(base.clone())
1151        );
1152        let mut edited_template = identity();
1153        edited_template.template_blake3 = Some("u".repeat(64));
1154        assert_ne!(
1155            ProviderConfig::version_suffix(&pins(), &edited_template),
1156            Some(base.clone())
1157        );
1158        let mut no_parameters = identity();
1159        no_parameters.service_prefix = None;
1160        no_parameters.temp_env = None;
1161        assert_ne!(
1162            ProviderConfig::version_suffix(&pins(), &no_parameters),
1163            Some(base.clone())
1164        );
1165        // And the same identity yields the same version, so the digest
1166        // is a function of what was supplied and nothing else.
1167        assert_eq!(
1168            ProviderConfig::version_suffix(&pins(), &identity()),
1169            Some(base)
1170        );
1171        // A configuration's own identity carries its parameters.
1172        let deploy = Deploy::new();
1173        let parsed = ProviderConfig::parse(&deploy.complete()).unwrap();
1174        let identity = parsed.jail_identity();
1175        assert_eq!(
1176            identity.service_prefix.as_deref(),
1177            Some(r"^ex\.ample\.Product\.")
1178        );
1179        assert_eq!(identity.temp_env.as_deref(), Some("EXAMPLE_TMPDIR"));
1180    }
1181
1182    #[test]
1183    fn an_executable_outside_its_own_closure_is_refused() {
1184        // A role that enumerates a closure must sit inside it, or its
1185        // literal grant would authorize an executable whose own tree is
1186        // unmeasured.
1187        let deploy = Deploy::new();
1188        let outside = deploy.complete().replace(
1189            &deploy.raster_roots_line(),
1190            &format!("closure_roots = [\"{}\"]", deploy.at("elsewhere")),
1191        );
1192        let error = ProviderConfig::parse(&outside).unwrap_err().to_string();
1193        assert!(error.contains("outside every closure_roots"), "{error}");
1194        // Inside as a subtree member, or as the entry itself, is fine.
1195        let as_entry = deploy.complete().replace(
1196            &deploy.raster_roots_line(),
1197            &format!("closure_roots = [\"{}\"]", deploy.at("raster/bin/raster")),
1198        );
1199        assert!(ProviderConfig::parse(&as_entry).is_ok());
1200        let among = deploy.complete().replace(
1201            &deploy.raster_roots_line(),
1202            &format!(
1203                "closure_roots = [\"{}\", \"{}\"]",
1204                deploy.at("elsewhere"),
1205                deploy.at("raster")
1206            ),
1207        );
1208        assert!(ProviderConfig::parse(&among).is_ok());
1209        // With no closure at all, the executable stands on its literal
1210        // grant alone, which is the shape of a self-contained component.
1211        let none = deploy
1212            .complete()
1213            .replace(&format!("{}\n", deploy.raster_roots_line()), "");
1214        assert!(ProviderConfig::parse(&none).is_ok());
1215    }
1216
1217    #[test]
1218    fn containment_is_decided_on_resolved_paths() {
1219        // Inside means inside the tree the entry really names. A `..`
1220        // alias is not in normal form and is refused as written; a
1221        // symlinked ancestor that resolves outside the closure is
1222        // refused on the resolved path; and a symlinked ancestor that
1223        // resolves inside is accepted, with the resolved path kept.
1224        let deploy = Deploy::new();
1225        let dotted = deploy.complete().replace(
1226            &deploy.raster_path_line(),
1227            &format!("path = \"{}\"", deploy.at("raster/../elsewhere/bin/raster")),
1228        );
1229        let error = ProviderConfig::parse(&dotted).unwrap_err().to_string();
1230        assert!(error.contains("not in normal form"), "{error}");
1231
1232        std::os::unix::fs::symlink(
1233            deploy.root.join("elsewhere"),
1234            deploy.root.join("raster/link"),
1235        )
1236        .unwrap();
1237        let escaping = deploy.complete().replace(
1238            &deploy.raster_path_line(),
1239            &format!("path = \"{}\"", deploy.at("raster/link/bin/raster")),
1240        );
1241        let error = ProviderConfig::parse(&escaping).unwrap_err().to_string();
1242        assert!(error.contains("outside every closure_roots"), "{error}");
1243
1244        std::os::unix::fs::symlink(deploy.root.join("raster"), deploy.root.join("alias")).unwrap();
1245        let entering = deploy.complete().replace(
1246            &deploy.raster_path_line(),
1247            &format!("path = \"{}\"", deploy.at("alias/bin/raster")),
1248        );
1249        let parsed = ProviderConfig::parse(&entering).unwrap();
1250        assert_eq!(
1251            parsed.svg().unwrap().raster.path,
1252            deploy.root.join("raster/bin/raster")
1253        );
1254
1255        // The shared-root refusal is decided the same way: an alias of
1256        // a shared root is refused as written, and a link whose target
1257        // is a shared root is refused as an entry.
1258        let alias = deploy.complete().replace(
1259            &deploy.encoder_roots_line(),
1260            "closure_roots = [\"/private/tmp/../tmp\"]",
1261        );
1262        let error = ProviderConfig::parse(&alias).unwrap_err().to_string();
1263        assert!(error.contains("not in normal form"), "{error}");
1264        std::os::unix::fs::symlink("/usr/lib", deploy.root.join("store")).unwrap();
1265        let linked = deploy.complete().replace(
1266            &deploy.encoder_roots_line(),
1267            &format!("closure_roots = [\"{}\"]", deploy.at("store")),
1268        );
1269        let error = ProviderConfig::parse(&linked).unwrap_err().to_string();
1270        assert!(error.contains("is a symlink"), "{error}");
1271        // And so is a link to a perfectly good tree: an entry is the
1272        // real directory or the real file, never a name for one.
1273        let real_link = deploy.complete().replace(
1274            &deploy.raster_roots_line(),
1275            &format!("closure_roots = [\"{}\"]", deploy.at("alias")),
1276        );
1277        let error = ProviderConfig::parse(&real_link).unwrap_err().to_string();
1278        assert!(error.contains("is a symlink"), "{error}");
1279
1280        // The resolved shared-root comparison itself: the entry is a
1281        // real directory, but an ancestor link carries it into a
1282        // shared root, so the written path is innocent and only the
1283        // resolved one is refused.
1284        std::os::unix::fs::symlink("/usr", deploy.root.join("usrlink")).unwrap();
1285        assert!(!is_symlink_entry(&deploy.root.join("usrlink/lib")));
1286        assert!(is_real_entry(&deploy.root.join("usrlink/lib")));
1287        let carried = deploy.complete().replace(
1288            &deploy.encoder_roots_line(),
1289            &format!("closure_roots = [\"{}\"]", deploy.at("usrlink/lib")),
1290        );
1291        let error = ProviderConfig::parse(&carried).unwrap_err().to_string();
1292        assert!(error.contains("shared root"), "{error}");
1293
1294        // An entry that is neither a directory nor a regular file is
1295        // refused, and so is an executable that is not a regular file:
1296        // a socket is neither a tree to grant nor a file to hash.
1297        let socket = deploy.root.join("sock");
1298        let _listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
1299        assert!(!is_real_entry(&socket));
1300        let special_entry = deploy.complete().replace(
1301            &deploy.encoder_roots_line(),
1302            &format!("closure_roots = [\"{}\"]", deploy.at("sock")),
1303        );
1304        let error = ProviderConfig::parse(&special_entry)
1305            .unwrap_err()
1306            .to_string();
1307        assert!(
1308            error.contains("not a directory or a regular file"),
1309            "{error}"
1310        );
1311        let special_executable = deploy.complete().replace(
1312            &deploy.raster_path_line(),
1313            &format!("path = \"{}\"", deploy.at("sock")),
1314        );
1315        let error = ProviderConfig::parse(&special_executable)
1316            .unwrap_err()
1317            .to_string();
1318        assert!(error.contains("not a regular file"), "{error}");
1319        // A FIFO is refused the same way.
1320        let fifo = deploy.root.join("fifo");
1321        assert!(
1322            std::process::Command::new("/usr/bin/mkfifo")
1323                .arg(&fifo)
1324                .status()
1325                .unwrap()
1326                .success()
1327        );
1328        assert!(!is_real_entry(&fifo));
1329        let fifo_entry = deploy.complete().replace(
1330            &deploy.encoder_roots_line(),
1331            &format!("closure_roots = [\"{}\"]", deploy.at("fifo")),
1332        );
1333        let error = ProviderConfig::parse(&fifo_entry).unwrap_err().to_string();
1334        assert!(
1335            error.contains("not a directory or a regular file"),
1336            "{error}"
1337        );
1338    }
1339}