use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
const MAX_NAME: usize = 128;
pub const UNKNOWN_COLOUR: &str = "#6b7280";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Tag {
pub colour: String,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct TagFile {
#[serde(default)]
tags: BTreeMap<String, Tag>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Vocabulary {
tags: BTreeMap<String, Tag>,
}
const SEED: &[(&str, &str, &str)] = &[
(
"tlp:red",
"#FF2B2B",
"Not for disclosure, restricted to participants only.",
),
(
"tlp:amber",
"#FFC000",
"Limited disclosure, restricted to participants' organizations.",
),
(
"tlp:amber+strict",
"#FFC000",
"Limited disclosure, restricted to participants' organization only.",
),
(
"tlp:green",
"#33FF00",
"Limited disclosure, restricted to the community.",
),
("tlp:clear", "#FFFFFF", "Disclosure is not limited."),
(
"tlp:white",
"#FFFFFF",
"Superseded by tlp:clear; kept because feeds still send it.",
),
(
"stix-type:",
"#7c3aed",
"The STIX observable type this value exports as, overriding what it looks like.",
),
(
"indicator-type:",
"#2563eb",
"The STIX indicator type, such as malicious-activity.",
),
(
"confidence:",
"#0891b2",
"Confidence in the sighting, 0 to 100.",
),
("identity:", "#059669", "Who reported the sighting."),
("misp-type:", "#d97706", "The MISP attribute type imported."),
(
"misp-category:",
"#d97706",
"The MISP attribute category imported.",
),
("misp-event:", "#d97706", "The MISP event this came from."),
];
impl Vocabulary {
pub fn seeded() -> Self {
let tags = SEED
.iter()
.map(|(name, colour, description)| {
(
(*name).to_string(),
Tag {
colour: (*colour).to_string(),
description: (*description).to_string(),
},
)
})
.collect();
Self { tags }
}
pub fn from_toml(text: &str) -> Result<Self, toml::de::Error> {
let parsed: TagFile = toml::from_str(text)?;
let mut tags = BTreeMap::new();
for (name, tag) in parsed.tags {
match validate(&name, &tag.colour) {
Ok(()) => {
tags.insert(name, tag);
}
Err(e) => log::warn!("Ignoring tag '{name}' in the tag vocabulary: {e}"),
}
}
Ok(Self { tags })
}
pub fn to_toml(&self) -> String {
let mut out = String::from(
"# Written by the SightingDB management interface. Comments added\n\
# here are replaced the next time a tag is saved.\n\
#\n\
# A name ending in ':' colours a whole family, so \"tlp:\" would\n\
# cover every tlp: tag at once.\n\
\n",
);
for (name, tag) in &self.tags {
out.push_str(&format!("[tags.\"{name}\"]\n"));
out.push_str(&format!("colour = \"{}\"\n", tag.colour));
if !tag.description.is_empty() {
let escaped = tag.description.replace('\\', "\\\\").replace('"', "\\\"");
out.push_str(&format!("description = \"{escaped}\"\n"));
}
out.push('\n');
}
out
}
pub fn colour_of(&self, tag: &str) -> Option<&str> {
if let Some(known) = self.tags.get(tag) {
return Some(&known.colour);
}
self.tags
.iter()
.filter(|(name, _)| name.ends_with(':') && tag.starts_with(name.as_str()))
.max_by_key(|(name, _)| name.len())
.map(|(_, tag)| tag.colour.as_str())
}
pub fn get(&self, name: &str) -> Option<&Tag> {
self.tags.get(name)
}
pub fn entries(&self) -> impl Iterator<Item = (&String, &Tag)> {
self.tags.iter()
}
#[cfg(test)]
pub fn len(&self) -> usize {
self.tags.len()
}
pub fn set(&mut self, name: &str, colour: &str, description: &str) -> Result<(), String> {
let name = name.trim();
validate(name, colour)?;
self.tags.insert(
name.to_string(),
Tag {
colour: normalise_colour(colour),
description: description.trim().to_string(),
},
);
Ok(())
}
pub fn remove(&mut self, name: &str) -> bool {
self.tags.remove(name.trim()).is_some()
}
}
fn validate(name: &str, colour: &str) -> Result<(), String> {
if name.is_empty() {
return Err("a tag needs a name".to_string());
}
if name.len() > MAX_NAME {
return Err(format!("a tag name is at most {MAX_NAME} characters"));
}
if name.contains(',') {
return Err("a tag cannot contain a comma, which is what separates tags".to_string());
}
if name.trim() != name {
return Err("a tag cannot start or end with a space".to_string());
}
if name.chars().any(|c| c.is_control()) {
return Err("a tag cannot contain control characters".to_string());
}
validate_colour(colour)
}
fn validate_colour(colour: &str) -> Result<(), String> {
let hex = colour.strip_prefix('#').ok_or("a colour starts with '#'")?;
if hex.len() != 3 && hex.len() != 6 {
return Err("a colour is #rgb or #rrggbb".to_string());
}
if !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err("a colour is hexadecimal".to_string());
}
Ok(())
}
fn normalise_colour(colour: &str) -> String {
colour.trim().to_lowercase()
}
pub fn split(tags: &str) -> impl Iterator<Item = &str> {
tags.split(',').map(str::trim).filter(|t| !t.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_seeded_vocabulary_uses_misps_own_tlp_colours() {
let vocabulary = Vocabulary::seeded();
assert_eq!(vocabulary.colour_of("tlp:red"), Some("#FF2B2B"));
assert_eq!(vocabulary.colour_of("tlp:green"), Some("#33FF00"));
}
#[test]
fn a_family_colours_the_tags_under_it() {
let vocabulary = Vocabulary::seeded();
assert_eq!(
vocabulary.colour_of("stix-type:ipv4-addr"),
vocabulary.colour_of("stix-type:domain-name"),
"two tags in one family should share its colour"
);
assert!(vocabulary.colour_of("stix-type:anything-at-all").is_some());
}
#[test]
fn an_exact_tag_wins_over_its_family() {
let mut vocabulary = Vocabulary::seeded();
vocabulary.set("misp-type:", "#111111", "").unwrap();
vocabulary.set("misp-type:md5", "#222222", "").unwrap();
assert_eq!(vocabulary.colour_of("misp-type:md5"), Some("#222222"));
assert_eq!(vocabulary.colour_of("misp-type:sha1"), Some("#111111"));
}
#[test]
fn the_longest_family_wins() {
let mut vocabulary = Vocabulary::default();
vocabulary.set("a:", "#111111", "").unwrap();
vocabulary.set("a:b:", "#222222", "").unwrap();
assert_eq!(vocabulary.colour_of("a:b:c"), Some("#222222"));
assert_eq!(vocabulary.colour_of("a:z"), Some("#111111"));
}
#[test]
fn an_unknown_tag_has_no_colour_of_its_own() {
assert_eq!(Vocabulary::seeded().colour_of("something-new"), None);
}
#[test]
fn a_tag_cannot_contain_the_separator() {
let mut vocabulary = Vocabulary::default();
let refused = vocabulary.set("one,two", "#ffffff", "");
assert!(refused.is_err(), "a comma in a tag must be refused");
assert!(refused.unwrap_err().contains("comma"));
}
#[test]
fn a_colour_must_be_hexadecimal() {
let mut vocabulary = Vocabulary::default();
assert!(vocabulary.set("t", "red", "").is_err());
assert!(vocabulary.set("t", "#gggggg", "").is_err());
assert!(vocabulary.set("t", "#12345", "").is_err());
assert!(vocabulary.set("t", "#abc", "").is_ok());
assert!(vocabulary.set("t", "#AABBCC", "").is_ok());
}
#[test]
fn a_vocabulary_survives_a_round_trip() {
let mut vocabulary = Vocabulary::seeded();
vocabulary
.set("odd", "#abcdef", "a \"quoted\" back\\slash")
.unwrap();
let reloaded = Vocabulary::from_toml(&vocabulary.to_toml()).expect("valid TOML");
assert_eq!(reloaded, vocabulary);
assert_eq!(
reloaded.get("odd").map(|t| t.description.as_str()),
Some("a \"quoted\" back\\slash")
);
}
#[test]
fn a_dotted_tag_name_round_trips() {
let mut vocabulary = Vocabulary::default();
vocabulary.set("file.md5", "#123456", "").unwrap();
let reloaded = Vocabulary::from_toml(&vocabulary.to_toml()).expect("valid TOML");
assert!(
reloaded.get("file.md5").is_some(),
"{}",
vocabulary.to_toml()
);
}
#[test]
fn a_malformed_entry_does_not_lose_the_rest() {
let text = r##"
[tags."good"]
colour = "#ffffff"
[tags."bad"]
colour = "not a colour"
"##;
let vocabulary = Vocabulary::from_toml(text).expect("the file itself parses");
assert!(vocabulary.get("good").is_some());
assert!(vocabulary.get("bad").is_none());
}
#[test]
fn removing_a_tag_leaves_the_others() {
let mut vocabulary = Vocabulary::seeded();
let before = vocabulary.len();
assert!(vocabulary.remove("tlp:red"));
assert!(
!vocabulary.remove("tlp:red"),
"removing twice is not a change"
);
assert_eq!(vocabulary.len(), before - 1);
assert!(vocabulary.get("tlp:green").is_some());
}
#[test]
fn splitting_a_tag_field_drops_blanks_and_spaces() {
let got: Vec<&str> = split(" a , ,b,, c ").collect();
assert_eq!(got, vec!["a", "b", "c"]);
}
}