use std::{fmt, str::FromStr};
use crate::PublicKey;
use url::Url;
use crate::{Error, errors::RequestError};
#[inline]
fn invalid(msg: impl Into<String>) -> Error {
RequestError::Validation {
message: msg.into(),
}
.into()
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ResourcePath(String);
impl ResourcePath {
pub fn parse<S: AsRef<str>>(s: S) -> Result<Self, Error> {
let raw = s.as_ref();
if raw.is_empty() {
return Err(invalid("path cannot be empty"));
}
let input = if raw.starts_with('/') {
raw.to_string()
} else {
format!("/{raw}")
};
if input == "/" {
return Ok(Self("/".to_string()));
}
let wants_trailing = input.ends_with('/');
let mut u = Url::parse("dummy:///").map_err(|_err| invalid("internal URL setup failed"))?;
{
let mut segs = u
.path_segments_mut()
.map_err(|_err| invalid("internal URL path handling failed"))?;
segs.clear();
let mut parts = input.trim_start_matches('/').split('/').peekable();
while let Some(seg) = parts.next() {
if seg.is_empty() {
if parts.peek().is_none() && wants_trailing {
break;
}
return Err(invalid("path contains empty segment ('//')"));
}
if seg == "." || seg == ".." {
return Err(invalid("path cannot contain '.' or '..'"));
}
segs.push(seg);
}
if wants_trailing {
segs.push(""); }
}
Ok(Self(u.path().to_string()))
}
#[inline]
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl FromStr for ResourcePath {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl fmt::Display for ResourcePath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct PubkyResource {
pub owner: PublicKey,
pub path: ResourcePath,
}
impl PubkyResource {
pub fn new<S: AsRef<str>>(owner: PublicKey, path: S) -> Result<Self, Error> {
Ok(Self {
owner,
path: ResourcePath::parse(path)?,
})
}
#[must_use]
pub fn to_pubky_url(&self) -> String {
let rel = self.path.as_str().trim_start_matches('/');
format!("pubky://{}/{}", self.owner.z32(), rel)
}
pub fn to_transport_url(&self) -> Result<Url, Error> {
let rel = self.path.as_str().trim_start_matches('/');
let https = format!("https://_pubky.{}/{}", self.owner.z32(), rel);
Ok(Url::parse(&https)?)
}
pub fn from_transport_url(url: &Url) -> Result<Self, Error> {
let host = url
.host_str()
.ok_or_else(|| invalid("transport URL missing host"))?;
let owner = host
.strip_prefix("_pubky.")
.ok_or_else(|| invalid("transport URL host must start with '_pubky.'"))?;
if PublicKey::is_pubky_prefixed(owner) {
return Err(invalid(
"transport URL host must use raw z32 without `pubky` prefix",
));
}
let public_key = PublicKey::try_from_z32(owner)
.map_err(|_err| invalid("transport URL host does not contain a valid public key"))?;
let path = if url.path().is_empty() {
"/"
} else {
url.path()
};
Self::new(public_key, path)
}
pub(crate) fn to_identifier(&self) -> String {
let rel = self.path.as_str().trim_start_matches('/');
format!("{}/{}", self.owner, rel)
}
}
impl FromStr for PubkyResource {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some(rest) = s.strip_prefix("pubky://") {
let (user_str, path) = rest
.split_once('/')
.ok_or_else(|| invalid("missing `<user>/<path>`"))?;
if PublicKey::is_pubky_prefixed(user_str) {
return Err(invalid(
"unexpected `pubky` prefix in user id; use raw z32 after `pubky://`",
));
}
let user = PublicKey::try_from_z32(user_str)
.map_err(|_err| invalid(format!("invalid user public key: {user_str}")))?;
return Self::new(user, path);
}
if let Some(rest) = s.strip_prefix("pubky") {
if let Some((user_id, path)) = rest.split_once('/') {
if PublicKey::is_pubky_prefixed(user_id) {
return Err(invalid(
"unexpected `pubky` prefix in user id; use raw z32 after `pubky`",
));
}
let user = PublicKey::try_from_z32(user_id).map_err(|_err| {
invalid("expected `pubky<user>/<path>` or `pubky://<user>/<path>`")
})?;
return Self::new(user, path);
}
return Err(invalid(
"expected `pubky<user>/<path>` or `pubky://<user>/<path>`",
));
}
Err(invalid(
"expected `pubky<user>/<path>` or `pubky://<user>/<path>`",
))
}
}
impl fmt::Display for PubkyResource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_identifier())
}
}
pub fn resolve_pubky<S: AsRef<str>>(input: S) -> Result<Url, Error> {
let resource: PubkyResource = input.as_ref().parse()?;
resource.to_transport_url()
}
pub trait IntoResourcePath {
fn into_abs_path(self) -> Result<ResourcePath, Error>;
}
impl IntoResourcePath for ResourcePath {
#[inline]
fn into_abs_path(self) -> Result<ResourcePath, Error> {
Ok(self)
}
}
impl IntoResourcePath for &ResourcePath {
#[inline]
fn into_abs_path(self) -> Result<ResourcePath, Error> {
Ok(self.clone())
}
}
impl IntoResourcePath for &str {
fn into_abs_path(self) -> Result<ResourcePath, Error> {
ResourcePath::from_str(self)
}
}
impl IntoResourcePath for String {
fn into_abs_path(self) -> Result<ResourcePath, Error> {
ResourcePath::from_str(&self)
}
}
impl IntoResourcePath for &String {
fn into_abs_path(self) -> Result<ResourcePath, Error> {
ResourcePath::from_str(self.as_str())
}
}
pub trait IntoPubkyResource {
fn into_pubky_resource(self) -> Result<PubkyResource, Error>;
}
impl IntoPubkyResource for PubkyResource {
#[inline]
fn into_pubky_resource(self) -> Result<PubkyResource, Error> {
Ok(self)
}
}
impl IntoPubkyResource for &PubkyResource {
#[inline]
fn into_pubky_resource(self) -> Result<PubkyResource, Error> {
Ok(self.clone())
}
}
impl IntoPubkyResource for &str {
fn into_pubky_resource(self) -> Result<PubkyResource, Error> {
PubkyResource::from_str(self)
}
}
impl IntoPubkyResource for String {
fn into_pubky_resource(self) -> Result<PubkyResource, Error> {
PubkyResource::from_str(&self)
}
}
impl IntoPubkyResource for &String {
fn into_pubky_resource(self) -> Result<PubkyResource, Error> {
PubkyResource::from_str(self.as_str())
}
}
impl<P: AsRef<str>> IntoPubkyResource for (PublicKey, P) {
fn into_pubky_resource(self) -> Result<PubkyResource, Error> {
PubkyResource::new(self.0, self.1.as_ref())
}
}
impl<P: AsRef<str>> IntoPubkyResource for (&PublicKey, P) {
fn into_pubky_resource(self) -> Result<PubkyResource, Error> {
PubkyResource::new(self.0.clone(), self.1.as_ref())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Keypair;
#[test]
fn file_path_normalization_and_rejections() {
assert_eq!(
ResourcePath::parse("pub/my.app").unwrap().as_str(),
"/pub/my.app"
);
assert_eq!(
ResourcePath::parse("/pub/my.app").unwrap().as_str(),
"/pub/my.app"
);
assert!(matches!(
ResourcePath::parse(""),
Err(Error::Request(RequestError::Validation { .. }))
));
assert!(matches!(
ResourcePath::parse("/pub//app"),
Err(Error::Request(RequestError::Validation { .. }))
));
}
#[test]
fn parse_addressed_user_both_forms() {
let kp = Keypair::random();
let user = kp.public_key();
let user_raw = user.z32();
let s1 = format!("pubky://{user_raw}/pub/my-cool-app/file");
let s3 = format!("pubky{user_raw}/pub/my-cool-app/file");
let p1 = PubkyResource::from_str(&s1).unwrap();
let p3 = PubkyResource::from_str(&s3).unwrap();
assert_eq!(p1.owner, user);
assert_eq!(p3.owner, user);
assert_eq!(p1.path.as_str(), "/pub/my-cool-app/file");
assert_eq!(p3.path.as_str(), "/pub/my-cool-app/file");
assert_eq!(p1.to_string(), s3);
assert_eq!(p3.to_string(), s3);
assert_eq!(p1.to_pubky_url(), s1);
}
#[test]
fn session_scoped_paths_and_rendering() {
let p_abs = ResourcePath::parse("/pub/my-cool-app/file").unwrap();
assert_eq!(p_abs.as_str(), "/pub/my-cool-app/file");
assert!(matches!(
PubkyResource::from_str("/pub/my-cool-app/file"),
Err(Error::Request(RequestError::Validation { .. }))
));
let kp = Keypair::random();
let user = kp.public_key();
let r = PubkyResource::new(user.clone(), p_abs.as_str()).unwrap();
assert_eq!(
r.to_pubky_url(),
format!("pubky://{}/pub/my-cool-app/file", user.z32())
);
}
#[test]
fn error_cases() {
let kp = Keypair::random();
let user = kp.public_key();
assert!(matches!(
PubkyResource::from_str("pubkynot-a-key/pub/my.app"),
Err(Error::Request(RequestError::Validation { .. }))
));
let s_bad = format!("{user}/pub//app");
assert!(matches!(
PubkyResource::from_str(&s_bad),
Err(Error::Request(RequestError::Validation { .. }))
));
}
#[test]
fn percent_encoding_and_unicode() {
assert_eq!(
ResourcePath::parse("pub/My File.txt").unwrap().as_str(),
"/pub/My%20File.txt"
);
assert_eq!(
ResourcePath::parse("/ä/β/漢").unwrap().as_str(),
"/%C3%A4/%CE%B2/%E6%BC%A2"
);
}
#[test]
fn rejects_dot_segments_but_allows_trailing_slash() {
ResourcePath::parse("/a/./b").unwrap_err();
ResourcePath::parse("/a/../b").unwrap_err();
assert_eq!(
ResourcePath::parse("/pub/my-cool-app/").unwrap().as_str(),
"/pub/my-cool-app/"
);
}
#[test]
fn resolve_identifiers() {
let kp = Keypair::random();
let user = kp.public_key();
let base = format!("pubky://{}/pub/site/index.html", user.z32());
let resolved = resolve_pubky(&base).unwrap();
assert_eq!(
resolved.as_str(),
format!("https://_pubky.{}/pub/site/index.html", user.z32())
);
let prefixed = format!("pubky{}/pub/site/index.html", user.z32());
let resolved2 = resolve_pubky(&prefixed).unwrap();
assert_eq!(resolved, resolved2);
let resource = PubkyResource::from_str(&prefixed).unwrap();
assert_eq!(resource.to_transport_url().unwrap(), resolved);
let parsed = PubkyResource::from_transport_url(&resolved).unwrap();
assert_eq!(parsed, resource);
let http_url =
Url::parse(&format!("http://_pubky.{}/pub/site/index.html", user.z32())).unwrap();
let parsed_http = PubkyResource::from_transport_url(&http_url).unwrap();
assert_eq!(parsed_http, resource);
}
}