598 lines · 23.8 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".into()).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".into()).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}; ///pub mod expand_path; mod scheme;pub use scheme::Scheme;mod impls; ///pub mod parse; /// Minimal URL parser to replace the `url` crate dependencymod simple_url; /// Parse the given `bytes` as a [git url](Url).////// # Note////// We cannot and should never have to deal with UTF-16 encoded windows strings, so bytes input is acceptable./// For file-paths, we don't expect UTF8 encoding either.pub fn parse(input: &BStr) -> Result<Url, parse::Error> {    use parse::InputScheme;    match parse::find_scheme(input) {        InputScheme::Local => parse::local(input),        InputScheme::Url { protocol_end } if input[..protocol_end].eq_ignore_ascii_case(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 by [`parse()`], resolving the home directories/// of `user` 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////// Due to the mutability of this type, it's possible that the URL serializes to something invalid/// when fields are modified directly. URLs should always be parsed to this type from string or byte/// parameters, but never be accepted as an instance of this type and then reconstructed, to maintain/// validity guarantees.////// # 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 lossless serialization, 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 password, whereas [lossless serialization](Url::to_bstring()) will contain all parts of the/// URL./// **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, serde::Deserialize))]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. Cannot be serialized in alternative form (will panic in debug builds).    pub password: Option<String>,    /// The host to which to connect. Localhost is implied if `None`.    ///    /// IPv6 addresses are stored *without* brackets for SSH schemes, but *with* brackets for other schemes.    /// Brackets are automatically added during serialization when needed (e.g., when a port is specified with an IPv6 host).    pub host: Option<String>,    /// When serializing, use the alternative forms as it was parsed as such.    ///    /// Alternative forms include SCP-like syntax (`user@host:path`) and bare file paths.    /// When `true`, password and port cannot be serialized (will panic in debug builds).    pub serialize_alternative_form: bool,    /// The port to use when connecting to a host. If `None`, standard ports depending on `scheme` will be used.    pub port: Option<u16>,    /// The path portion of the URL, usually the location of the git repository.    ///    /// Paths are stored in decoded form: percent-encoded characters are decoded during parsing    /// and re-encoded during canonical serialization (e.g., `%20` becomes a space in this field).    ///    /// 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 `/`    ///    /// 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.    ///    /// If this value is ever going to be passed to a command-line application, call [Self::path_argument_safe()] instead.    pub path: BString,} /// Instantiationimpl Url {    /// Create a new instance from the given parts, including a password, which will be validated by parsing them back.    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> {        parse(            Url {                scheme,                user,                password,                host,                port,                path,                serialize_alternative_form,            }            .to_bstring()            .as_ref(),        )    }} /// 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 {    /// Enable alternate serialization for this url, e.g. `file:///path` becomes `/path`.    ///    /// This is automatically set correctly for parsed URLs, but can be set here for urls    /// created by constructor.    ///    /// Setting `use_alternate_form` to `false` forces canonical serialization. For SCP-like SSH URLs this    /// can be lossy: `user@host:path/repo` and `user@host:/path/repo` both serialize as    /// `ssh://user@host/path/repo`, even though Git treats their repository paths as    /// `path/repo` and `/path/repo` respectively when invoking the remote service.    pub fn serialize_alternate_form(mut self, use_alternate_form: bool) -> Self {        self.serialize_alternative_form = use_alternate_form;        self    }     /// Turn a file url like `file://relative` into `file:///root/relative`, hence it assures the url's path component is absolute,    /// using `current_dir` if needed to achieve that.    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 host 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 host 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,        }    }     /// Return the path of this URL *if* it can't be mistaken for a command-line argument.    /// Note that it always begins with a slash, which is ignored for this comparison.    ///    /// Use this method instead of accessing [Self::path] directly if the path is going to be passed to a    /// command-line application, unless it is certain that the leading `/` will always be included.    pub fn path_argument_safe(&self) -> Option<&BStr> {        self.path            .get(1..)            .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,                File | Ext(_) => return None,            })        })    }} fn looks_like_command_line_option(b: &[u8]) -> bool {    b.first() == Some(&b'-')} /// Transformationimpl Url {    /// Turn a file URL like `file://relative` into `file:///root/relative`, hence it assures the URL's path component is absolute, using    /// `current_dir` if necessary.    pub fn canonicalized(&self, current_dir: &std::path::Path) -> Result<Self, gix_path::realpath::Error> {        let mut res = self.clone();        res.canonicalize(current_dir)?;        Ok(res)    }} /// Serializationimpl Url {    /// Write this URL losslessly to `out`, ready to be parsed again.    pub fn write_to(&self, out: &mut dyn std::io::Write) -> std::io::Result<()> {        // Since alternative form doesn't employ any escape syntax, password and        // port number cannot be encoded.        if self.serialize_alternative_form            && (self.scheme == Scheme::File || self.scheme == Scheme::Ssh)            && self.password.is_none()            && self.port.is_none()        {            self.write_alternative_form_to(out)        } else {            self.write_canonical_form_to(out)        }    }     fn write_canonical_form_to(&self, out: &mut dyn std::io::Write) -> std::io::Result<()> {        fn percent_encode(s: &str) -> Cow<'_, str> {            /// Characters that must be percent-encoded in the userinfo component of a URL.            ///            /// According to RFC 3986, userinfo can contain:            /// - unreserved characters: `A-Z a-z 0-9 - . _ ~`            /// - percent-encoded characters            /// - sub-delims: `! $ & ' ( ) * + , ; =`            /// - `:`            ///            /// This encode-set encodes everything else, particularly `@` (userinfo delimiter),            /// `/` `?` `#` (path/query/fragment delimiters), and various other special characters.            const USERINFO_ENCODE_SET: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS                .add(b' ')                .add(b'"')                .add(b'#')                .add(b'%')                .add(b'/')                .add(b'<')                .add(b'>')                .add(b'?')                .add(b'@')                .add(b'[')                .add(b'\\')                .add(b']')                .add(b'^')                .add(b'`')                .add(b'{')                .add(b'|')                .add(b'}');            percent_encoding::utf8_percent_encode(s, USERINFO_ENCODE_SET).into()        }         out.write_all(self.scheme.as_str().as_bytes())?;        out.write_all(b"://")?;         let needs_brackets = self.port.is_some() && self.host_needs_brackets();         match (&self.user, &self.host) {            (Some(user), Some(host)) => {                out.write_all(percent_encode(user).as_bytes())?;                if let Some(password) = &self.password {                    out.write_all(b":")?;                    out.write_all(percent_encode(password).as_bytes())?;                }                out.write_all(b"@")?;                if needs_brackets {                    out.write_all(b"[")?;                }                out.write_all(host.as_bytes())?;                if needs_brackets {                    out.write_all(b"]")?;                }            }            (None, Some(host)) => {                if needs_brackets {                    out.write_all(b"[")?;                }                out.write_all(host.as_bytes())?;                if needs_brackets {                    out.write_all(b"]")?;                }            }            (None, None) => {}            (Some(_user), None) => {                return Err(std::io::Error::other(                    "Invalid URL structure: user specified without host",                ));            }        }        if let Some(port) = &self.port {            write!(out, ":{port}")?;        }        // For SSH and Git URLs, add leading '/' if path doesn't start with '/'        // This handles paths like "~repo" which serialize as "/~repo" in URL form        if matches!(self.scheme, Scheme::Ssh | Scheme::Git) && !self.path.starts_with(b"/") {            out.write_all(b"/")?;        }        if matches!(self.scheme, Scheme::Http | Scheme::Https) {            // We intentionally do not encode '?' and '#': ParsedUrl keeps them in `path`,            // and encoding would change routed endpoints for already parsed URLs.            const 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'}');            write!(                out,                "{}",                percent_encoding::percent_encode(self.path.as_ref(), PATH_ENCODE_SET)            )?;        } else {            out.write_all(&self.path)?;        }        Ok(())    }     fn host_needs_brackets(&self) -> bool {        fn is_ipv6(h: &str) -> bool {            h.contains(':') && !h.starts_with('[')        }        self.host.as_ref().is_some_and(|h| is_ipv6(h))    }     fn write_alternative_form_to(&self, out: &mut dyn std::io::Write) -> std::io::Result<()> {        let needs_brackets = self.host_needs_brackets();

Lines 1–500 of 598.