814 lines · 34.0 KB · Rust
RawDownload
//! A library implementing a URL for use in git with access to its special capabilities.//!//! ## Examples//!//! ```//! let mut url = gix_url::parse("ssh://git@example.com/gitoxide").unwrap();//! assert_eq!(url.user(), Some("git"));//! assert_eq!(url.host(), Some("example.com"));//! assert_eq!(url.to_bstring(), "ssh://git@example.com/gitoxide");//!//! assert_eq!(url.set_user(Some("byron".into())), Some("git".into()));//! assert_eq!(url.user_argument_safe(), Some("byron"));//! assert_eq!(url.to_bstring(), "ssh://byron@example.com/gitoxide");//!//! let suspicious = gix_url::parse("ssh://-Fconfig@host/repo").unwrap();//! assert_eq!(suspicious.user_argument_safe(), None, "The user isn't returned as it looks like an argument");//! ```//! ## Feature Flags#![cfg_attr(    all(doc, feature = "document-features"),    doc = ::document_features::document_features!())]#![cfg_attr(all(doc, feature = "document-features"), feature(doc_cfg))]#![deny(missing_docs)]#![forbid(unsafe_code)] use std::{borrow::Cow, path::PathBuf}; use bstr::{BStr, BString};use gix_utils::AsBStr; const HTTP_PATH_ENCODE_SET: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS    .add(b' ')    .add(b'"')    .add(b'%')    .add(b'<')    .add(b'>')    .add(b'`')    .add(b'{')    .add(b'}'); /// User-home expansion for repository paths.pub mod expand_path; mod scheme;pub use scheme::Scheme;mod impls; /// Parsing errors and input classifications.pub mod parse; /// Minimal URL parser to replace the `url` crate dependencymod simple_url; /// Parse a Git remote location from `input`.////// This accepts standard URLs, SCP-like SSH locations, remote-helper locations and local paths. URL and SCP-like/// inputs must be UTF-8; remote-helper addresses and local paths retain arbitrary bytes.////// Locations of the `<helper>::<address>` form described in/// [`gitremote-helpers`](https://git-scm.com/docs/gitremote-helpers) are recognized before any URL/// syntax, so an address may itself contain `://`. They are represented as/// [`Scheme::Helper`] holding the helper name, with the address kept verbatim in [`Url::path`] as only the helper/// program can interpret it. The command-executing `ext` helper is represented as [`Scheme::Ext`] in both spellings;/// `ext://<address>` retains the entire URL as its command. Other unknown URL transports in the/// `<helper>://<address>` form are represented as [`Scheme::HelperUrl`].////// # Deviation////// Unlike Git, this rejects textual and overflowing ports in SSH and Git URLs. Git treats such port text as part of/// the hostname, which hides the malformed port and causes a less useful hostname-resolution error later.////// Also unlike Git, an empty remote-helper name as in `::address` is not accepted, as the `git-remote-` program it/// would name cannot meaningfully exist.pub fn parse(input: impl AsBStr) -> Result<Url, parse::Error> {    use parse::InputScheme;    let input = input.as_bstr();    match parse::find_scheme(input) {        InputScheme::RemoteHelper { helper_end } => Ok(parse::remote_helper(input, helper_end)),        InputScheme::Local => parse::local(input),        InputScheme::Url { protocol_end } if input[..protocol_end] == *b"file" => parse::file_url(input, protocol_end),        InputScheme::Url { protocol_end } => parse::url(input, protocol_end),        InputScheme::Scp { colon } => parse::scp(input, colon),    }} /// Expand `path` for the given `user`, which can be obtained from [`expand_path::parse()`], resolving the home/// directory automatically.////// If more precise control of the resolution mechanism is needed, then use the [expand_path::with()] function.pub fn expand_path(user: Option<&expand_path::ForUser>, path: &BStr) -> Result<PathBuf, expand_path::Error> {    expand_path::with(user, path, |user| match user {        expand_path::ForUser::Current => gix_path::env::home_dir(),        expand_path::ForUser::Name(user) => {            gix_path::env::home_dir().and_then(|home| home.parent().map(|home_dirs| home_dirs.join(user.to_string())))        }    })} /// Classification of a portion of a URL by whether it is *syntactically* safe to pass as an argument to a command-line program.////// Various parts of URLs can be specified to begin with `-`. If they are used as options to a command-line application/// such as an SSH client, they will be treated as options rather than as non-option arguments as the developer intended./// This is a security risk, because URLs are not always trusted and can often be composed or influenced by an attacker./// See <https://secure.phabricator.com/T12961> for details.////// # Security Warning////// This type only expresses known *syntactic* risk. It does not cover other risks, such as passing a personal access/// token as a username rather than a password in an application that logs usernames.#[derive(Debug, PartialEq, Eq, Copy, Clone)]pub enum ArgumentSafety<'a> {    /// May be safe. There is nothing to pass, so there is nothing dangerous.    Absent,    /// May be safe. The argument does not begin with a `-` and so will not be confused as an option.    Usable(&'a str),    /// Dangerous! Begins with `-` and could be treated as an option. Use the value in error messages only.    Dangerous(&'a str),} /// A URL with support for specialized git related capabilities.////// Additionally, there is support for [deserialization](Url::from_bytes()) and [serialization](Url::to_bstring()).////// # Mutability Warning////// Public fields can be modified into combinations that do not serialize or parse. Use [`parse()`] or/// [`Url::from_parts()`] at trust boundaries; do not assume an accepted `Url` remains valid without revalidation.////// # Serialization////// This type does not implement `Into<String>`, `From<Url> for String` because URLs/// can contain non-UTF-8 sequences in the path component when parsed from raw bytes./// Use [to_bstring()](Url::to_bstring()) for complete serialization, including non-UTF-8 path bytes, or use the/// [`Display`](std::fmt::Display) trait for a UTF-8 representation that redacts passwords for safe logging.////// When the `serde` feature is enabled, this type implements `serde::Serialize` and `serde::Deserialize`,/// which will serialize *all* fields, including the password.////// # Security Warning////// URLs may contain passwords and using standard [formatting](std::fmt::Display) will redact such passwords,/// whereas [`Url::to_bstring()`] includes all URL parts./// **Beware that some URLs still print secrets if they use them outside of the designated password fields.**////// Also note that URLs that fail to parse are typically stored in [the resulting error](parse::Error) type/// and printed in full using its display implementation.#[derive(PartialEq, Eq, Debug, Hash, Ord, PartialOrd, Clone)]#[cfg_attr(feature = "serde", derive(serde::Serialize))]pub struct Url {    /// The URL scheme.    pub scheme: Scheme,    /// The user to impersonate on the remote.    ///    /// Stored in decoded form: percent-encoded characters are decoded during parsing.    /// Re-encoded during canonical serialization, but written as-is in alternative form.    pub user: Option<String>,    /// The password associated with a user.    ///    /// Stored in decoded form: percent-encoded characters are decoded during parsing.    /// Re-encoded during canonical serialization. Its presence makes serialization use canonical rather than alternative    /// form because SCP-like and local-path syntax cannot represent a password.    pub password: Option<String>,    /// The host to which to connect, or `None` for locations without a host, such as local paths.    ///    /// Brackets are stripped from parsed SSH hosts and otherwise preserved as parsed. Serialization adds brackets to    /// unbracketed colon-containing hosts when needed to disambiguate a port or scoped IPv6 address.    /// DNS-like ASCII hosts are lowercased. Non-HTTP hosts are percent-decoded for Git compatibility, while HTTP and    /// HTTPS host escapes remain encoded.    pub host: Option<String>,    /// Request alternative serialization, generally because the location was parsed in that form.    ///    /// Alternative forms include SCP-like syntax (`user@host:path`), bare file paths, and the `<helper>::<address>`    /// syntax of [`gitremote-helpers`](https://git-scm.com/docs/gitremote-helpers).    /// It is used only for SSH or file locations without a password or port. [`Scheme::Helper`] and [`Scheme::Ext`]    /// always use remote-helper form, and SCP-like form is also retained when canonical SSH form would change a    /// relative repository path.    pub serialize_alternative_form: bool,    /// The explicit port, if parsed or assigned.    ///    /// Git accepts port zero in SSH and Git URLs, so this field may contain `0`. Textual and overflowing ports are    /// rejected unlike Git; see the deviation documented on [`parse()`]. Use [`Self::port_or_default()`] to obtain a    /// scheme default when this is `None`.    pub port: Option<u16>,    /// The path portion of the URL, usually the location of the git repository.    ///    /// Percent-encoded characters are decoded during parsing (e.g., `%20` becomes a space in this field). An unchanged    /// parsed path may retain its original encoded spelling for serialization. Constructed or modified HTTP paths are    /// percent-encoded during canonical serialization; other schemes write the path bytes as stored.    ///    /// Path normalization during parsing:    /// - SSH/Git schemes: Leading `/~` is stripped (e.g., `/~repo` becomes `~repo`)    /// - SSH/Git schemes: Empty paths are rejected as errors    /// - HTTP/HTTPS schemes: Empty paths are normalized to `/`    ///    /// This type has no separate query or fragment fields. For HTTP and HTTPS, `?`, `#`, and everything after them are    /// stored in this field. For other URL schemes, Git treats `?` and `#` before the first slash as authority text.    ///    /// For locations in the `<helper>::<address>` form of    /// [`gitremote-helpers`](https://git-scm.com/docs/gitremote-helpers), this holds the address verbatim,    /// uninterpreted and possibly empty, as only the helper program can make sense of it.    ///    /// During serialization, SSH/Git URLs prepend `/` to paths not starting with `/`.    ///    /// # Security Warning    ///    /// URLs allow paths to start with `-` which makes it possible to mask command-line arguments as path which then leads to    /// the invocation of programs from an attacker controlled URL. See <https://secure.phabricator.com/T12961> for details.    ///    /// For a slash-prefixed path that will be passed intact to a command-line application, call    /// [`Self::path_argument_safe()`]. Other path forms require validation appropriate to how they will be passed.    pub path: BString,    /// The original parsed path when it contains percent escapes.    ///    /// This lets serialization retain the encoded spelling while [`Self::path`] remains decoded. It is reused only    /// while decoding it still produces the public path; constructing or mutating the public path encodes percent signs.    #[cfg_attr(feature = "serde", serde(default))]    pub(crate) path_with_percent_escapes: Option<BString>,} #[cfg(feature = "serde")]impl<'de> serde::Deserialize<'de> for Url {    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>    where        D: serde::Deserializer<'de>,    {        #[derive(serde::Deserialize)]        struct Fields {            scheme: Scheme,            user: Option<String>,            password: Option<String>,            host: Option<String>,            serialize_alternative_form: bool,            port: Option<u16>,            path: BString,            #[serde(default)]            path_with_percent_escapes: Option<BString>,        }         let mut fields = Fields::deserialize(deserializer)?;        if fields.path_with_percent_escapes.as_ref() == Some(&fields.path) {            fields.path_with_percent_escapes = Some(encode_legacy_http_path(&fields.path));            fields.path = percent_encoding::percent_decode(&fields.path)                .collect::<Vec<_>>()                .into();        }        Ok(Url {            scheme: fields.scheme,            user: fields.user,            password: fields.password,            host: fields.host,            serialize_alternative_form: fields.serialize_alternative_form,            port: fields.port,            path: fields.path,            path_with_percent_escapes: fields.path_with_percent_escapes,        })    }} #[cfg(feature = "serde")]fn encode_legacy_http_path(path: &[u8]) -> BString {    let mut out = Vec::with_capacity(path.len());    let mut start = 0;    let mut pos = 0;    while pos + 2 < path.len() {        if path[pos] == b'%' && path[pos + 1].is_ascii_hexdigit() && path[pos + 2].is_ascii_hexdigit() {            out.extend(                percent_encoding::percent_encode(&path[start..pos], HTTP_PATH_ENCODE_SET)                    .to_string()                    .bytes(),            );            out.extend_from_slice(&path[pos..pos + 3]);            pos += 3;            start = pos;        } else {            pos += 1;        }    }    out.extend(        percent_encoding::percent_encode(&path[start..], HTTP_PATH_ENCODE_SET)            .to_string()            .bytes(),    );    out.into()} /// Instantiationimpl Url {    /// Create an instance from the given parts and validate it by serializing and parsing it back.    ///    /// For HTTP and HTTPS, `path` is decoded data: literal percent signs are encoded during serialization, and an empty    /// path is normalized to `/`. Other schemes interpret `path` according to their serialized syntax.    /// `serialize_alternative_form` merely requests alternative form; passwords, ports, and unsupported schemes force    /// canonical URL serialization.    ///    /// # Panics    ///    /// Panics if the supplied parts cannot be serialized before validation, such as a user without a host.    pub fn from_parts(        scheme: Scheme,        user: Option<String>,        password: Option<String>,        host: Option<String>,        port: Option<u16>,        path: BString,        serialize_alternative_form: bool,    ) -> Result<Self, parse::Error> {        if let Scheme::Helper(name) = &scheme            && !parse::is_valid_remote_helper_name(name.as_bytes())        {            return Err(parse::Error::InvalidRemoteHelperName { name: name.clone() });        }        let is_http = matches!(scheme, Scheme::Http | Scheme::Https);        let mut parsed = parse(            Url {                scheme,                user,                password,                host,                port,                path: path.clone(),                serialize_alternative_form,                path_with_percent_escapes: None,            }            .to_bstring(),        )?;        if is_http {            // Preserve the caller's path as decoded data, except for an empty path normalized to `/` above. In            // particular, percent escapes supplied through `from_parts()` are literal text and must be encoded.            if !path.is_empty() {                parsed.path = path;            }            parsed.path_with_percent_escapes = None;        }        Ok(parsed)    }} /// Modificationimpl Url {    /// Set the given `user`, or unset it with `None`. Return the previous value.    pub fn set_user(&mut self, user: Option<String>) -> Option<String> {        let prev = self.user.take();        self.user = user;        prev    }     /// Set the given `password`, or unset it with `None`. Return the previous value.    pub fn set_password(&mut self, password: Option<String>) -> Option<String> {        let prev = self.password.take();        self.password = password;        prev    }} /// Builderimpl Url {    /// Request alternative serialization, e.g. `file:///path` becomes `/path`.    ///    /// Parsed URLs set this automatically. Alternative form is used only for SSH or file locations without a password    /// or port; all other values serialize in canonical URL form.    ///    /// Setting `use_alternate_form` to `false` requests canonical, URL-like, serialization. SCP-like form is retained if    /// canonical SSH form would change a relative repository path. Remote-helper form is always retained because URL    /// form would change the address Git passes to the helper.    pub fn with_request_alternate_form(mut self, use_alternate_form: bool) -> Self {        self.serialize_alternative_form = use_alternate_form;        self    }     /// Resolve the path of a file location against `current_dir` and normalize it in place.    ///    /// Other schemes are unchanged.    pub fn canonicalize(&mut self, current_dir: &std::path::Path) -> Result<(), gix_path::realpath::Error> {        if self.scheme == Scheme::File {            let path = gix_path::from_bstr(Cow::Borrowed(self.path.as_ref()));            let abs_path = gix_path::realpath_opts(path.as_ref(), current_dir, gix_path::realpath::MAX_SYMLINKS)?;            self.path = gix_path::into_bstr(abs_path).into_owned();        }        Ok(())    }} /// Accessimpl Url {    /// Return the username mentioned in the URL, if present.    ///    /// # Security Warning    ///    /// URLs allow usernames to start with `-` which makes it possible to mask command-line arguments as username which then leads to    /// the invocation of programs from an attacker controlled URL. See <https://secure.phabricator.com/T12961> for details.    ///    /// If this value is ever going to be passed to a command-line application, call [Self::user_argument_safe()] instead.    pub fn user(&self) -> Option<&str> {        self.user.as_deref()    }     /// Classify the username of this URL by whether it is safe to pass as a command-line argument.    ///    /// Use this method instead of [Self::user()] if the username is going to be passed to a command-line application.    /// If the unsafe and absent cases need not be distinguished, [Self::user_argument_safe()] may also be used.    pub fn user_as_argument(&self) -> ArgumentSafety<'_> {        match self.user() {            Some(user) if looks_like_command_line_option(user.as_bytes()) => ArgumentSafety::Dangerous(user),            Some(user) => ArgumentSafety::Usable(user),            None => ArgumentSafety::Absent,        }    }     /// Return the username of this URL if present *and* if it can't be mistaken for a command-line argument.    ///    /// Use this method or [Self::user_as_argument()] instead of [Self::user()] if the username is going to be    /// passed to a command-line application. Prefer [Self::user_as_argument()] unless the unsafe and absent    /// cases need not be distinguished from each other.    pub fn user_argument_safe(&self) -> Option<&str> {        match self.user_as_argument() {            ArgumentSafety::Usable(user) => Some(user),            _ => None,        }    }     /// Return the password mentioned in the url, if present.    pub fn password(&self) -> Option<&str> {        self.password.as_deref()    }     /// Return the host mentioned in the URL, if present.    ///    /// # Security Warning    ///    /// URLs allow hosts to start with `-` which makes it possible to mask command-line arguments as host which then leads to    /// the invocation of programs from an attacker controlled URL. See <https://secure.phabricator.com/T12961> for details.    ///    /// If this value is ever going to be passed to a command-line application, call [Self::host_as_argument()]    /// or [Self::host_argument_safe()] instead.    pub fn host(&self) -> Option<&str> {        self.host.as_deref()    }     /// Classify the host of this URL by whether it is safe to pass as a command-line argument.    ///    /// Use this method instead of [Self::host()] if the host is going to be passed to a command-line application.    /// If the unsafe and absent cases need not be distinguished, [Self::host_argument_safe()] may also be used.    pub fn host_as_argument(&self) -> ArgumentSafety<'_> {        match self.host() {            Some(host) if looks_like_command_line_option(host.as_bytes()) => ArgumentSafety::Dangerous(host),            Some(host) => ArgumentSafety::Usable(host),            None => ArgumentSafety::Absent,        }    }     /// Return the host of this URL if present *and* if it can't be mistaken for a command-line argument.    ///    /// Use this method or [Self::host_as_argument()] instead of [Self::host()] if the host is going to be    /// passed to a command-line application. Prefer [Self::host_as_argument()] unless the unsafe and absent    /// cases need not be distinguished from each other.    pub fn host_argument_safe(&self) -> Option<&str> {        match self.host_as_argument() {            ArgumentSafety::Usable(host) => Some(host),            _ => None,        }    }     fn path_with_percent_escapes(&self) -> Option<&BStr> {        let encoded = self.path_with_percent_escapes.as_ref()?;        percent_encoding::percent_decode(encoded)            .eq(self.path.iter().copied())            .then_some(encoded.as_ref())    }     /// Return the original percent-escaped path if [Self::path] wasn't changed in the meantime, or [`Self::path`] otherwise.    pub fn original_path(&self) -> &BStr {        self.path_with_percent_escapes().unwrap_or(self.path.as_ref())    }     /// Return a slash-prefixed path if the bytes after the slash can't be mistaken for a command-line argument.    ///    /// The leading slash must be present and must be passed to the command. Empty and non-slash-prefixed paths return    /// `None`; validate those according to how they will be passed.    pub fn path_argument_safe(&self) -> Option<&BStr> {        self.path            .strip_prefix(b"/")            .and_then(|truncated| (!looks_like_command_line_option(truncated)).then_some(self.path.as_ref()))    }     /// Return true if the path portion of the URL is `/`.    pub fn path_is_root(&self) -> bool {        self.path == "/"    }     /// Return the actual or default port for use according to the URL scheme.    /// Note that there may be no default port either.    pub fn port_or_default(&self) -> Option<u16> {        self.port.or_else(|| {            use Scheme::*;            Some(match self.scheme {                Http => 80,                Https => 443,                Ssh => 22,                Git => 9418,

Lines 1–500 of 814.