use std::collections::BTreeSet;
use std::fmt;
pub const SCHEMA_VERSION: u16 = 1;
pub const MAX_DESCRIPTOR_BYTES: usize = 1024 * 1024;
pub const MAX_RECORDS: usize = 4096;
pub const MAX_ID_BYTES: usize = 128;
pub const MAX_PATH_BYTES: usize = 4096;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuleError(String);
impl RuleError {
pub fn new(field: &str, reason: &str) -> Self {
let reason: String = reason.chars().take(512).collect();
Self(format!("{field}: {reason}"))
}
fn value(field: &str, reason: &str, value: &str) -> Self {
let excerpt: String = value
.chars()
.take(MAX_ID_BYTES)
.flat_map(char::escape_default)
.collect();
Self::new(field, &format!("{reason}: {excerpt}"))
}
}
impl fmt::Display for RuleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for RuleError {}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct RuleId(String);
impl RuleId {
pub fn new(value: &str) -> Result<Self, RuleError> {
if value.len() > MAX_ID_BYTES {
return Err(RuleError::new("id", "exceeds MAX_ID_BYTES (128)"));
}
if !value
.as_bytes()
.first()
.is_some_and(u8::is_ascii_alphanumeric)
|| !value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
{
return Err(RuleError::value(
"id",
"expected an alphanumeric identity",
value,
));
}
Ok(Self(value.to_owned()))
}
pub fn set_name(value: &str) -> Result<Self, RuleError> {
let id = Self::new(value)?;
if value.contains('.') {
return Err(RuleError::value(
"set name",
"expected ASCII letters, digits, - and _ only",
value,
));
}
Ok(id)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct RelativePath(String);
impl RelativePath {
pub fn new(value: &str) -> Result<Self, RuleError> {
if value.len() > MAX_PATH_BYTES {
return Err(RuleError::new("path", "exceeds MAX_PATH_BYTES (4096)"));
}
let first_component = value
.split('/')
.find(|part| !part.is_empty() && *part != ".")
.unwrap_or_default();
let windows_drive = first_component.as_bytes().get(1) == Some(&b':')
&& first_component
.as_bytes()
.first()
.is_some_and(u8::is_ascii_alphabetic);
if value.starts_with('/')
|| windows_drive
|| value.contains('\\')
|| value.bytes().any(|b| b.is_ascii_control())
|| value.split('/').any(|part| part == "..")
{
return Err(RuleError::value(
"path",
"expected a confined relative path",
value,
));
}
let normalized = value
.split('/')
.filter(|part| !part.is_empty() && *part != ".")
.collect::<Vec<_>>()
.join("/");
if normalized.is_empty() {
return Err(RuleError::new(
"path",
"must name a resource below its base",
));
}
Ok(Self(normalized))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct EnvironmentName(String);
impl EnvironmentName {
pub fn new(value: &str) -> Result<Self, RuleError> {
if value.len() > MAX_ID_BYTES {
return Err(RuleError::new("environment", "exceeds MAX_ID_BYTES (128)"));
}
if !value
.as_bytes()
.first()
.is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_')
|| !value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_')
{
return Err(RuleError::value(
"environment",
"invalid variable name",
value,
));
}
if value.starts_with("LD_") || matches!(value, "GCONV_PATH" | "GLIBC_TUNABLES") {
return Err(RuleError::value(
"environment",
"prohibited library-search or loader-control variable",
value,
));
}
if value.starts_with("ONELF_") {
return Err(RuleError::value(
"environment",
"prohibited runtime-owned variable",
value,
));
}
Ok(Self(value.to_owned()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ValueShape {
Single,
List,
}
pub(crate) type NameShape = (&'static str, ValueShape, ResourceKind);
pub(crate) type AssignedNames = &'static [NameShape];
pub(crate) const HOOK_NAMES: &[(RuntimeHookId, AssignedNames)] = &[
(
RuntimeHookId::AlsaConfigPathNaturalSort,
&[("ALSA_CONFIG_PATH", ValueShape::List, ResourceKind::File)],
),
(
RuntimeHookId::GdkCacheAbsolutise,
&[
(
"GDK_PIXBUF_MODULE_FILE",
ValueShape::Single,
ResourceKind::File,
),
("GTK_IM_MODULE_FILE", ValueShape::Single, ResourceKind::File),
],
),
(
RuntimeHookId::FontconfigGenerate,
&[("FONTCONFIG_FILE", ValueShape::Single, ResourceKind::File)],
),
(
RuntimeHookId::GioModulePaths,
&[
(
"GIO_MODULE_DIR",
ValueShape::Single,
ResourceKind::Directory,
),
(
"GIO_EXTRA_MODULES",
ValueShape::List,
ResourceKind::Directory,
),
],
),
(
RuntimeHookId::TclTkLibraries,
&[
("TCL_LIBRARY", ValueShape::Single, ResourceKind::Directory),
("TK_LIBRARY", ValueShape::Single, ResourceKind::Directory),
],
),
];
pub(crate) const PROCEDURE_NAMES: AssignedNames = &[
("GCONV_PATH", ValueShape::List, ResourceKind::Directory),
(
"LIBDECOR_PLUGIN_DIR",
ValueShape::Single,
ResourceKind::Directory,
),
(
"LIBGL_DRIVERS_PATH",
ValueShape::List,
ResourceKind::Directory,
),
(
"LIBVA_DRIVERS_PATH",
ValueShape::List,
ResourceKind::Directory,
),
(
"GBM_BACKENDS_PATH",
ValueShape::List,
ResourceKind::Directory,
),
(
"__EGL_VENDOR_LIBRARY_DIRS",
ValueShape::List,
ResourceKind::Directory,
),
("VK_DRIVER_FILES", ValueShape::List, ResourceKind::File),
("VK_ADD_DRIVER_FILES", ValueShape::List, ResourceKind::File),
(
"DRIRC_CONFIGDIR",
ValueShape::Single,
ResourceKind::Directory,
),
(
"LIBDRM_IDS_PATH",
ValueShape::Single,
ResourceKind::Directory,
),
("TERMINFO_DIRS", ValueShape::List, ResourceKind::Directory),
("SSL_CERT_FILE", ValueShape::Single, ResourceKind::File),
("CURL_CA_BUNDLE", ValueShape::Single, ResourceKind::File),
("LOCPATH", ValueShape::List, ResourceKind::Directory),
];
fn runtime_owned(name: &str) -> bool {
HOOK_NAMES
.iter()
.flat_map(|(_, names)| names.iter())
.chain(PROCEDURE_NAMES)
.any(|(owned, _, _)| *owned == name)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Trigger {
pub soname_prefix: String,
}
impl Trigger {
pub fn matches_soname(&self, soname: &str) -> bool {
valid_prefix(&self.soname_prefix)
&& valid_soname(soname)
&& soname.starts_with(&self.soname_prefix)
}
}
fn valid_prefix(value: &str) -> bool {
valid_soname(value) && !value.bytes().any(|b| b":*?[]".contains(&b))
}
fn valid_soname(value: &str) -> bool {
!value.is_empty()
&& value.len() <= MAX_PATH_BYTES
&& value != "."
&& value != ".."
&& !value
.bytes()
.any(|b| b.is_ascii_control() || b"/\\".contains(&b))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResourceKind {
File,
Directory,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Requirement {
Required,
Optional,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceBase {
InstallationPrefix,
LibraryDir,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceCandidate {
pub base: SourceBase,
pub path: RelativePath,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResourceGroup {
pub id: RuleId,
pub kind: ResourceKind,
pub candidates: Vec<SourceCandidate>,
pub destination: RelativePath,
pub requirement: Requirement,
pub members: Vec<RelativePath>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HelperGroup {
pub id: RuleId,
pub group: RuleId,
pub kind: ResourceKind,
pub candidates: Vec<SourceCandidate>,
pub destination: RelativePath,
pub requirement: Requirement,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Resources {
pub plugins: Vec<ResourceGroup>,
pub data: Vec<ResourceGroup>,
pub helpers: Vec<HelperGroup>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuntimeBase {
PackageRoot,
LibraryRoot,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeCandidate {
pub base: RuntimeBase,
pub path: RelativePath,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathSelection {
First,
All,
FirstPerLibraryRoot,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Inheritance {
Preserve,
Replace,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimePathGroup {
pub id: RuleId,
pub kind: ResourceKind,
pub candidates: Vec<RuntimeCandidate>,
pub variables: Vec<EnvironmentName>,
pub selection: PathSelection,
pub inheritance: Inheritance,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum BuildHookId {
AlsaConfPatch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum RuntimeHookId {
AlsaConfigPathNaturalSort,
GdkCacheAbsolutise,
FontconfigGenerate,
GioModulePaths,
TclTkLibraries,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BuildRules {
pub hooks: Vec<BuildHookId>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RuntimeRules {
pub paths: Vec<RuntimePathGroup>,
pub hooks: Vec<RuntimeHookId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Descriptor {
pub schema: u16,
pub id: RuleId,
pub triggers: Vec<Trigger>,
pub resources: Resources,
pub build: BuildRules,
pub runtime: RuntimeRules,
}
impl Descriptor {
pub fn validate(&self) -> Result<(), RuleError> {
self.count_records(&mut RecordBudget::default())?;
self.validate_fields()
}
fn count_records(&self, budget: &mut RecordBudget) -> Result<(), RuleError> {
budget.add(1)?;
budget.add(self.triggers.len())?;
budget.add(self.resources.plugins.len())?;
budget.add(self.resources.data.len())?;
budget.add(self.resources.helpers.len())?;
budget.add(self.runtime.paths.len())?;
budget.add(self.build.hooks.len())?;
budget.add(self.runtime.hooks.len())?;
for resource in self.resources.plugins.iter().chain(&self.resources.data) {
budget.add(resource.candidates.len())?;
budget.add(resource.members.len())?;
}
for helper in &self.resources.helpers {
budget.add(1)?;
budget.add(helper.candidates.len())?;
}
for path in &self.runtime.paths {
budget.add(path.candidates.len())?;
budget.add(path.variables.len())?;
}
Ok(())
}
fn validate_fields(&self) -> Result<(), RuleError> {
if self.schema != SCHEMA_VERSION {
return Err(RuleError::new(
"schema",
&format!(
"unsupported version {} for {}",
self.schema,
self.id.as_str()
),
));
}
let mut triggers = BTreeSet::new();
for trigger in &self.triggers {
if !valid_prefix(&trigger.soname_prefix) {
return Err(RuleError::value(
"soname-prefix",
"expected a literal soname prefix",
&trigger.soname_prefix,
));
}
unique(
&mut triggers,
trigger.soname_prefix.as_str(),
"soname-prefix",
)?;
}
let mut groups = BTreeSet::new();
for resource in self.resources.plugins.iter().chain(&self.resources.data) {
unique(&mut groups, &resource.id, "resource id")?;
require_candidates(&resource.candidates, resource.id.as_str())?;
}
for plugin in &self.resources.plugins {
if plugin.kind != ResourceKind::Directory {
return Err(RuleError::value(
"plugins.kind",
"must be directory",
plugin.id.as_str(),
));
}
let mut members = BTreeSet::new();
for member in &plugin.members {
unique(&mut members, member, "plugins.members")?;
}
}
if let Some(data) = self.resources.data.iter().find(|d| !d.members.is_empty()) {
return Err(RuleError::value(
"data.members",
"only plugin groups list members",
data.id.as_str(),
));
}
for helper in &self.resources.helpers {
if !groups.contains(&helper.group) {
return Err(RuleError::value(
"helpers.group",
"unknown plugin or data group",
helper.group.as_str(),
));
}
if helper.kind != ResourceKind::File {
return Err(RuleError::value(
"helpers.kind",
"must be file",
helper.id.as_str(),
));
}
require_candidates(&helper.candidates, helper.id.as_str())?;
}
for helper in &self.resources.helpers {
unique(&mut groups, &helper.id, "resource id")?;
}
self.runtime.validate_fields()?;
let mut build_hooks = BTreeSet::new();
for hook in &self.build.hooks {
unique(&mut build_hooks, hook, "build hook")?;
}
Ok(())
}
}
impl RuntimeRules {
pub fn validate(&self) -> Result<(), RuleError> {
let mut budget = RecordBudget::default();
budget.add(self.paths.len())?;
budget.add(self.hooks.len())?;
for path in &self.paths {
budget.add(path.candidates.len())?;
budget.add(path.variables.len())?;
}
self.validate_fields()
}
fn validate_fields(&self) -> Result<(), RuleError> {
let mut paths = BTreeSet::new();
for path in &self.paths {
unique(&mut paths, &path.id, "runtime path id")?;
require_candidates(&path.candidates, path.id.as_str())?;
if path.variables.is_empty() {
return Err(RuleError::value(
"runtime.paths.variables",
"requires at least one environment alias",
path.id.as_str(),
));
}
let mut variables = BTreeSet::new();
for variable in &path.variables {
if runtime_owned(variable.as_str()) {
return Err(RuleError::value(
"runtime.paths.variables",
"assigned by a runtime hook or launch procedure",
variable.as_str(),
));
}
unique(&mut variables, variable, "environment alias")?;
}
if path.selection == PathSelection::FirstPerLibraryRoot
&& path
.candidates
.iter()
.any(|c| c.base != RuntimeBase::LibraryRoot)
{
return Err(RuleError::value(
"runtime.paths.selection",
"first-per-library-root requires library-root candidates",
path.id.as_str(),
));
}
}
let mut runtime_hooks = BTreeSet::new();
for hook in &self.hooks {
unique(&mut runtime_hooks, hook, "runtime hook")?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ExclusionBase {
InstallationPrefix,
LibraryDirs,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ExclusionEntry {
pub base: ExclusionBase,
pub path: RelativePath,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InferenceExclusions {
pub shared_roots: Vec<RelativePath>,
pub shared_trees: Vec<ExclusionEntry>,
pub libc: Vec<ExclusionEntry>,
pub drivers: Vec<ExclusionEntry>,
}
impl InferenceExclusions {
pub fn validate(&self) -> Result<(), RuleError> {
let mut budget = RecordBudget::default();
budget.add(self.shared_roots.len())?;
budget.add(self.shared_trees.len())?;
budget.add(self.libc.len())?;
budget.add(self.drivers.len())?;
if self.shared_roots.is_empty() {
return Err(RuleError::new(
"shared-roots",
"must name at least one root",
));
}
let mut roots = BTreeSet::new();
for root in &self.shared_roots {
unique(&mut roots, root, "shared root")?;
}
let mut owned = BTreeSet::new();
for entry in self
.shared_trees
.iter()
.chain(&self.libc)
.chain(&self.drivers)
{
if entry.base == ExclusionBase::InstallationPrefix && roots.contains(&entry.path) {
return Err(RuleError::value(
"shared-roots",
"also names a tree, libc or driver directory",
entry.path.as_str(),
));
}
let first = entry.path.as_str().split('/').next().unwrap_or_default();
if entry.base == ExclusionBase::LibraryDirs && is_multiarch(first) {
return Err(RuleError::value(
"lib-dirs entry",
"begins with a multiarch library directory",
entry.path.as_str(),
));
}
unique(&mut owned, entry, "shared tree, libc or driver directory")?;
}
Ok(())
}
}
pub fn is_multiarch(name: &str) -> bool {
let mut parts = name.splitn(3, '-');
matches!(
(parts.next(), parts.next(), parts.next()),
(Some(arch), Some("linux"), Some(abi)) if !arch.is_empty() && !abi.is_empty()
)
}
#[derive(Default)]
struct RecordBudget(usize);
impl RecordBudget {
fn add(&mut self, count: usize) -> Result<(), RuleError> {
self.0 = self
.0
.checked_add(count)
.filter(|n| *n <= MAX_RECORDS)
.ok_or_else(|| RuleError::new("records", "exceeds MAX_RECORDS (4096)"))?;
Ok(())
}
}
fn unique<T: Ord + fmt::Debug>(
set: &mut BTreeSet<T>,
value: T,
field: &str,
) -> Result<(), RuleError> {
if set.contains(&value) {
return Err(RuleError::new(
field,
&format!("duplicate identity {value:?}"),
));
}
set.insert(value);
Ok(())
}
fn require_candidates<T>(candidates: &[T], id: &str) -> Result<(), RuleError> {
if candidates.is_empty() {
return Err(RuleError::value(
"candidates",
"requires at least one source or runtime candidate",
id,
));
}
Ok(())
}
pub fn validate_catalog(
builtins: &[Descriptor],
local: &[Descriptor],
disabled: &[RuleId],
) -> Result<(), RuleError> {
let mut budget = RecordBudget::default();
budget.add(disabled.len())?;
for rule in builtins.iter().chain(local) {
rule.count_records(&mut budget)?;
}
let mut ids = BTreeSet::new();
for rule in builtins.iter().chain(local) {
rule.validate_fields()?;
unique(&mut ids, &rule.id, "rule id")?;
}
let mut disabled_ids = BTreeSet::new();
for id in disabled {
if !ids.contains(id) {
return Err(RuleError::value(
"disable-rules",
"unknown rule id",
id.as_str(),
));
}
unique(&mut disabled_ids, id, "disabled rule id")?;
}
let mut mapped = BTreeSet::new();
for rule in builtins.iter().chain(local) {
if disabled_ids.contains(&rule.id) {
continue;
}
for variable in rule.runtime.paths.iter().flat_map(|path| &path.variables) {
if !mapped.insert(variable) {
return Err(RuleError::value(
"runtime.paths.variables",
"mapped by more than one enabled runtime path group",
variable.as_str(),
));
}
}
}
Ok(())
}