use url::{Url, form_urlencoded};
use crate::{LinkPermissions, LinkSecret, StreamId, protocol::MAX_SAFE_INTEGER_U64};
pub const DEFAULT_WEB_BASE_URL: &str = "https://tail.surf";
pub const LINK_SECRET_ENCODED_LENGTH: usize = LinkSecret::ENCODED_LEN;
#[derive(Clone, Debug)]
pub struct StreamLinkParam {
pub declared_permissions: LinkPermissions,
pub secret: LinkSecret,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct StreamAnchor {
pub seq_num: u64,
}
#[derive(Clone, Debug)]
pub struct StreamLocator {
pub stream_id: StreamId,
pub link: Option<StreamLinkParam>,
pub anchor: Option<StreamAnchor>,
}
impl StreamLocator {
pub fn parse(input: &str) -> Result<Self, StreamLinkError> {
let url = Url::parse(input)?;
validate_web_scheme(&url)?;
if url.query().is_some() {
return Err(StreamLinkError::QueryNotAllowed);
}
let stream_id = parse_stream_id(&url)?;
let (link, anchor) = url
.fragment()
.map(parse_fragment)
.transpose()?
.unwrap_or((None, None));
Ok(Self {
stream_id,
link,
anchor,
})
}
pub fn link_declaring(
&self,
required: impl Fn(LinkPermissions) -> bool,
) -> Option<&LinkSecret> {
self.link
.as_ref()
.filter(|candidate| required(candidate.declared_permissions))
.map(|candidate| &candidate.secret)
}
}
pub fn stream_link(
base_url: &Url,
stream_id: &StreamId,
permissions: LinkPermissions,
secret: &LinkSecret,
) -> Result<Url, StreamLinkError> {
let mut url = public_stream_url(base_url, stream_id)?;
let fragment = form_urlencoded::Serializer::new(String::new())
.append_pair(permissions.as_str(), secret.expose_secret())
.finish();
url.set_fragment(Some(&fragment));
Ok(url)
}
pub fn public_stream_url(base_url: &Url, stream_id: &StreamId) -> Result<Url, StreamLinkError> {
let mut url = base_url.clone();
validate_web_scheme(&url)?;
url.set_username("")
.map_err(|()| StreamLinkError::InvalidBaseUrl)?;
url.set_password(None)
.map_err(|()| StreamLinkError::InvalidBaseUrl)?;
url.set_path(&format!("/s/{stream_id}"));
url.set_query(None);
url.set_fragment(None);
Ok(url)
}
pub fn default_web_base_url() -> Url {
Url::parse(DEFAULT_WEB_BASE_URL).expect("default tsf web base URL is valid")
}
fn parse_stream_id(url: &Url) -> Result<StreamId, StreamLinkError> {
let mut segments = url
.path_segments()
.ok_or(StreamLinkError::InvalidStreamPath)?;
match (segments.next(), segments.next(), segments.next()) {
(Some("s"), Some(stream_id), None) => StreamId::decode(stream_id)
.map_err(|source| StreamLinkError::InvalidStreamId { source }),
_ => Err(StreamLinkError::InvalidStreamPath),
}
}
fn parse_fragment(
fragment: &str,
) -> Result<(Option<StreamLinkParam>, Option<StreamAnchor>), StreamLinkError> {
if fragment.is_empty() || fragment.split('&').any(str::is_empty) {
return Err(StreamLinkError::InvalidFragment);
}
let mut link = None;
let mut anchor = None;
for (key, value) in form_urlencoded::parse(fragment.as_bytes()) {
if key == "at" {
if anchor.is_some() {
return Err(StreamLinkError::MultipleAnchors);
}
let canonical = value == "0"
|| (!value.starts_with('0') && value.bytes().all(|byte| byte.is_ascii_digit()));
let seq_num = if canonical {
value.parse().map_err(|_| StreamLinkError::InvalidAnchor)?
} else {
return Err(StreamLinkError::InvalidAnchor);
};
if seq_num > MAX_SAFE_INTEGER_U64 {
return Err(StreamLinkError::InvalidAnchor);
}
anchor = Some(StreamAnchor { seq_num });
continue;
}
if link.is_some() {
return Err(StreamLinkError::MultipleLinks);
}
let declared_permissions = key.parse()?;
let secret = value
.parse()
.map_err(|_| StreamLinkError::InvalidLinkSecret)?;
link = Some(StreamLinkParam {
declared_permissions,
secret,
});
}
Ok((link, anchor))
}
fn validate_web_scheme(url: &Url) -> Result<(), StreamLinkError> {
if matches!(url.scheme(), "http" | "https") {
Ok(())
} else {
Err(StreamLinkError::InvalidScheme(url.scheme().to_owned()))
}
}
#[derive(Debug, thiserror::Error)]
pub enum StreamLinkError {
#[error("invalid stream URL: {0}")]
Url(#[from] url::ParseError),
#[error("stream URL scheme must be http or https, not {0:?}")]
InvalidScheme(String),
#[error("stream URL base could not be normalized")]
InvalidBaseUrl,
#[error("stream URL path must be /s/{{stream_id}}")]
InvalidStreamPath,
#[error("stream URL has invalid stream id")]
InvalidStreamId {
#[source]
source: ubid::DecodeError,
},
#[error("stream URLs do not accept query parameters")]
QueryNotAllowed,
#[error("stream URL fragment must contain a credential or at")]
InvalidFragment,
#[error("stream URL fragment has invalid permissions")]
InvalidPermissions(#[from] crate::PermissionsError),
#[error("stream link secret must be canonical 32-character unpadded base64url")]
InvalidLinkSecret,
#[error("stream URL fragment contains multiple links")]
MultipleLinks,
#[error("stream URL at anchor must be a canonical decimal u64")]
InvalidAnchor,
#[error("stream URL fragment contains multiple at parameters")]
MultipleAnchors,
}
#[cfg(test)]
mod tests {
use super::*;
const STREAM_ID: &str = "0123456789abcdefghjkmnpqrstvwxyz";
const SECRET: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
#[test]
fn parses_stream_link() {
let locator = StreamLocator::parse(&format!(
"https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#w={SECRET}"
))
.expect("stream URL");
assert_eq!(
locator.stream_id,
STREAM_ID.parse::<StreamId>().expect("stream id")
);
let link = locator.link.expect("link");
assert_eq!(link.declared_permissions.to_string(), "w");
assert_eq!(link.secret.expose_secret(), SECRET);
}
#[test]
fn parses_percent_encoded_link_fragment() {
let encoded_secret = format!("%41{}", &SECRET[1..]);
let locator = StreamLocator::parse(&format!(
"https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#o={encoded_secret}"
))
.expect("stream URL");
assert_eq!(
locator.stream_id,
STREAM_ID.parse::<StreamId>().expect("stream id")
);
let link = locator.link.expect("link");
assert_eq!(link.declared_permissions.to_string(), "o");
assert_eq!(link.secret.expose_secret(), SECRET);
}
#[test]
fn parses_a_composite_client_fragment() {
let locator =
StreamLocator::parse(&format!("https://tail.surf/s/{STREAM_ID}#r={SECRET}&at=50"))
.expect("stream URL");
assert_eq!(locator.anchor, Some(StreamAnchor { seq_num: 50 }));
assert_eq!(locator.link.expect("link").secret.expose_secret(), SECRET);
let anchor_only = StreamLocator::parse(&format!("https://tail.surf/s/{STREAM_ID}#at=0"))
.expect("anchor URL");
assert!(anchor_only.link.is_none());
assert_eq!(anchor_only.anchor, Some(StreamAnchor { seq_num: 0 }));
}
#[test]
fn rejects_invalid_paths_permissions_empty_secrets_and_multiple_links() {
assert!(matches!(
StreamLocator::parse("https://tail.surf/not-a-stream"),
Err(StreamLinkError::InvalidStreamPath)
));
assert!(matches!(
StreamLocator::parse(&format!(
"https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#x={SECRET}"
)),
Err(StreamLinkError::InvalidPermissions(_))
));
assert!(matches!(
StreamLocator::parse("https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#r="),
Err(StreamLinkError::InvalidLinkSecret)
));
assert!(matches!(
StreamLocator::parse(
"https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#r=too-short"
),
Err(StreamLinkError::InvalidLinkSecret)
));
assert!(matches!(
StreamLocator::parse(
"https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#r=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+"
),
Err(StreamLinkError::InvalidLinkSecret)
));
assert!(matches!(
StreamLocator::parse(
"https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#r=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
),
Err(StreamLinkError::InvalidLinkSecret)
));
assert!(matches!(
StreamLocator::parse(&format!(
"https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#w={SECRET}&r={SECRET}"
)),
Err(StreamLinkError::MultipleLinks)
));
for fragment in ["at=01", "at=-1", "at=9007199254740992"] {
assert!(matches!(
StreamLocator::parse(&format!(
"https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#{fragment}"
)),
Err(StreamLinkError::InvalidAnchor)
));
}
assert!(matches!(
StreamLocator::parse("https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#at=1&at=2"),
Err(StreamLinkError::MultipleAnchors)
));
for query in ["", "at=50", "seq_num=100", "view=raw"] {
assert!(matches!(
StreamLocator::parse(&format!(
"https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz?{query}"
)),
Err(StreamLinkError::QueryNotAllowed)
));
}
assert!(matches!(
StreamLocator::parse("https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#&"),
Err(StreamLinkError::InvalidFragment)
));
assert!(matches!(
StreamLocator::parse("https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#"),
Err(StreamLinkError::InvalidFragment)
));
assert!(matches!(
StreamLocator::parse("https://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#at=1&"),
Err(StreamLinkError::InvalidFragment)
));
}
#[test]
fn builds_stream_link() {
let base_url = Url::parse("http://user:password@localhost:8787/old?query=yes#fragment")
.expect("base URL");
let stream_id = STREAM_ID.parse::<StreamId>().expect("stream id");
let link: LinkSecret = SECRET.parse().expect("canonical secret");
let url = stream_link(&base_url, &stream_id, LinkPermissions::owner(), &link)
.expect("valid stream link");
assert_eq!(
url.as_str(),
format!("http://localhost:8787/s/0123456789abcdefghjkmnpqrstvwxyz#o={SECRET}")
);
}
#[test]
fn rejects_invalid_link_secret_text() {
assert!("too-short".parse::<LinkSecret>().is_err());
assert!(
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
.parse::<LinkSecret>()
.is_err()
);
}
#[test]
fn rejects_non_http_stream_links_when_parsing_and_building() {
assert!(matches!(
StreamLocator::parse(&format!(
"ftp://tail.surf/s/0123456789abcdefghjkmnpqrstvwxyz#r={SECRET}"
)),
Err(StreamLinkError::InvalidScheme(scheme)) if scheme == "ftp"
));
let base_url = Url::parse("ftp://tail.surf").expect("base URL");
let stream_id = STREAM_ID.parse::<StreamId>().expect("stream id");
assert!(matches!(
stream_link(
&base_url,
&stream_id,
LinkPermissions::read(),
&SECRET.parse().expect("canonical secret")
),
Err(StreamLinkError::InvalidScheme(scheme)) if scheme == "ftp"
));
}
}