mod errors;
mod url_parts;
mod version_hash;
mod xorurl_media_types;
pub use errors::{Error, Result};
pub use version_hash::VersionHash;
use sn_interface::types::{ChunkAddress, DataAddress, RegisterAddress};
use multibase::{decode as base_decode, encode as base_encode, Base};
use serde::{Deserialize, Serialize};
use std::fmt;
use tracing::{info, trace, warn};
use url::Url;
use url_parts::UrlParts;
use xor_name::{XorName, XOR_NAME_LEN};
use xorurl_media_types::{MEDIA_TYPE_CODES, MEDIA_TYPE_STR};
use crate::{nrs::NRS_MAP_TYPE_TAG, DEFAULT_XORURL_BASE};
const URL_PROTOCOL: &str = "safe://";
const URL_SCHEME: &str = "safe";
const XOR_URL_VERSION_1: u64 = 0x1; const XOR_URL_STR_MAX_LENGTH: usize = 44;
const XOR_NAME_BYTES_OFFSET: usize = 4; const URL_VERSION_QUERY_NAME: &str = "v";
pub type XorUrl = String;
#[derive(Copy, Clone, Debug)]
pub enum XorUrlBase {
#[allow(missing_docs)]
Base32z,
#[allow(missing_docs)]
Base32,
#[allow(missing_docs)]
Base64,
}
impl std::str::FromStr for XorUrlBase {
type Err = Error;
fn from_str(str: &str) -> Result<Self> {
match str {
"base32z" => Ok(Self::Base32z),
"base32" => Ok(Self::Base32),
"base64" => Ok(Self::Base64),
other => Err(Error::InvalidInput(format!(
"Invalid XOR URL base encoding: {other}. Supported values are base32z, base32, and base64",
))),
}
}
}
impl fmt::Display for XorUrlBase {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{self:?}")
}
}
impl XorUrlBase {
#[allow(missing_docs)]
pub fn from_u8(value: u8) -> Result<Self> {
match value {
0 => Ok(Self::Base32z),
1 => Ok(Self::Base32),
2 => Ok(Self::Base64),
_other => Err(Error::InvalidInput("Invalid XOR URL base encoding code. Supported values are 0=base32z, 1=base32, and 2=base64".to_string())),
}
}
#[allow(missing_docs)]
pub fn from_u16(value: u16) -> Result<Self> {
match value {
0 => Ok(Self::Base32z),
1 => Ok(Self::Base32),
2 => Ok(Self::Base64),
_other => Err(Error::InvalidInput("Invalid XOR URL base encoding code. Supported values are 0=base32z, 1=base32, and 2=base64".to_string())),
}
}
}
#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize)]
pub enum ContentType {
#[allow(missing_docs)]
Raw,
#[allow(missing_docs)]
Wallet,
#[allow(missing_docs)]
FilesContainer,
#[allow(missing_docs)]
NrsMapContainer,
#[allow(missing_docs)]
Multimap,
#[allow(missing_docs)]
MediaType(String),
}
impl std::fmt::Display for ContentType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{self:?}")
}
}
impl ContentType {
#[allow(missing_docs)]
pub fn from_u16(value: u16) -> Result<Self> {
match value {
0 => Ok(Self::Raw),
1 => Ok(Self::Wallet),
2 => Ok(Self::FilesContainer),
3 => Ok(Self::NrsMapContainer),
4 => Ok(Self::Multimap),
_other => Err(Error::InvalidInput("Invalid Media-type code".to_string())),
}
}
#[allow(missing_docs)]
pub fn value(&self) -> Result<u16> {
match self {
Self::Raw => Ok(0),
Self::Wallet => Ok(1),
Self::FilesContainer => Ok(2),
Self::NrsMapContainer => Ok(3),
Self::Multimap => Ok(4),
Self::MediaType(media_type) => match MEDIA_TYPE_CODES.get(media_type) {
Some(media_type_code) => Ok(*media_type_code),
None => Err(Error::UnsupportedMediaType(format!("Media-type '{media_type}' not supported. You can use 'ContentType::Raw' as the 'content_type' for this type of content"))),
},
}
}
}
#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize)]
pub enum DataType {
#[allow(missing_docs)]
SafeKey = 0x00,
#[allow(missing_docs)]
File = 0x01,
#[allow(missing_docs)]
Register = 0x02,
#[allow(missing_docs)]
Spentbook = 0x03,
}
impl std::fmt::Display for DataType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{self:?}")
}
}
#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
pub enum UrlType {
#[allow(missing_docs)]
XorUrl,
#[allow(missing_docs)]
NrsUrl,
}
impl UrlType {
#[allow(missing_docs)]
pub fn value(&self) -> Result<u16> {
match self {
Self::XorUrl => Ok(0),
Self::NrsUrl => Ok(1),
}
}
}
#[derive(Debug, Clone, Hash, Serialize, Deserialize, PartialEq, Eq, Ord, PartialOrd)]
pub struct SafeUrl {
encoding_version: u64, public_name: String, top_name: String, sub_names: String, sub_names_vec: Vec<String>, type_tag: u64,
address: DataAddress, content_type: ContentType, content_type_u16: u16, path: String, query_string: String, fragment: String, content_version: Option<VersionHash>, url_type: UrlType, }
impl SafeUrl {
#[allow(clippy::too_many_arguments)]
pub fn new(
address: DataAddress,
nrs_name: Option<&str>,
type_tag: u64,
content_type: ContentType,
path: Option<&str>,
sub_names: Option<Vec<String>>,
query_string: Option<&str>,
fragment: Option<&str>,
content_version: Option<VersionHash>,
) -> Result<Self> {
let content_type_u16 = content_type.value()?;
let public_name: String;
let top_name: String;
let sub_names_str: String;
let sub_names_vec: Vec<String>;
let url_type: UrlType;
if let Some(nh) = nrs_name {
if nh.is_empty() {
let msg = "nrs_name cannot be empty string.".to_string();
return Err(Error::InvalidInput(msg));
}
let tmpurl = format!("{URL_PROTOCOL}{nh}");
let parts = UrlParts::parse(&tmpurl, false)?;
let hashed_name = Self::xor_name_from_nrs_string(&parts.top_name);
if &hashed_name != address.name() {
let msg = format!(
"input mis-match. nrs_name `{}` does not hash to address.name() `{}`",
parts.top_name,
address.name()
);
return Err(Error::InvalidInput(msg));
}
public_name = parts.public_name;
top_name = parts.top_name;
sub_names_str = parts.sub_names;
sub_names_vec = parts.sub_names_vec; url_type = UrlType::NrsUrl;
} else {
public_name = String::default(); top_name = String::default(); sub_names_vec = sub_names.unwrap_or_default();
sub_names_str = sub_names_vec.join(".");
url_type = UrlType::XorUrl;
for s in &sub_names_vec {
if s.is_empty() {
let msg = "empty subname".to_string();
return Err(Error::InvalidInput(msg));
}
}
}
let mut url = Self {
encoding_version: XOR_URL_VERSION_1,
address,
public_name,
top_name,
sub_names: sub_names_str,
sub_names_vec,
type_tag,
content_type,
content_type_u16,
path: String::default(), query_string: String::default(), fragment: fragment.unwrap_or("").to_string(),
content_version: None, url_type,
};
if url.url_type == UrlType::XorUrl {
url.top_name = url.name_to_base(DEFAULT_XORURL_BASE, false);
let sep = if url.sub_names.is_empty() { "" } else { "." };
url.public_name = format!("{}{}{}", url.sub_names(), sep, url.top_name);
}
url.set_path_internal(path.unwrap_or(""), false);
url.set_query_string(query_string.unwrap_or(""))?;
if let Some(version) = content_version {
url.set_content_version(Some(version));
}
Ok(url)
}
pub fn is_media_type_supported(media_type: &str) -> bool {
MEDIA_TYPE_CODES.get(media_type).is_some()
}
pub fn from_url(url: &str) -> Result<Self> {
match Self::from_xorurl(url) {
Ok(enc) => Ok(enc),
Err(err) => {
info!(
"Falling back to NRS. XorUrl decoding failed with: {:?}",
err
);
Self::from_nrsurl(url)
}
}
}
pub fn from_nrsurl(nrsurl: &str) -> Result<Self> {
let parts = UrlParts::parse(nrsurl, false)?;
let hashed_name = Self::xor_name_from_nrs_string(&parts.top_name);
let address = DataAddress::Register(RegisterAddress::new(hashed_name, NRS_MAP_TYPE_TAG));
Self::new(
address,
Some(&parts.public_name),
NRS_MAP_TYPE_TAG,
ContentType::NrsMapContainer,
Some(&parts.path),
Some(parts.sub_names_vec),
Some(&parts.query_string),
Some(&parts.fragment),
None,
)
}
pub fn from_xorurl(xorurl: &str) -> Result<Self> {
let parts = UrlParts::parse(xorurl, true)?;
let (_base, xorurl_bytes): (Base, Vec<u8>) = base_decode(&parts.top_name)
.map_err(|err| Error::InvalidXorUrl(format!("Failed to decode XOR-URL: {err:?}")))?;
let type_tag_offset = XOR_NAME_BYTES_OFFSET + XOR_NAME_LEN;
if xorurl_bytes.len() < type_tag_offset {
return Err(Error::InvalidXorUrl(format!(
"Invalid XOR-URL, encoded string too short: {} bytes",
xorurl_bytes.len()
)));
}
if xorurl_bytes.len() > XOR_URL_STR_MAX_LENGTH {
return Err(Error::InvalidXorUrl(format!(
"Invalid XOR-URL, encoded string too long: {} bytes",
xorurl_bytes.len()
)));
}
let u8_version: u8 = xorurl_bytes[0];
let encoding_version: u64 = u64::from(u8_version);
if encoding_version != XOR_URL_VERSION_1 {
return Err(Error::InvalidXorUrl(format!(
"Invalid or unsupported XOR-URL encoding version: {encoding_version}",
)));
}
let mut content_type_bytes = [0; 2];
content_type_bytes[0..].copy_from_slice(&xorurl_bytes[1..3]);
let content_type = match u16::from_be_bytes(content_type_bytes) {
0 => ContentType::Raw,
1 => ContentType::Wallet,
2 => ContentType::FilesContainer,
3 => ContentType::NrsMapContainer,
4 => ContentType::Multimap,
other => match MEDIA_TYPE_STR.get(&other) {
Some(media_type_str) => ContentType::MediaType((*media_type_str).to_string()),
None => {
return Err(Error::InvalidXorUrl(format!(
"Invalid content type encoded in the XOR-URL string: {other}",
)))
}
},
};
trace!("Attempting to match content type of URL: {xorurl}, {content_type:?}");
let mut xor_name = XorName::default();
xor_name
.0
.copy_from_slice(&xorurl_bytes[XOR_NAME_BYTES_OFFSET..type_tag_offset]);
let type_tag_bytes_len = xorurl_bytes.len() - type_tag_offset;
let mut type_tag_bytes = [0; 8];
type_tag_bytes[8 - type_tag_bytes_len..].copy_from_slice(&xorurl_bytes[type_tag_offset..]);
let type_tag: u64 = u64::from_be_bytes(type_tag_bytes);
let address = match xorurl_bytes[3] {
0 => DataAddress::SafeKey(xor_name),
1 => DataAddress::Bytes(ChunkAddress(xor_name)),
2 => DataAddress::Register(RegisterAddress::new(xor_name, type_tag)),
other => {
return Err(Error::InvalidXorUrl(format!(
"Invalid data type encoded in the XOR-URL string: {other}"
)))
}
};
Self::new(
address,
None, type_tag,
content_type,
Some(&parts.path),
Some(parts.sub_names_vec),
Some(&parts.query_string),
Some(&parts.fragment),
None,
)
}
pub fn from_safekey(xor_name: XorName) -> Result<Self> {
SafeUrl::new(
DataAddress::SafeKey(xor_name),
None,
0,
ContentType::Raw,
None,
None,
None,
None,
None,
)
}
pub fn from_bytes(address: XorName, content_type: ContentType) -> Result<Self> {
SafeUrl::new(
DataAddress::Bytes(ChunkAddress(address)),
None,
0,
content_type,
None,
None,
None,
None,
None,
)
}
pub fn from_register(
xor_name: XorName,
type_tag: u64,
content_type: ContentType,
) -> Result<Self> {
SafeUrl::new(
DataAddress::Register(RegisterAddress::new(xor_name, type_tag)),
None,
type_tag,
content_type,
None,
None,
None,
None,
None,
)
}
pub fn scheme(&self) -> &str {
URL_SCHEME
}
pub fn encoding_version(&self) -> u64 {
self.encoding_version
}
pub fn data_type(&self) -> DataType {
match self.address {
DataAddress::Bytes(_) => DataType::File,
DataAddress::Register(_) => DataType::Register,
DataAddress::SafeKey(_) => DataType::SafeKey,
DataAddress::Spentbook(_) => DataType::Spentbook,
}
}
pub fn content_type(&self) -> ContentType {
self.content_type.clone()
}
pub fn set_content_type(&mut self, content_type: ContentType) -> Result<()> {
self.content_type_u16 = content_type.value()?;
self.content_type = content_type;
Ok(())
}
pub fn xorname(&self) -> XorName {
*self.address().name()
}
pub fn address(&self) -> DataAddress {
self.address
}
pub fn xorurl_public_name(&self) -> String {
self.name_to_base(DEFAULT_XORURL_BASE, true)
}
pub fn public_name(&self) -> &str {
&self.public_name
}
pub fn top_name(&self) -> &str {
&self.top_name
}
pub fn sub_names(&self) -> &str {
&self.sub_names
}
pub fn sub_names_vec(&self) -> &[String] {
&self.sub_names_vec
}
pub fn set_sub_names(&mut self, sub_names: &str) -> Result<()> {
let sep = if sub_names.is_empty() { "" } else { "." };
let tmpurl = format!("{}{}{}{}", URL_PROTOCOL, sub_names, sep, self.top_name());
let parts = UrlParts::parse(&tmpurl, true)?;
self.sub_names = parts.sub_names;
self.sub_names_vec = parts.sub_names_vec;
self.public_name = parts.public_name;
Ok(())
}
pub fn type_tag(&self) -> u64 {
self.type_tag
}
pub fn path(&self) -> &str {
&self.path
}
pub fn path_decoded(&self) -> Result<String> {
Self::url_percent_decode(&self.path)
}
pub fn set_path(&mut self, path: &str) {
self.set_path_internal(path, true);
}
pub fn content_version(&self) -> Option<VersionHash> {
self.content_version
}
pub fn set_content_version(&mut self, version: Option<VersionHash>) {
let version_string: String;
let v_option = match version {
Some(v) => {
version_string = v.to_string();
Some(version_string.as_str())
}
None => None,
};
self.set_query_key(URL_VERSION_QUERY_NAME, v_option)
.unwrap_or_else(|e| {
warn!("{}", e);
});
}
pub fn set_query_key(&mut self, key: &str, val: Option<&str>) -> Result<()> {
let mut pairs = url::form_urlencoded::Serializer::new(String::new());
let url = Self::query_string_to_url(&self.query_string)?;
let mut set_key = false;
for (k, v) in url.query_pairs() {
if k == key {
if let Some(v) = val {
if !set_key {
let _res = pairs.append_pair(key, v);
set_key = true;
}
}
} else {
let _res = pairs.append_pair(&k, &v);
}
}
if !set_key {
if let Some(v) = val {
let _res = pairs.append_pair(key, v);
}
}
self.query_string = pairs.finish();
trace!("Set query_string: {}", self.query_string);
if key == URL_VERSION_QUERY_NAME {
self.set_content_version_internal(val)?;
}
Ok(())
}
pub fn set_query_string(&mut self, query: &str) -> Result<()> {
let v_option = Self::query_key_last_internal(query, URL_VERSION_QUERY_NAME);
self.set_content_version_internal(v_option.as_deref())?;
self.query_string = query.to_string();
Ok(())
}
pub fn query_string(&self) -> &str {
&self.query_string
}
pub fn query_string_with_separator(&self) -> String {
let qs = self.query_string();
if qs.is_empty() {
qs.to_string()
} else {
format!("?{qs}")
}
}
pub fn query_pairs(&self) -> Vec<(String, String)> {
Self::query_pairs_internal(&self.query_string)
}
pub fn query_key(&self, key: &str) -> Vec<String> {
Self::query_key_internal(&self.query_string, key)
}
pub fn query_key_last(&self, key: &str) -> Option<String> {
Self::query_key_last_internal(&self.query_string, key)
}
pub fn query_key_first(&self, key: &str) -> Option<String> {
Self::query_key_first_internal(&self.query_string, key)
}
pub fn set_fragment(&mut self, fragment: String) {
self.fragment = fragment;
}
pub fn fragment(&self) -> &str {
&self.fragment
}
pub fn fragment_with_separator(&self) -> String {
if self.fragment.is_empty() {
"".to_string()
} else {
format!("#{}", self.fragment)
}
}
pub fn is_nrsurl(&self) -> bool {
self.url_type == UrlType::NrsUrl
}
pub fn is_xorurl(&self) -> bool {
self.url_type == UrlType::XorUrl
}
pub fn url_type(&self) -> &UrlType {
&self.url_type
}
pub fn to_xorurl_string(&self) -> String {
self.encode(DEFAULT_XORURL_BASE)
}
pub fn to_nrsurl_string(&self) -> Option<String> {
if !self.is_nrsurl() {
return None;
}
let query_string = self.query_string_with_separator();
let fragment = self.fragment_with_separator();
let url = format!(
"{}{}{}{}{}",
URL_PROTOCOL, self.public_name, self.path, query_string, fragment
);
Some(url)
}
pub fn name_to_base(&self, base: XorUrlBase, include_subnames: bool) -> String {
let mut cid_vec: Vec<u8> = vec![XOR_URL_VERSION_1 as u8];
cid_vec.extend_from_slice(&self.content_type_u16.to_be_bytes());
cid_vec.push(self.data_type() as u8);
cid_vec.extend_from_slice(&self.address().name().0);
let start_byte: usize = (self.type_tag.leading_zeros() / 8) as usize;
cid_vec.extend_from_slice(&self.type_tag.to_be_bytes()[start_byte..]);
let base_encoding = match base {
XorUrlBase::Base32z => Base::Base32Z,
XorUrlBase::Base32 => Base::Base32Lower,
XorUrlBase::Base64 => Base::Base64,
};
let top_name = base_encode(base_encoding, cid_vec);
if include_subnames {
let sub_names = self.sub_names();
let sep = if sub_names.is_empty() { "" } else { "." };
format!("{sub_names}{sep}{top_name}")
} else {
top_name
}
}
pub fn url_percent_decode(s: &str) -> Result<String> {
match urlencoding::decode(s) {
Ok(c) => Ok(c),
Err(e) => Err(Error::InvalidInput(format!("{e:#?}"))),
}
}
pub fn url_percent_encode(s: &str) -> String {
urlencoding::encode(s)
}
pub fn validate(&self) -> Result<()> {
let s = self.to_string();
Self::from_url(&s).map(|_| ())
}
pub fn encode(&self, base: XorUrlBase) -> String {
let name = self.name_to_base(base, true);
let query_string = self.query_string_with_separator();
let fragment = self.fragment_with_separator();
format!(
"{}{}{}{}{}",
URL_PROTOCOL, name, self.path, query_string, fragment
)
}
fn query_string_to_url(query_str: &str) -> Result<Url> {
let dummy = format!("file://dummy?{query_str}");
match Url::parse(&dummy) {
Ok(u) => Ok(u),
Err(_e) => {
let msg = format!("Invalid query string: {query_str}");
Err(Error::InvalidInput(msg))
}
}
}
fn query_pairs_internal(query_str: &str) -> Vec<(String, String)> {
let url = match Self::query_string_to_url(query_str) {
Ok(u) => u,
Err(_) => {
return Vec::<(String, String)>::new();
}
};
let pairs: Vec<(String, String)> = url.query_pairs().into_owned().collect();
pairs
}
fn set_content_version_internal(&mut self, version_option: Option<&str>) -> Result<()> {
if let Some(version_str) = version_option {
let version = version_str.parse::<VersionHash>().map_err(|_e| {
let msg = format!(
"{URL_VERSION_QUERY_NAME} param could not be parsed as VersionHash. invalid: '{version_str}'",
);
Error::InvalidInput(msg)
})?;
self.content_version = Some(version);
} else {
self.content_version = None;
}
trace!("Set version: {:#?}", self.content_version);
Ok(())
}
fn set_path_internal(&mut self, path: &str, percent_encode: bool) {
if path.is_empty() {
if !self.path.is_empty() {
self.path = path.to_string();
}
return;
}
let parts: Vec<&str> = path.split('/').collect();
let mut new_parts = Vec::<String>::new();
for (count, p) in parts.iter().enumerate() {
if !p.is_empty() || count > 0 {
if percent_encode {
new_parts.push(Self::url_percent_encode(p));
} else {
new_parts.push(p.to_string());
}
}
}
let new_path = new_parts.join("/");
let separator = if new_path.is_empty() { "" } else { "/" };
self.path = format!("{separator}{new_path}");
}
fn query_key_internal(query_str: &str, key: &str) -> Vec<String> {
let pairs = Self::query_pairs_internal(query_str);
let mut values = Vec::<String>::new();
for (k, val) in pairs {
if k == key {
values.push(val);
}
}
values
}
fn query_key_last_internal(query_str: &str, key: &str) -> Option<String> {
let matches = Self::query_key_internal(query_str, key);
matches.last().map(|v| v.to_string())
}
fn query_key_first_internal(query_str: &str, key: &str) -> Option<String> {
let matches = Self::query_key_internal(query_str, key);
matches.first().map(|v| v.to_string())
}
fn xor_name_from_nrs_string(name: &str) -> XorName {
XorName::from_content(name.as_bytes())
}
}
impl fmt::Display for SafeUrl {
fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
let buf = if self.is_nrsurl() {
match self.to_nrsurl_string() {
Some(s) => s,
None => {
warn!("to_nrsurl_string() return None when is_nrsurl() == true. '{}'. This should never happen. Please investigate.", self.public_name);
return Err(fmt::Error);
}
}
} else {
self.to_xorurl_string()
};
write!(fmt, "{buf}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use sn_interface::types::register::EntryHash;
use color_eyre::{eyre::bail, eyre::eyre, Result};
use rand::Rng;
macro_rules! verify_expected_result {
($result:expr, $pattern:pat $(if $cond:expr)?) => {
match $result {
$pattern $(if $cond)? => Ok(()),
other => Err(eyre!("Expecting {}, got {:?}", stringify!($pattern), other)),
}
}
}
#[test]
fn test_url_new_validation() -> Result<()> {
let xor_name = XorName(*b"12345678901234567890123456789012");
let address = DataAddress::register(xor_name, NRS_MAP_TYPE_TAG);
let result = SafeUrl::new(
address,
None,
NRS_MAP_TYPE_TAG,
ContentType::MediaType("garbage/trash".to_string()),
None,
None,
None,
None,
None,
);
verify_expected_result!(result, Err(Error::UnsupportedMediaType(err)) if err.contains("You can use 'ContentType::Raw'"))?;
let result = SafeUrl::new(
address,
Some(""), NRS_MAP_TYPE_TAG,
ContentType::NrsMapContainer,
None,
None,
None,
None,
None,
);
verify_expected_result!(result, Err(Error::InvalidInput(err)) if err.contains("nrs_name cannot be empty string."))?;
let result = SafeUrl::new(
address,
Some("a.b.c"), NRS_MAP_TYPE_TAG,
ContentType::NrsMapContainer,
None,
None,
None,
None,
None,
);
verify_expected_result!(result, Err(Error::InvalidInput(err)) if err.contains("does not hash to address.name()"))?;
let result = SafeUrl::new(
address,
Some("a..b.c"), NRS_MAP_TYPE_TAG,
ContentType::NrsMapContainer,
None,
None,
None,
None,
None,
);
verify_expected_result!(result, Err(Error::InvalidXorUrl(err)) if err.contains("name contains empty subname"))?;
let result = SafeUrl::new(
address,
None, NRS_MAP_TYPE_TAG,
ContentType::NrsMapContainer,
None,
Some(vec!["a".to_string(), "".to_string(), "b".to_string()]),
None,
None,
None,
);
verify_expected_result!(result, Err(Error::InvalidInput(err)) if err.contains("empty subname"))?;
Ok(())
}
#[test]
fn test_url_base32_encoding() -> Result<()> {
let xor_name = XorName(*b"12345678901234567890123456789012");
let address = DataAddress::bytes(xor_name);
let xorurl = SafeUrl::new(
address,
None,
0xa632_3c4d_4a32,
ContentType::Raw,
None,
None,
None,
None,
None,
)?
.encode(XorUrlBase::Base32);
let base32_xorurl =
"safe://baeaaaajrgiztinjwg44dsmbrgiztinjwg44dsmbrgiztinjwg44dsmbrgktdepcnjiza";
assert_eq!(xorurl, base32_xorurl);
Ok(())
}
#[test]
fn test_url_base32z_encoding() -> Result<()> {
let xor_name = XorName(*b"12345678901234567890123456789012");
let xorurl = SafeUrl::from_bytes(xor_name, ContentType::Raw)?.encode(XorUrlBase::Base32z);
let base32z_xorurl = "safe://hyryyyyjtge3uepjsghhd1cbtge3uepjsghhd1cbtge3uepjsghhd1cbtge";
assert_eq!(xorurl, base32z_xorurl);
Ok(())
}
#[test]
fn test_url_base64_encoding() -> Result<()> {
let xor_name = XorName(*b"12345678901234567890123456789012");
let xorurl = SafeUrl::from_register(xor_name, 4_584_545, ContentType::FilesContainer)?
.encode(XorUrlBase::Base64);
let base64_xorurl = "safe://mAQACAjEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyRfRh";
assert_eq!(xorurl, base64_xorurl);
let url = SafeUrl::from_url(base64_xorurl)?;
assert_eq!(base64_xorurl, url.encode(XorUrlBase::Base64));
assert_eq!("", url.path());
assert_eq!(XOR_URL_VERSION_1, url.encoding_version());
assert_eq!(xor_name, url.xorname());
assert_eq!(4_584_545, url.type_tag());
assert_eq!(DataType::Register, url.data_type());
assert_eq!(ContentType::FilesContainer, url.content_type());
Ok(())
}
#[test]
fn test_url_default_base_encoding() -> Result<()> {
let xor_name = XorName(*b"12345678901234567890123456789012");
let base32z_xorurl = "safe://hyryyyyjtge3uepjsghhd1cbtge3uepjsghhd1cbtge3uepjsghhd1cbtge";
let xorurl = SafeUrl::from_bytes(xor_name, ContentType::Raw)?.encode(DEFAULT_XORURL_BASE);
assert_eq!(xorurl, base32z_xorurl);
Ok(())
}
#[test]
fn test_url_decoding() -> Result<()> {
let xor_name = XorName(*b"12345678901234567890123456789012");
let type_tag: u64 = 0x0eef;
let subdirs = "/dir1/dir2";
let random_hash = EntryHash(rand::thread_rng().gen::<[u8; 32]>());
let content_version = VersionHash::from(&random_hash);
let query_string = "k1=v1&k2=v2";
let query_string_v = format!("{query_string}&v={content_version}");
let fragment = "myfragment";
let address = DataAddress::bytes(xor_name);
let xorurl = SafeUrl::new(
address,
None,
type_tag,
ContentType::Raw,
Some(subdirs),
Some(vec!["subname".to_string()]),
Some(query_string),
Some(fragment),
Some(content_version),
)?
.encode(XorUrlBase::Base32z);
let url = SafeUrl::from_url(&xorurl)?;
assert_eq!(subdirs, url.path());
assert_eq!(XOR_URL_VERSION_1, url.encoding_version());
assert_eq!(xor_name, url.xorname());
assert_eq!(type_tag, url.type_tag());
assert_eq!(DataType::File, url.data_type());
assert_eq!(ContentType::Raw, url.content_type());
assert_eq!(Some(content_version), url.content_version());
assert_eq!(query_string_v, url.query_string());
assert_eq!(fragment, url.fragment());
Ok(())
}
#[test]
fn test_url_decoding_with_path() -> Result<()> {
let xor_name = XorName(*b"12345678901234567890123456789012");
let type_tag: u64 = 0x0eef;
let xorurl = SafeUrl::from_register(xor_name, type_tag, ContentType::Wallet)?
.encode(XorUrlBase::Base32z);
let xorurl_with_path = format!("{xorurl}/subfolder/file");
let url_with_path = SafeUrl::from_url(&xorurl_with_path)?;
assert_eq!(xorurl_with_path, url_with_path.encode(XorUrlBase::Base32z));
assert_eq!("/subfolder/file", url_with_path.path());
assert_eq!(XOR_URL_VERSION_1, url_with_path.encoding_version());
assert_eq!(xor_name, url_with_path.xorname());
assert_eq!(type_tag, url_with_path.type_tag());
assert_eq!(DataType::Register, url_with_path.data_type());
assert_eq!(ContentType::Wallet, url_with_path.content_type());
Ok(())
}
#[test]
fn test_url_decoding_with_subname() -> Result<()> {
let xor_name = XorName(*b"12345678901234567890123456789012");
let type_tag: u64 = 0x0eef;
let address = DataAddress::bytes(xor_name);
let xorurl_with_subname = SafeUrl::new(
address,
None,
type_tag,
ContentType::NrsMapContainer,
None,
Some(vec!["sub".to_string()]),
None,
None,
None,
)?
.encode(XorUrlBase::Base32z);
assert!(xorurl_with_subname.contains("safe://sub."));
let url_with_subname = SafeUrl::from_url(&xorurl_with_subname)?;
assert_eq!(
xorurl_with_subname,
url_with_subname.encode(XorUrlBase::Base32z)
);
assert_eq!("", url_with_subname.path());
assert_eq!(1, url_with_subname.encoding_version());
assert_eq!(xor_name, url_with_subname.xorname());
assert_eq!(type_tag, url_with_subname.type_tag());
assert_eq!(&["sub"], url_with_subname.sub_names_vec());
assert_eq!(
ContentType::NrsMapContainer,
url_with_subname.content_type()
);
Ok(())
}
#[test]
fn encode_bytes_should_set_media_type() -> Result<()> {
let xor_name = XorName(*b"12345678901234567890123456789012");
let xorurl =
SafeUrl::from_bytes(xor_name, ContentType::MediaType("text/html".to_string()))?
.encode(XorUrlBase::Base32z);
let url = SafeUrl::from_url(xorurl.as_str())?;
assert_eq!(
ContentType::MediaType("text/html".to_string()),
url.content_type()
);
Ok(())
}
#[test]
fn encode_bytes_should_set_data_type() -> Result<()> {
let xor_name = XorName(*b"12345678901234567890123456789012");
let xorurl =
SafeUrl::from_bytes(xor_name, ContentType::MediaType("text/html".to_string()))?
.encode(XorUrlBase::Base32z);
let url = SafeUrl::from_url(&xorurl)?;
assert_eq!(url.data_type(), DataType::File);
Ok(())
}
#[test]
fn test_url_too_long() -> Result<()> {
let xorurl =
"safe://heyyynunctugo4ucp3a8radnctugo4ucp3a8radnctugo4ucp3a8radnctmfp5zq75zq75zq7";
match SafeUrl::from_xorurl(xorurl) {
Ok(_) => Err(eyre!(
"Unexpectedly parsed an invalid (too long) xorurl".to_string(),
)),
Err(Error::InvalidXorUrl(msg)) => {
assert!(msg.starts_with("Invalid XOR-URL, encoded string too long"));
Ok(())
}
other => Err(eyre!("Error returned is not the expected one: {:?}", other)),
}
}
#[test]
fn test_url_too_short() -> Result<()> {
let xor_name = XorName(*b"12345678901234567890123456789012");
let xorurl =
SafeUrl::from_bytes(xor_name, ContentType::MediaType("text/html".to_string()))?
.encode(XorUrlBase::Base32z);
let len = xorurl.len() - 2;
match SafeUrl::from_xorurl(&xorurl[..len]) {
Ok(_) => Err(eyre!(
"Unexpectedly parsed an invalid (too short) xorurl".to_string(),
)),
Err(Error::InvalidXorUrl(msg)) => {
assert!(msg.starts_with("Invalid XOR-URL, encoded string too short"));
Ok(())
}
other => Err(eyre!("Error returned is not the expected one: {:?}", other)),
}
}
#[test]
fn test_url_query_key_first() -> Result<()> {
let x = SafeUrl::from_url("safe://myname?name=John+Doe&name=Jane%20Doe")?;
let name = x.query_key_first("name");
assert_eq!(name, Some("John Doe".to_string()));
Ok(())
}
#[test]
fn test_url_query_key_last() -> Result<()> {
let x = SafeUrl::from_url("safe://myname?name=John+Doe&name=Jane%20Doe")?;
let name = x.query_key_last("name");
assert_eq!(name, Some("Jane Doe".to_string()));
Ok(())
}
#[test]
fn test_url_query_key() -> Result<()> {
let x = SafeUrl::from_url("safe://myname?name=John+Doe&name=Jane%20Doe")?;
let name = x.query_key("name");
assert_eq!(name, vec!["John Doe".to_string(), "Jane Doe".to_string()]);
Ok(())
}
#[test]
fn test_url_set_query_key() -> Result<()> {
let mut x = SafeUrl::from_url("safe://myname?name=John+Doe&name=Jane%20Doe")?;
let peggy_sue = "Peggy Sue".to_string();
x.set_query_key("name", Some(&peggy_sue))?;
assert_eq!(x.query_key_first("name"), Some(peggy_sue.clone()));
assert_eq!(x.query_key_last("name"), Some(peggy_sue));
assert_eq!(x.to_string(), "safe://myname?name=Peggy+Sue");
x.set_query_key("name", None)?;
assert_eq!(x.query_key_last("name"), None);
assert_eq!(x.to_string(), "safe://myname");
x.set_query_key("name", Some(""))?;
x.set_query_key("age", Some("25"))?;
assert_eq!(x.query_key_last("name"), Some("".to_string()));
assert_eq!(x.query_key_last("age"), Some("25".to_string()));
assert_eq!(x.to_string(), "safe://myname?name=&age=25");
let random_hash = EntryHash(rand::thread_rng().gen::<[u8; 32]>());
let version_hash = VersionHash::from(&random_hash);
x.set_query_key(URL_VERSION_QUERY_NAME, Some(&version_hash.to_string()))?;
assert_eq!(
x.query_key_last(URL_VERSION_QUERY_NAME),
Some(version_hash.to_string())
);
assert_eq!(x.content_version(), Some(version_hash));
x.set_query_key(URL_VERSION_QUERY_NAME, None)?;
assert_eq!(x.query_key_last(URL_VERSION_QUERY_NAME), None);
assert_eq!(x.content_version(), None);
let result = x.set_query_key(URL_VERSION_QUERY_NAME, Some("non-hash"));
assert!(result.is_err());
Ok(())
}
#[test]
fn test_url_set_sub_names() -> Result<()> {
let mut x = SafeUrl::from_url("safe://sub1.sub2.myname")?;
assert_eq!(x.sub_names(), "sub1.sub2");
assert_eq!(x.sub_names_vec(), ["sub1", "sub2"]);
x.set_sub_names("s1.s2.s3")?;
assert_eq!(x.sub_names(), "s1.s2.s3");
assert_eq!(x.sub_names_vec(), ["s1", "s2", "s3"]);
assert_eq!(x.to_string(), "safe://s1.s2.s3.myname");
Ok(())
}
#[test]
fn test_url_set_content_version() -> Result<()> {
let mut x = SafeUrl::from_url("safe://myname?name=John+Doe&name=Jane%20Doe")?;
let random_hash = EntryHash(rand::thread_rng().gen::<[u8; 32]>());
let version_hash = VersionHash::from(&random_hash);
x.set_content_version(Some(version_hash));
assert_eq!(
x.query_key_first(URL_VERSION_QUERY_NAME),
Some(version_hash.to_string())
);
assert_eq!(x.content_version(), Some(version_hash));
assert_eq!(
x.to_string(),
format!("safe://myname?name=John+Doe&name=Jane+Doe&v={version_hash}",)
);
x.set_content_version(None);
assert_eq!(x.query_key_first(URL_VERSION_QUERY_NAME), None);
assert_eq!(x.content_version(), None);
assert_eq!(x.to_string(), "safe://myname?name=John+Doe&name=Jane+Doe");
Ok(())
}
#[test]
fn test_url_path() -> Result<()> {
let mut x = SafeUrl::from_url("safe://domain/path/to/my%20file.txt")?;
let mut u = Url::parse("safe://domain/path/to/my%20file.txt").map_err(|e| {
Error::InvalidInput(format!(
"Unexpectedly failed to parse with third-party Url::parse: {e}",
))
})?;
assert_eq!(x.path(), "/path/to/my%20file.txt");
assert_eq!(x.path(), u.path());
x.set_path("/path/to/my new file.txt");
u.set_path("/path/to/my new file.txt");
assert_eq!(x.path(), "/path/to/my%20new%20file.txt");
assert_eq!(x.path(), u.path());
assert_eq!(x.path_decoded()?, "/path/to/my new file.txt");
x.set_path("/trailing/slash/");
u.set_path("/trailing/slash/");
assert_eq!(x.path(), "/trailing/slash/");
assert_eq!(x.path(), u.path());
x.set_path("no-leading-slash");
u.set_path("no-leading-slash");
assert_eq!(x.path(), "/no-leading-slash");
assert_eq!(x.path(), u.path());
assert_eq!(x.to_string(), "safe://domain/no-leading-slash");
assert_eq!(x.to_string(), u.to_string());
x.set_path("");
u.set_path("");
assert_eq!(x.path(), ""); assert_eq!(x.path(), u.path());
assert_eq!(x.to_string(), "safe://domain");
assert_eq!(x.to_string(), u.to_string());
x.set_path("/");
u.set_path("/");
assert_eq!(x.path(), "");
assert_eq!(u.path(), "/"); assert_eq!(x.to_string(), "safe://domain"); assert_eq!(u.to_string(), "safe://domain/");
Ok(())
}
#[test]
fn test_url_to_string() -> Result<()> {
let nrsurl = "safe://my.sub.domain/path/my%20dir/my%20file.txt?this=that&this=other&color=blue&v=hyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy&name=John+Doe#somefragment";
let xorurl = "safe://my.sub.hyryygyuk9cke7k99tyhp941od8q375wxgaqeyiag8za1jnpzbw9pb61sccn7a/path/my%20dir/my%20file.txt?this=that&this=other&color=blue&v=hyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy&name=John+Doe#somefragment";
let nrs = SafeUrl::from_url(nrsurl)?;
let xor = SafeUrl::from_url(xorurl)?;
assert_eq!(nrs.to_string(), nrsurl);
assert_eq!(xor.to_string(), xorurl);
assert_eq!(nrs.to_nrsurl_string(), Some(nrsurl.to_string()));
assert_eq!(nrs.to_xorurl_string(), xorurl);
assert_eq!(xor.to_nrsurl_string(), None);
assert_eq!(xor.to_xorurl_string(), xorurl);
Ok(())
}
#[test]
fn test_url_parts() -> Result<()> {
let random_hash = EntryHash(rand::thread_rng().gen::<[u8; 32]>());
let nrsurl_version_hash = VersionHash::from(&random_hash);
let random_hash = EntryHash(rand::thread_rng().gen::<[u8; 32]>());
let xorurl_version_hash = VersionHash::from(&random_hash);
let nrsurl = format!("safe://my.sub.domain/path/my%20dir/my%20file.txt?this=that&this=other&color=blue&v={nrsurl_version_hash}&name=John+Doe#somefragment", );
let xorurl = format!("safe://my.sub.hnyydyiixsfrqix9aoqg97jebuzc6748uc8rykhdd5hjrtg5o4xso9jmggbqh/path/my%20dir/my%20file.txt?this=that&this=other&color=blue&v={xorurl_version_hash}&name=John+Doe#somefragment");
let nrs = SafeUrl::from_url(&nrsurl)?;
let xor = SafeUrl::from_url(&xorurl)?;
assert_eq!(nrs.scheme(), URL_SCHEME);
assert_eq!(xor.scheme(), URL_SCHEME);
assert_eq!(nrs.public_name(), "my.sub.domain");
assert_eq!(
xor.public_name(),
"my.sub.hnyydyiixsfrqix9aoqg97jebuzc6748uc8rykhdd5hjrtg5o4xso9jmggbqh"
);
assert_eq!(nrs.top_name(), "domain");
assert_eq!(
xor.top_name(),
"hnyydyiixsfrqix9aoqg97jebuzc6748uc8rykhdd5hjrtg5o4xso9jmggbqh"
);
assert_eq!(nrs.sub_names(), "my.sub");
assert_eq!(xor.sub_names(), "my.sub");
assert_eq!(nrs.sub_names_vec(), ["my", "sub"]);
assert_eq!(xor.sub_names_vec(), ["my", "sub"]);
assert_eq!(nrs.path(), "/path/my%20dir/my%20file.txt");
assert_eq!(xor.path(), "/path/my%20dir/my%20file.txt");
assert_eq!(nrs.path_decoded()?, "/path/my dir/my file.txt");
assert_eq!(xor.path_decoded()?, "/path/my dir/my file.txt");
assert_eq!(
nrs.query_string(),
format!("this=that&this=other&color=blue&v={nrsurl_version_hash}&name=John+Doe",)
);
assert_eq!(
xor.query_string(),
format!("this=that&this=other&color=blue&v={xorurl_version_hash}&name=John+Doe",)
);
assert_eq!(nrs.fragment(), "somefragment");
assert_eq!(xor.fragment(), "somefragment");
assert_eq!(nrs.content_version(), Some(nrsurl_version_hash));
assert_eq!(xor.content_version(), Some(xorurl_version_hash));
Ok(())
}
#[test]
fn test_url_from_url_validation() -> Result<()> {
let result = SafeUrl::from_url("withoutscheme");
verify_expected_result!(result, Err(Error::InvalidXorUrl(err)) if err.contains("relative URL without a base"))?;
let result = SafeUrl::from_url("http://badscheme");
verify_expected_result!(result, Err(Error::InvalidXorUrl(err)) if err.contains("invalid scheme"))?;
let result = SafeUrl::from_url("safe:///emptyname");
verify_expected_result!(result, Err(Error::InvalidXorUrl(err)) if err.contains("missing name"))?;
let result = SafeUrl::from_url("safe://space in name");
verify_expected_result!(result, Err(Error::InvalidInput(err)) if err.contains("The URL cannot contain whitespace"))?;
let result = SafeUrl::from_url("safe://my.sub..name");
verify_expected_result!(result, Err(Error::InvalidXorUrl(err)) if err.contains("name contains empty subname"))?;
let result = SafeUrl::from_url("safe://name//");
verify_expected_result!(result, Err(Error::InvalidXorUrl(err)) if err.contains("path contains empty component"))?;
let _url = SafeUrl::from_url("safe://name??foo=bar")?;
let _url = SafeUrl::from_url("safe://name?foo=bar##fragment")?;
let _url = SafeUrl::from_nrsurl("safe://name/single%percent/in/path")?;
Ok(())
}
#[test]
fn test_url_from_xorurl_validation() -> Result<()> {
let msg = "Expected error";
let wrong_err = "Wrong error type".to_string();
let result = SafeUrl::from_xorurl("safe://invalidxor").expect_err(msg);
match result {
Error::InvalidXorUrl(e) => assert!(e.contains("Failed to decode XOR-URL")),
_ => bail!(wrong_err),
}
let result = SafeUrl::from_xorurl(
"safe://hnyydy iixsfrqix9aoqg97jebuzc6748uc8rykhdd5hjrtg5o4xso9jmggbqh",
)
.expect_err(msg);
match result {
Error::InvalidInput(e) => assert!(e.contains("The URL cannot contain whitespace")),
_ => bail!(wrong_err),
}
Ok(())
}
#[test]
fn test_url_validate() -> Result<()> {
let nrsurl = "safe://my.sub.domain/path/my%20dir/my%20file.txt?this=that&this=other&color=blue&v=hyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy&name=John+Doe#somefragment";
let trailing_slash = "safe://my.domain/";
let double_q = "safe://my.domain/??foo=bar";
let nrs = SafeUrl::from_url(nrsurl)?;
let xor = SafeUrl::from_url(&nrs.to_xorurl_string())?;
assert!(nrs.validate().is_ok());
assert!(xor.validate().is_ok());
assert!(SafeUrl::from_url(trailing_slash)?.validate().is_ok());
assert!(SafeUrl::from_url(double_q)?.validate().is_ok());
Ok(())
}
}