use crate::profiles::{ProfileRuntimeMeta, ProfileStore};
use crate::style::{Style, parse_palette};
use crate::terminal_text::escape_untrusted;
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
use std::fs::OpenOptions;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use thiserror::Error;
pub const RESERVED_PROFILE_RUNTIME_MESSAGE: &str =
"the profile.runtime field is reserved for built-in profiles in this PrismTTY version";
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to read {path}: {source}")]
Read {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to parse YAML: {0}")]
Yaml(#[from] serde_norway::Error),
#[error("failed to parse YAML in {path}: {source}")]
YamlFile {
path: PathBuf,
source: serde_norway::Error,
},
#[error("unknown profile '{0}'")]
UnknownProfile(String),
#[error("cyclic profile inheritance: {0}")]
CyclicProfileInheritance(String),
#[error("profile files must include profile.name")]
MissingProfileName,
#[error("bundled profile files must include profile.runtime")]
MissingProfileRuntime,
#[error("{0}")]
ReservedProfileRuntime(&'static str),
#[error("rule '{description}' has invalid style: {message}")]
InvalidStyle {
description: String,
message: String,
},
#[error("palette has invalid color: {0}")]
InvalidPalette(String),
#[error("rule '{description}' has invalid capture key: {key}")]
InvalidCaptureKey {
description: String,
key: String,
},
}
#[derive(Clone, Debug, Default)]
pub struct PrismConfig {
pub rules: Vec<RuleSpec>,
pub enabled_profiles: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct RuleSpec {
pub description: String,
pub regex: String,
pub style: RuleStyle,
pub exclusive: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum CaptureRef {
Index(usize),
Name(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum RuleStyle {
Whole(Style),
Captures(BTreeMap<CaptureRef, Style>),
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RulesDoc {
#[serde(default)]
profile: Option<ProfileMetaDoc>,
#[serde(default)]
palette: BTreeMap<String, String>,
#[serde(default)]
rules: Vec<RuleDoc>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProfileMetaDoc {
pub name: String,
#[serde(default)]
pub inherits: Vec<String>,
#[serde(default)]
pub detection: Vec<String>,
#[serde(default)]
pub(crate) runtime: Option<ProfileRuntimeMeta>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RuleDoc {
#[serde(default)]
description: String,
regex: String,
color: serde_norway::Value,
#[serde(default)]
exclusive: bool,
}
#[derive(Clone, Debug)]
pub struct LoadedProfileFile {
pub meta: ProfileMetaDoc,
pub runtime: Option<ProfileRuntimeMeta>,
pub rules: Vec<RuleSpec>,
}
impl PrismConfig {
pub fn from_chromaterm_yaml(input: &str) -> Result<Self, ConfigError> {
let doc: RulesDoc = serde_norway::from_str(input)?;
Self::from_rules_doc(doc)
}
fn from_rules_doc(doc: RulesDoc) -> Result<Self, ConfigError> {
let palette = parse_palette(&doc.palette).map_err(ConfigError::InvalidPalette)?;
Ok(Self {
rules: parse_rule_docs(doc.rules, &palette)?,
enabled_profiles: Vec::new(),
})
}
pub fn from_chromaterm_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
let path = path.as_ref();
let input = read_config_file(path).map_err(|source| ConfigError::Read {
path: path.to_path_buf(),
source,
})?;
let doc: RulesDoc =
serde_norway::from_str(&input).map_err(|source| ConfigError::YamlFile {
path: path.to_path_buf(),
source,
})?;
Self::from_rules_doc(doc)
}
pub fn from_profiles(
store: &ProfileStore,
profile_names: &[&str],
) -> Result<Self, ConfigError> {
let mut rules = Vec::new();
let mut loaded = BTreeSet::new();
for profile_name in store.top_level_profile_names(profile_names)? {
store.append_profile_rules(profile_name, &mut loaded, &mut rules)?;
}
Ok(Self {
rules,
enabled_profiles: loaded.into_iter().collect(),
})
}
pub fn merge(mut self, mut other: Self) -> Self {
self.rules.append(&mut other.rules);
for profile in other.enabled_profiles {
if !self.enabled_profiles.contains(&profile) {
self.enabled_profiles.push(profile);
}
}
self
}
}
const MAX_CONFIG_FILE_BYTES: u64 = 1024 * 1024;
pub(crate) const MAX_PROFILE_TEST_FILE_BYTES: u64 = 16 * 1024 * 1024;
pub(crate) fn read_bounded_regular_file(
path: &Path,
max_bytes: u64,
kind: &str,
) -> std::io::Result<Vec<u8>> {
use std::io::Read as _;
let file = OpenOptions::new()
.read(true)
.custom_flags(nix::libc::O_NONBLOCK)
.open(path)?;
let metadata = file.metadata()?;
if !metadata.file_type().is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("{kind} path is not a regular file: {}", path.display()),
));
}
let len = metadata.len();
if len > max_bytes {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("{kind} file is too large ({len} bytes; limit {max_bytes})"),
));
}
let mut input = Vec::with_capacity(len as usize);
file.take(max_bytes + 1).read_to_end(&mut input)?;
if input.len() as u64 > max_bytes {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("{kind} file is too large (more than {max_bytes} bytes)"),
));
}
Ok(input)
}
fn read_config_file(path: &Path) -> std::io::Result<String> {
let input = read_bounded_regular_file(path, MAX_CONFIG_FILE_BYTES, "config")?;
String::from_utf8(input)
.map_err(|source| std::io::Error::new(std::io::ErrorKind::InvalidData, source))
}
pub fn load_profile_file(path: impl AsRef<Path>) -> Result<LoadedProfileFile, ConfigError> {
let path = path.as_ref();
let input = read_profile_file_contents(path)?;
parse_profile_file_contents(path, &input)
}
pub(crate) fn read_profile_file_contents(path: &Path) -> Result<String, ConfigError> {
read_config_file(path).map_err(|source| ConfigError::Read {
path: path.to_path_buf(),
source,
})
}
pub(crate) fn parse_profile_file_contents(
path: &Path,
input: &str,
) -> Result<LoadedProfileFile, ConfigError> {
let doc: RulesDoc = serde_norway::from_str(input).map_err(|source| ConfigError::YamlFile {
path: path.to_path_buf(),
source,
})?;
match profile_file_from_doc(doc, ProfileYamlMode::User) {
Err(ConfigError::Yaml(source)) => Err(ConfigError::YamlFile {
path: path.to_path_buf(),
source,
}),
result => result,
}
}
pub fn parse_profile_yaml(input: &str) -> Result<LoadedProfileFile, ConfigError> {
parse_profile_yaml_with_mode(input, ProfileYamlMode::User)
}
pub(crate) fn parse_builtin_profile_yaml(input: &str) -> Result<LoadedProfileFile, ConfigError> {
parse_profile_yaml_with_mode(input, ProfileYamlMode::Bundled)
}
#[derive(Clone, Copy)]
enum ProfileYamlMode {
User,
Bundled,
}
fn parse_profile_yaml_with_mode(
input: &str,
mode: ProfileYamlMode,
) -> Result<LoadedProfileFile, ConfigError> {
let doc: RulesDoc = serde_norway::from_str(input)?;
profile_file_from_doc(doc, mode)
}
fn profile_file_from_doc(
doc: RulesDoc,
mode: ProfileYamlMode,
) -> Result<LoadedProfileFile, ConfigError> {
let mut meta = doc.profile.ok_or(ConfigError::MissingProfileName)?;
if meta.name.trim().is_empty() {
return Err(ConfigError::MissingProfileName);
}
let (inherits, detection) =
crate::profiles::normalize_and_validate_profile_metadata(meta.inherits, meta.detection)
.map_err(|error| {
ConfigError::Yaml(<serde_norway::Error as serde::de::Error>::custom(
error.to_string(),
))
})?;
meta.inherits = inherits;
meta.detection = detection;
let runtime = meta.runtime.take();
match mode {
ProfileYamlMode::User if runtime.is_some() => {
return Err(ConfigError::ReservedProfileRuntime(
RESERVED_PROFILE_RUNTIME_MESSAGE,
));
}
ProfileYamlMode::Bundled if runtime.is_none() => {
return Err(ConfigError::MissingProfileRuntime);
}
_ => {}
}
let palette = parse_palette(&doc.palette).map_err(ConfigError::InvalidPalette)?;
Ok(LoadedProfileFile {
meta,
runtime,
rules: parse_rule_docs(doc.rules, &palette)?,
})
}
fn parse_rule_docs(
rule_docs: Vec<RuleDoc>,
palette: &BTreeMap<String, crate::style::Rgb>,
) -> Result<Vec<RuleSpec>, ConfigError> {
rule_docs
.into_iter()
.enumerate()
.map(|(idx, rule)| {
let description = if rule.description.trim().is_empty() {
format!("rule {}", idx + 1)
} else {
rule.description
};
let style = parse_color_doc(&description, rule.color, palette)?;
Ok(RuleSpec {
description,
regex: rule.regex,
style,
exclusive: rule.exclusive,
})
})
.collect()
}
fn parse_color_doc(
description: &str,
color: serde_norway::Value,
palette: &BTreeMap<String, crate::style::Rgb>,
) -> Result<RuleStyle, ConfigError> {
match color {
serde_norway::Value::String(spec) => {
Ok(RuleStyle::Whole(parse_style(description, &spec, palette)?))
}
serde_norway::Value::Mapping(captures) => {
let mut parsed = BTreeMap::new();
for (group, spec) in captures {
let group = parse_capture_ref(description, group)?;
let spec = spec.as_str().ok_or_else(|| ConfigError::InvalidStyle {
description: escape_untrusted(description),
message: "capture color must be a string".to_string(),
})?;
parsed.insert(group, parse_style(description, spec, palette)?);
}
Ok(RuleStyle::Captures(parsed))
}
_ => Err(ConfigError::InvalidStyle {
description: escape_untrusted(description),
message: "color must be a string or capture-group mapping".to_string(),
}),
}
}
fn parse_capture_ref(
description: &str,
value: serde_norway::Value,
) -> Result<CaptureRef, ConfigError> {
match value {
serde_norway::Value::Number(number) => {
let Some(group) = number.as_u64() else {
return Err(ConfigError::InvalidCaptureKey {
description: escape_untrusted(description),
key: escape_untrusted(&number.to_string()),
});
};
Ok(CaptureRef::Index(group as usize))
}
serde_norway::Value::String(name) => parse_capture_ref_string(description, name),
other => Err(ConfigError::InvalidCaptureKey {
description: escape_untrusted(description),
key: escape_untrusted(&format!("{other:?}")),
}),
}
}
fn parse_capture_ref_string(description: &str, name: String) -> Result<CaptureRef, ConfigError> {
if name.bytes().all(|byte| byte.is_ascii_digit()) {
return name.parse::<usize>().map(CaptureRef::Index).map_err(|_| {
ConfigError::InvalidCaptureKey {
description: escape_untrusted(description),
key: escape_untrusted(&name),
}
});
}
if is_valid_capture_name(&name) {
Ok(CaptureRef::Name(name))
} else {
Err(ConfigError::InvalidCaptureKey {
description: escape_untrusted(description),
key: escape_untrusted(&name),
})
}
}
fn is_valid_capture_name(name: &str) -> bool {
let mut bytes = name.bytes();
let Some(first) = bytes.next() else {
return false;
};
(first.is_ascii_alphabetic() || first == b'_')
&& bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
}
fn parse_style(
description: &str,
spec: &str,
palette: &BTreeMap<String, crate::style::Rgb>,
) -> Result<Style, ConfigError> {
let palette = (!palette.is_empty()).then_some(palette);
Style::parse_with_palette(spec, palette).map_err(|message| ConfigError::InvalidStyle {
description: escape_untrusted(description),
message: escape_untrusted(&message),
})
}
#[cfg(test)]
mod tests {
use super::{
PrismConfig, RESERVED_PROFILE_RUNTIME_MESSAGE, load_profile_file,
parse_builtin_profile_yaml, parse_profile_yaml,
};
#[test]
fn read_config_file_rejects_oversized_files() {
let small = tempfile::NamedTempFile::new().expect("temp file");
std::fs::write(small.path(), "rules: []\n").expect("write small");
assert!(
super::read_config_file(small.path()).is_ok(),
"a normal config file should read"
);
let big = tempfile::NamedTempFile::new().expect("temp file");
std::fs::write(
big.path(),
vec![b'#'; super::MAX_CONFIG_FILE_BYTES as usize + 1],
)
.expect("write big");
assert!(
super::read_config_file(big.path()).is_err(),
"an oversized config file should be rejected"
);
}
#[test]
fn read_config_file_rejects_non_regular_files() {
let dir = tempfile::tempdir().expect("tempdir creates");
let error =
super::read_config_file(dir.path()).expect_err("directory paths should be rejected");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
}
#[test]
fn read_config_file_rejects_fifo_without_waiting_for_a_writer() {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
use std::time::{Duration, Instant};
let dir = tempfile::tempdir().expect("tempdir creates");
let fifo = dir.path().join("config.fifo");
let path = CString::new(fifo.as_os_str().as_bytes()).expect("path has no NUL");
let result = unsafe { nix::libc::mkfifo(path.as_ptr(), 0o600) };
assert_eq!(
result,
0,
"mkfifo failed: {}",
std::io::Error::last_os_error()
);
let started = Instant::now();
let error = super::read_config_file(&fifo).expect_err("FIFO must be rejected");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
assert!(started.elapsed() < Duration::from_secs(1));
}
#[test]
fn user_profile_runtime_is_reserved() {
let yaml = r#"
profile:
name: custom-router
runtime:
priority: 5
startup_prompt: cisco_host_marker
runtime_prompt: cisco_host_marker
strong_signals: []
rules: []
"#;
let err = parse_profile_yaml(yaml).expect_err("user profile.runtime must be rejected");
assert_eq!(err.to_string(), RESERVED_PROFILE_RUNTIME_MESSAGE);
}
#[test]
fn profile_metadata_is_normalized_and_deduplicated() {
let yaml = r#"
profile:
name: custom-router
inherits: [" generic ", generic, "base", "base ", ""]
detection: [" JUNOS ", junos, "jUnOs", " IOS ", ""]
rules: []
"#;
let loaded = parse_profile_yaml(yaml).expect("bounded profile metadata should parse");
assert_eq!(loaded.meta.inherits, ["generic", "base"]);
assert_eq!(loaded.meta.detection, ["JUNOS", "IOS"]);
}
#[test]
fn profile_parser_rejects_metadata_one_over_the_limit_with_safe_diagnostics() {
let mut yaml =
String::from("profile:\n name: \"bad\\u001b]0;title\\u0007\"\n detection:\n");
for index in 0..=crate::profiles::MAX_PROFILE_DETECTION_HINTS {
yaml.push_str(&format!(" - hint-{index}\n"));
}
yaml.push_str("rules: []\n");
let message = parse_profile_yaml(&yaml)
.expect_err("one unique hint above the limit must fail during parsing")
.to_string();
assert_eq!(
message,
format!(
"failed to parse YAML: profile detection hint count is {}; limit is {}",
crate::profiles::MAX_PROFILE_DETECTION_HINTS + 1,
crate::profiles::MAX_PROFILE_DETECTION_HINTS
)
);
assert!(!message.contains('\u{1b}'), "{message:?}");
assert!(!message.contains('\u{7}'), "{message:?}");
}
#[test]
fn bundled_profile_runtime_rejects_unknown_prompt_matcher() {
let yaml = r#"
profile:
name: broken-builtin
runtime:
priority: 1
startup_prompt: mystery_prompt
runtime_prompt: none
strong_signals: []
rules: []
"#;
let err = parse_builtin_profile_yaml(yaml).expect_err("unknown prompt matcher should fail");
assert!(err.to_string().contains("mystery_prompt"));
}
#[test]
fn chromaterm_file_yaml_errors_include_path() {
let file = tempfile::NamedTempFile::new().expect("temp file creates");
std::fs::write(file.path(), "rules: [").expect("invalid yaml writes");
let err = PrismConfig::from_chromaterm_file(file.path())
.expect_err("invalid file YAML should fail");
let message = err.to_string();
assert!(message.contains(&file.path().display().to_string()));
assert!(message.contains("failed to parse YAML in"));
}
#[test]
fn profile_file_yaml_errors_include_path() {
let file = tempfile::NamedTempFile::new().expect("temp file creates");
std::fs::write(file.path(), "profile: [").expect("invalid yaml writes");
let err = load_profile_file(file.path()).expect_err("invalid profile YAML should fail");
let message = err.to_string();
assert!(message.contains(&file.path().display().to_string()));
assert!(message.contains("failed to parse YAML in"));
}
#[test]
fn capture_names_must_match_pcre2_identifier_shape() {
let yaml = r#"
rules:
- description: named capture
regex: '(?P<name>\w+)'
color:
_valid_name_1: f#ffffff
bad-name: f#ff0000
"#;
let err = PrismConfig::from_chromaterm_yaml(yaml)
.expect_err("invalid capture name should fail during config parsing");
assert_eq!(
err.to_string(),
"rule 'named capture' has invalid capture key: bad-name"
);
}
#[test]
fn config_diagnostics_escape_untrusted_terminal_metadata() {
let yaml = r#"
rules:
- description: "bad\u001b]0;title\u0007"
regex: '(?P<name>\w+)'
color:
"bad\u001b[31m": f#ffffff
"#;
let message = PrismConfig::from_chromaterm_yaml(yaml)
.expect_err("invalid capture name should fail")
.to_string();
assert!(!message.contains('\u{1b}'), "{message:?}");
assert!(!message.contains('\u{7}'), "{message:?}");
assert!(message.contains("\\x1b"), "{message:?}");
assert!(message.contains("\\x07"), "{message:?}");
}
}