diff --git a/query-security-txt/Cargo.toml b/query-security-txt/Cargo.toml index 43b8da1..9f4189b 100644 --- a/query-security-txt/Cargo.toml +++ b/query-security-txt/Cargo.toml @@ -11,6 +11,6 @@ license = "MIT OR Apache-2.0" bincode = "1.3.3" clap = { version = "3.1.6", features = ["derive"] } color-eyre = "0.6.1" -solana-security-txt = { version = "0.1.4", path = "../security-txt" } +solana-security-txt = { version = "0.1.4", path = "../security-txt", features = ["parser"] } solana-client = "1.10.0" solana-sdk = "1.10.0" diff --git a/security-txt/Cargo.toml b/security-txt/Cargo.toml index f42c2fb..17de4e5 100644 --- a/security-txt/Cargo.toml +++ b/security-txt/Cargo.toml @@ -7,7 +7,10 @@ readme = "../README.md" repository = "https://github.com/neodyme-labs/solana-security-txt" license = "MIT OR Apache-2.0" +[features] +# when no features are enabled, this crate has no dependencies. The parser requires two, but won't be used on-chain. +parser = ["thiserror", "twoway"] [dependencies] -thiserror = "1.0.30" -twoway = "0.2.2" +thiserror = {version = "=1.0.30", optional = true} +twoway = {version = "=0.2.2", optional = true} diff --git a/security-txt/src/lib.rs b/security-txt/src/lib.rs index aa68303..960ad98 100644 --- a/security-txt/src/lib.rs +++ b/security-txt/src/lib.rs @@ -60,11 +60,11 @@ //! //! The string the macro builds begins with the start marker `=======BEGIN SECURITY.TXT V1=======\0`, and ends with the end marker `=======END SECURITY.TXT V1=======\0`. In between is a list of an even amount of strings, delimited by nullbytes. Every two strings form a key-value-pair. -use core::fmt; -use std::{collections::HashMap, fmt::Display}; -use thiserror::Error; -use twoway::find_bytes; +#[cfg(feature = "parser")] +mod parser; +#[cfg(feature = "parser")] +pub use crate::parser::*; pub const SECURITY_TXT_BEGIN: &str = "=======BEGIN SECURITY.TXT V1=======\0"; pub const SECURITY_TXT_END: &str = "=======END SECURITY.TXT V1=======\0"; @@ -83,226 +83,3 @@ macro_rules! security_txt { }; } -#[derive(Error, Debug)] -pub enum SecurityTxtError { - #[error("security.txt doesn't start with the right string")] - InvalidSecurityTxtBegin, - #[error("Couldn't find end string")] - EndNotFound, - #[error("Couldn't find start string")] - StartNotFound, - #[error("Invalid field: `{0:?}`")] - InvalidField(Vec<u8>), - #[error("Unknown field: `{0}`")] - UnknownField(String), - #[error("Invalid value `{0:?}` for field `{1}`")] - InvalidValue(Vec<u8>, String), - #[error("Invalid contact `{0}`")] - InvalidContact(String), - #[error("Missing field: `{0}`")] - MissingField(String), - #[error("Duplicate field: `{0}`")] - DuplicateField(String), - #[error("Uneven amount of parts")] - Uneven, -} - -pub enum Contact { - Email(String), - Discord(String), - Telegram(String), - Twitter(String), - Link(String), - Other(String), -} - -impl Display for Contact { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - Contact::Discord(s) => write!(f, "Discord: {}", s), - Contact::Email(s) => write!(f, "Email: {}", s), - Contact::Telegram(s) => write!(f, "Telegram: {}", s), - Contact::Twitter(s) => write!(f, "Twitter: {}", s), - Contact::Link(s) => write!(f, "Link: {}", s), - Contact::Other(s) => write!(f, "Other: {}", s), - } - } -} - -impl Contact { - pub fn from_str(s: &str) -> Result<Self, SecurityTxtError> { - let (typ, value) = s - .split_once(":") - .ok_or_else(|| SecurityTxtError::InvalidContact(s.to_string()))?; - let (contact_type, contact_info) = (typ.trim(), value.trim()); - match contact_type.to_ascii_lowercase().as_str() { - "email" => Ok(Contact::Email(contact_info.to_string())), - "discord" => Ok(Contact::Discord(contact_info.to_string())), - "telegram" => Ok(Contact::Telegram(contact_info.to_string())), - "twitter" => Ok(Contact::Twitter(contact_info.to_string())), - "link" => Ok(Contact::Link(contact_info.to_string())), - "other" => Ok(Contact::Other(contact_info.to_string())), - _ => Err(SecurityTxtError::InvalidContact(s.to_string())), - } - } -} - -pub struct SecurityTxt { - pub name: String, - pub project_url: String, - pub source_code: Option<String>, - pub expiry: Option<String>, - pub preferred_languages: Vec<String>, - pub contacts: Vec<Contact>, - pub auditors: Vec<String>, - pub encryption: Option<String>, - pub acknowledgements: Option<String>, - pub policy: String, -} - -impl Display for SecurityTxt { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f, "Name: {}", self.name)?; - writeln!(f, "Project URL: {}", self.project_url)?; - - if let Some(expiry) = &self.expiry { - writeln!(f, "Expires at: {}", expiry)?; - } - - if let Some(source_code) = &self.source_code { - writeln!(f, "Source code: {}", source_code)?; - } - - if !self.contacts.is_empty() { - writeln!(f, "\nContacts:")?; - for contact in &self.contacts { - writeln!(f, " {}", contact)?; - } - } - - if !self.preferred_languages.is_empty() { - writeln!(f, "\nPreferred Languages:")?; - for languages in &self.preferred_languages { - writeln!(f, " {}", languages)?; - } - } - - if let Some(encryption) = &self.encryption { - writeln!(f, "\nEncryption:")?; - writeln!(f, "{}", encryption)?; - } - - if let Some(acknowledegments) = &self.acknowledgements { - writeln!(f, "\nAcknowledgements:")?; - writeln!(f, "{}", acknowledegments)?; - } - - if !self.auditors.is_empty() { - writeln!(f, "\nAuditors:")?; - for auditor in &self.auditors { - writeln!(f, " {}", auditor)?; - } - } - - writeln!(f, "\nPolicy:")?; - writeln!(f, "{}", self.policy)?; - - Ok(()) - } -} - -/// Parses a security.txt. Might not consume all of `data`. -pub fn parse(mut data: &[u8]) -> Result<SecurityTxt, SecurityTxtError> { - if !data.starts_with(SECURITY_TXT_BEGIN.as_bytes()) { - return Err(SecurityTxtError::InvalidSecurityTxtBegin); - } - - let end = match find_bytes(data, SECURITY_TXT_END.as_bytes()) { - Some(i) => i, - None => return Err(SecurityTxtError::EndNotFound), - }; - - data = &data[SECURITY_TXT_BEGIN.len()..end]; - - let mut attributes = HashMap::<String, String>::default(); - let mut field: Option<String> = None; - for part in data.split(|&b| b == 0) { - if let Some(ref f) = field { - let value = std::str::from_utf8(part) - .map_err(|_| SecurityTxtError::InvalidValue(part.to_vec(), f.clone()))?; - attributes.insert(f.clone(), value.to_string()); - field = None; - } else { - field = Some({ - let field = std::str::from_utf8(part) - .map_err(|_| SecurityTxtError::InvalidField(part.to_vec()))? - .to_string(); - if attributes.contains_key(&field) { - return Err(SecurityTxtError::DuplicateField(field)); - } - field - }); - } - } - - let name = attributes - .remove("name") - .ok_or_else(|| SecurityTxtError::MissingField("name".to_string()))?; - let project_url = attributes - .remove("project_url") - .ok_or_else(|| SecurityTxtError::MissingField("project_url".to_string()))?; - let source_code = attributes.remove("source_code"); - let expiry = attributes.remove("expiry"); - let preferred_languages: Vec<_> = attributes - .remove("preferred_languages") - .ok_or_else(|| SecurityTxtError::MissingField("preferred_languages".to_string()))? - .split(',') - .map(|s| s.trim().to_string()) - .collect(); - let contacts: Result<Vec<_>, SecurityTxtError> = attributes - .remove("contacts") - .ok_or_else(|| SecurityTxtError::MissingField("contacts".to_string()))? - .split(",") - .map(|s| Contact::from_str(s.trim())) - .collect(); - let contacts = contacts?; - let auditors: Vec<_> = attributes - .remove("auditors") - .unwrap_or_default() - .split(",") - .map(|s| s.trim().to_string()) - .collect(); - let encryption = attributes.remove("encryption"); - let acknowledgements = attributes.remove("acknowledgements"); - let policy = attributes - .remove("policy") - .ok_or_else(|| SecurityTxtError::MissingField("policy".to_string()))?; - - if !attributes.is_empty() { - return Err(SecurityTxtError::UnknownField( - attributes.keys().next().unwrap().clone(), - )); - } - - Ok(SecurityTxt { - name, - project_url, - source_code, - expiry, - preferred_languages, - contacts, - auditors, - encryption, - acknowledgements, - policy, - }) -} - -/// Finds and parses the security.txt in the haystack -pub fn find_and_parse(data: &[u8]) -> Result<SecurityTxt, SecurityTxtError> { - let start = match find_bytes(data, SECURITY_TXT_BEGIN.as_bytes()) { - Some(i) => i, - None => return Err(SecurityTxtError::StartNotFound), - }; - parse(&data[start..]) -} diff --git a/security-txt/src/parser.rs b/security-txt/src/parser.rs new file mode 100644 index 0000000..7d03332 --- /dev/null +++ b/security-txt/src/parser.rs @@ -0,0 +1,229 @@ + +use thiserror::Error; +use twoway::find_bytes; +use crate::{SECURITY_TXT_BEGIN, SECURITY_TXT_END}; +use std::collections::HashMap; +use core::fmt::{Display, self}; + +pub enum Contact { + Email(String), + Discord(String), + Telegram(String), + Twitter(String), + Link(String), + Other(String), +} + +impl Display for Contact { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Contact::Discord(s) => write!(f, "Discord: {}", s), + Contact::Email(s) => write!(f, "Email: {}", s), + Contact::Telegram(s) => write!(f, "Telegram: {}", s), + Contact::Twitter(s) => write!(f, "Twitter: {}", s), + Contact::Link(s) => write!(f, "Link: {}", s), + Contact::Other(s) => write!(f, "Other: {}", s), + } + } +} + +pub struct SecurityTxt { + pub name: String, + pub project_url: String, + pub source_code: Option<String>, + pub expiry: Option<String>, + pub preferred_languages: Vec<String>, + pub contacts: Vec<Contact>, + pub auditors: Vec<String>, + pub encryption: Option<String>, + pub acknowledgements: Option<String>, + pub policy: String, +} + +impl Display for SecurityTxt { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "Name: {}", self.name)?; + writeln!(f, "Project URL: {}", self.project_url)?; + + if let Some(expiry) = &self.expiry { + writeln!(f, "Expires at: {}", expiry)?; + } + + if let Some(source_code) = &self.source_code { + writeln!(f, "Source code: {}", source_code)?; + } + + if !self.contacts.is_empty() { + writeln!(f, "\nContacts:")?; + for contact in &self.contacts { + writeln!(f, " {}", contact)?; + } + } + + if !self.preferred_languages.is_empty() { + writeln!(f, "\nPreferred Languages:")?; + for languages in &self.preferred_languages { + writeln!(f, " {}", languages)?; + } + } + + if let Some(encryption) = &self.encryption { + writeln!(f, "\nEncryption:")?; + writeln!(f, "{}", encryption)?; + } + + if let Some(acknowledegments) = &self.acknowledgements { + writeln!(f, "\nAcknowledgements:")?; + writeln!(f, "{}", acknowledegments)?; + } + + if !self.auditors.is_empty() { + writeln!(f, "\nAuditors:")?; + for auditor in &self.auditors { + writeln!(f, " {}", auditor)?; + } + } + + writeln!(f, "\nPolicy:")?; + writeln!(f, "{}", self.policy)?; + + Ok(()) + } +} + + +impl Contact { + pub fn from_str(s: &str) -> Result<Self, SecurityTxtError> { + let (typ, value) = s + .split_once(":") + .ok_or_else(|| SecurityTxtError::InvalidContact(s.to_string()))?; + let (contact_type, contact_info) = (typ.trim(), value.trim()); match contact_type.to_ascii_lowercase().as_str() { + "email" => Ok(Contact::Email(contact_info.to_string())), + "discord" => Ok(Contact::Discord(contact_info.to_string())), + "telegram" => Ok(Contact::Telegram(contact_info.to_string())), + "twitter" => Ok(Contact::Twitter(contact_info.to_string())), + "link" => Ok(Contact::Link(contact_info.to_string())), + "other" => Ok(Contact::Other(contact_info.to_string())), + _ => Err(SecurityTxtError::InvalidContact(s.to_string())), + } + } +} + +#[derive(Error, Debug)] +pub enum SecurityTxtError { + #[error("security.txt doesn't start with the right string")] + InvalidSecurityTxtBegin, + #[error("Couldn't find end string")] + EndNotFound, + #[error("Couldn't find start string")] + StartNotFound, + #[error("Invalid field: `{0:?}`")] + InvalidField(Vec<u8>), + #[error("Unknown field: `{0}`")] + UnknownField(String), + #[error("Invalid value `{0:?}` for field `{1}`")] + InvalidValue(Vec<u8>, String), + #[error("Invalid contact `{0}`")] + InvalidContact(String), + #[error("Missing field: `{0}`")] + MissingField(String), + #[error("Duplicate field: `{0}`")] + DuplicateField(String), + #[error("Uneven amount of parts")] + Uneven, +} +/// Parses a security.txt. Might not consume all of `data`. +pub fn parse(mut data: &[u8]) -> Result<SecurityTxt, SecurityTxtError> { + if !data.starts_with(SECURITY_TXT_BEGIN.as_bytes()) { + return Err(SecurityTxtError::InvalidSecurityTxtBegin); + } + + let end = match find_bytes(data, SECURITY_TXT_END.as_bytes()) { + Some(i) => i, + None => return Err(SecurityTxtError::EndNotFound), + }; + + data = &data[SECURITY_TXT_BEGIN.len()..end]; + + let mut attributes = HashMap::<String, String>::default(); + let mut field: Option<String> = None; + for part in data.split(|&b| b == 0) { + if let Some(ref f) = field { + let value = std::str::from_utf8(part) + .map_err(|_| SecurityTxtError::InvalidValue(part.to_vec(), f.clone()))?; + attributes.insert(f.clone(), value.to_string()); + field = None; + } else { + field = Some({ + let field = std::str::from_utf8(part) + .map_err(|_| SecurityTxtError::InvalidField(part.to_vec()))? + .to_string(); + if attributes.contains_key(&field) { + return Err(SecurityTxtError::DuplicateField(field)); + } + field + }); + } + } + + let name = attributes + .remove("name") + .ok_or_else(|| SecurityTxtError::MissingField("name".to_string()))?; + let project_url = attributes + .remove("project_url") + .ok_or_else(|| SecurityTxtError::MissingField("project_url".to_string()))?; + let source_code = attributes.remove("source_code"); + let expiry = attributes.remove("expiry"); + let preferred_languages: Vec<_> = attributes + .remove("preferred_languages") + .ok_or_else(|| SecurityTxtError::MissingField("preferred_languages".to_string()))? + .split(',') + .map(|s| s.trim().to_string()) + .collect(); + let contacts: Result<Vec<_>, SecurityTxtError> = attributes + .remove("contacts") + .ok_or_else(|| SecurityTxtError::MissingField("contacts".to_string()))? + .split(",") + .map(|s| Contact::from_str(s.trim())) + .collect(); + let contacts = contacts?; + let auditors: Vec<_> = attributes + .remove("auditors") + .unwrap_or_default() + .split(",") + .map(|s| s.trim().to_string()) + .collect(); + let encryption = attributes.remove("encryption"); + let acknowledgements = attributes.remove("acknowledgements"); + let policy = attributes + .remove("policy") + .ok_or_else(|| SecurityTxtError::MissingField("policy".to_string()))?; + + if !attributes.is_empty() { + return Err(SecurityTxtError::UnknownField( + attributes.keys().next().unwrap().clone(), + )); + } + + Ok(SecurityTxt { + name, + project_url, + source_code, + expiry, + preferred_languages, + contacts, + auditors, + encryption, + acknowledgements, + policy, + }) +} + +/// Finds and parses the security.txt in the haystack +pub fn find_and_parse(data: &[u8]) -> Result<SecurityTxt, SecurityTxtError> { + let start = match find_bytes(data, SECURITY_TXT_BEGIN.as_bytes()) { + Some(i) => i, + None => return Err(SecurityTxtError::StartNotFound), + }; + parse(&data[start..]) +} \ No newline at end of file