1use std::collections::BTreeMap;
23use std::path::{Path, PathBuf};
24
25use serde::{Deserialize, Serialize};
26
27use crate::{Error, Result};
28
29pub const PROVIDER_SCHEMA: &str = "text-mirror/provider@1";
31
32pub const PROVIDER_ROLE_RASTER: &str = "raster-browser";
35
36pub const PROVIDER_ROLE_ENCODER: &str = "raster-magick";
39
40pub const PROVIDER_ROLES: [&str; 2] = [PROVIDER_ROLE_RASTER, PROVIDER_ROLE_ENCODER];
43
44pub const PROVIDER_ADAPTER_VERSION: &str = "1.0.0";
49
50pub const PROVIDER_GEOMETRY_TOLERANCE_PX: i64 = 1;
53
54pub 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73#[serde(deny_unknown_fields)]
74pub struct ProviderPin {
75 pub blake3: String,
77 pub version: String,
81 #[serde(default)]
91 pub closure_blake3: Option<String>,
92}
93
94#[derive(Debug, Clone, Deserialize)]
98#[serde(deny_unknown_fields)]
99pub struct ProviderRole {
100 pub path: PathBuf,
104 #[serde(default)]
119 pub closure_roots: Vec<PathBuf>,
120 #[serde(default)]
125 pub jail_service_prefix: Option<String>,
126 #[serde(default)]
130 pub jail_temp_env: Option<String>,
131}
132
133#[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#[derive(Debug, Clone, Default)]
156pub struct ProviderConfig {
157 svg: Option<SvgProvider>,
158}
159
160#[derive(Debug, Clone)]
162pub struct SvgProvider {
163 pub raster: ProviderRole,
165 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
176fn 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
184fn 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
195pub 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
232const 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
272pub 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 path.components().count() < 3
296}
297
298pub 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
309pub 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
319pub 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
328pub fn canonical(path: &Path) -> Option<PathBuf> {
332 std::fs::canonicalize(path).ok()
333}
334
335pub fn lies_inside(real_path: &Path, real_root: &Path) -> bool {
337 real_path == real_root || real_path.starts_with(real_root)
338}
339
340pub 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 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#[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
401pub(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 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 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 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 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 pub fn empty() -> ProviderConfig {
549 ProviderConfig::default()
550 }
551
552 pub fn svg(&self) -> Option<&SvgProvider> {
554 self.svg.as_ref()
555 }
556
557 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 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 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 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 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
685pub struct ProviderJailIdentity {
686 pub template_blake3: Option<String>,
689 pub service_prefix: Option<String>,
691 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 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 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 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 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 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 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 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 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 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 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 let mut one = pins();
1095 one.remove(PROVIDER_ROLE_ENCODER);
1096 assert!(ProviderConfig::version_suffix(&one, &identity()).is_none());
1097 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 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 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 assert_eq!(
1168 ProviderConfig::version_suffix(&pins(), &identity()),
1169 Some(base)
1170 );
1171 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 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 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 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 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 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 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 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 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 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}