Sebastian Thielmake fmte22080e3 years agoHistory
288 lines · 10.4 KB · Rust
RawDownload
use std::{    any::Any,    borrow::Cow,    error::Error,    ffi::{OsStr, OsString},    io::Write,    process::{self, Stdio},}; use bstr::{io::BufReadExt, BStr, BString, ByteSlice}; use crate::{    client::{self, git, ssh, MessageKind, RequestWriter, SetServiceResponse, WriteMode},    Protocol, Service,}; // from https://github.com/git/git/blob/20de7e7e4f4e9ae52e6cc7cfaa6469f186ddb0fa/environment.c#L115:L115const ENV_VARS_TO_REMOVE: &[&str] = &[    "GIT_ALTERNATE_OBJECT_DIRECTORIES",    "GIT_CONFIG",    "GIT_CONFIG_PARAMETERS",    "GIT_OBJECT_DIRECTORY",    "GIT_DIR",    "GIT_WORK_TREE",    "GIT_IMPLICIT_WORK_TREE",    "GIT_GRAFT_FILE",    "GIT_INDEX_FILE",    "GIT_NO_REPLACE_OBJECTS",    "GIT_REPLACE_REF_BASE",    "GIT_PREFIX",    "GIT_INTERNAL_SUPER_PREFIX",    "GIT_SHALLOW_FILE",    "GIT_COMMON_DIR",    "GIT_CONFIG_COUNT",]; /// A utility to spawn a helper process to actually transmit data, possibly over `ssh`.////// It can only be instantiated using the local [`connect()`] or [ssh connect][crate::client::ssh::connect()].pub struct SpawnProcessOnDemand {    desired_version: Protocol,    url: git_url::Url,    path: BString,    ssh_cmd: Option<(OsString, ssh::ProgramKind)>,    /// The environment variables to set in the invoked command.    envs: Vec<(&'static str, String)>,    ssh_disallow_shell: bool,    connection: Option<git::Connection<Box<dyn std::io::Read + Send>, process::ChildStdin>>,    child: Option<process::Child>,} impl SpawnProcessOnDemand {    pub(crate) fn new_ssh(        url: git_url::Url,        program: impl Into<OsString>,        path: BString,        ssh_kind: ssh::ProgramKind,        ssh_disallow_shell: bool,        version: Protocol,    ) -> SpawnProcessOnDemand {        SpawnProcessOnDemand {            url,            path,            ssh_cmd: Some((program.into(), ssh_kind)),            envs: Default::default(),            ssh_disallow_shell,            child: None,            connection: None,            desired_version: version,        }    }    fn new_local(path: BString, version: Protocol) -> SpawnProcessOnDemand {        SpawnProcessOnDemand {            url: git_url::Url::from_parts_as_alternative_form(git_url::Scheme::File, None, None, None, path.clone())                .expect("valid url"),            path,            ssh_cmd: None,            envs: (version != Protocol::V1)                .then(|| vec![("GIT_PROTOCOL", format!("version={}", version as usize))])                .unwrap_or_default(),            ssh_disallow_shell: false,            child: None,            connection: None,            desired_version: version,        }    }} impl client::TransportWithoutIO for SpawnProcessOnDemand {    fn set_identity(&mut self, identity: git_sec::identity::Account) -> Result<(), client::Error> {        if self.url.scheme == git_url::Scheme::Ssh {            self.url                .set_user((!identity.username.is_empty()).then_some(identity.username));            Ok(())        } else {            Err(client::Error::AuthenticationUnsupported)        }    }     fn request(        &mut self,        write_mode: WriteMode,        on_into_read: MessageKind,    ) -> Result<RequestWriter<'_>, client::Error> {        self.connection            .as_mut()            .expect("handshake() to have been called first")            .request(write_mode, on_into_read)    }     fn to_url(&self) -> Cow<'_, BStr> {        Cow::Owned(self.url.to_bstring())    }     fn connection_persists_across_multiple_requests(&self) -> bool {        true    }     fn configure(&mut self, _config: &dyn Any) -> Result<(), Box<dyn Error + Send + Sync + 'static>> {        Ok(())    }} struct ReadStdoutFailOnError {    recv: std::sync::mpsc::Receiver<std::io::Error>,    read: std::process::ChildStdout,} fn supervise_stderr(    ssh_kind: ssh::ProgramKind,    stderr: std::process::ChildStderr,    stdout: std::process::ChildStdout,) -> ReadStdoutFailOnError {    impl ReadStdoutFailOnError {        fn swap_err_if_present_in_stderr(&self, wanted: usize, res: std::io::Result<usize>) -> std::io::Result<usize> {            match self.recv.try_recv().ok() {                Some(err) => Err(err),                None => match res {                    Ok(n) if n == wanted => Ok(n),                    Ok(n) => {                        // TODO: fix this                        // When parsing refs this seems to happen legitimately                        // (even though we read packet lines only and should always know exactly how much to read)                        // Maybe this still happens in `read_exact()` as sometimes we just don't get enough bytes                        // despite knowing how many.                        // To prevent deadlock, we have to set a timeout which slows down legitimate parts of the protocol.                        // This code was specifically written to make the `cargo` test-suite pass, and we can reduce                        // the timeouts even more once there is a native ssh transport that is used by `cargo`, it will                        // be able to handle these properly.                        // Alternatively, one could implement something like `read2` to avoid blocking on stderr entirely.                        self.recv                            .recv_timeout(std::time::Duration::from_millis(5))                            .ok()                            .map(Err)                            .unwrap_or(Ok(n))                    }                    Err(err) => Err(self.recv.recv().ok().unwrap_or(err)),                },            }        }    }    impl std::io::Read for ReadStdoutFailOnError {        fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {            let res = self.read.read(buf);            self.swap_err_if_present_in_stderr(buf.len(), res)        }    }     let (send, recv) = std::sync::mpsc::sync_channel(1);    std::thread::Builder::new()        .name("supervise ssh stderr".into())        .stack_size(128 * 1024)        .spawn(move || -> std::io::Result<()> {            let mut process_stderr = std::io::stderr();            for line in std::io::BufReader::new(stderr).byte_lines() {                let line = line?;                match ssh_kind.line_to_err(line.into()) {                    Ok(err) => {                        send.send(err).ok();                    }                    Err(line) => {                        process_stderr.write_all(&line).ok();                        writeln!(&process_stderr).ok();                    }                }            }            Ok(())        })        .expect("named threads with small stack work on all platforms");    ReadStdoutFailOnError { read: stdout, recv }} impl client::Transport for SpawnProcessOnDemand {    fn handshake<'a>(        &mut self,        service: Service,        extra_parameters: &'a [(&'a str, Option<&'a str>)],    ) -> Result<SetServiceResponse<'_>, client::Error> {        let (mut cmd, ssh_kind, cmd_name) = match &self.ssh_cmd {            Some((command, kind)) => (                kind.prepare_invocation(command, &self.url, self.desired_version, self.ssh_disallow_shell)                    .map_err(client::Error::SshInvocation)?                    .stderr(Stdio::piped()),                Some(*kind),                Cow::Owned(command.to_owned()),            ),            None => (                git_command::prepare(service.as_str()).stderr(Stdio::null()),                None,                Cow::Borrowed(OsStr::new(service.as_str())),            ),        };        cmd.stdin = Stdio::piped();        cmd.stdout = Stdio::piped();        let repo_path = if self.ssh_cmd.is_some() {            cmd.args.push(service.as_str().into());            git_quote::single(self.path.as_ref()).to_os_str_lossy().into_owned()        } else {            self.path.to_os_str_lossy().into_owned()        };        cmd.args.push(repo_path);         let mut cmd = std::process::Command::from(cmd);        for env_to_remove in ENV_VARS_TO_REMOVE {            cmd.env_remove(env_to_remove);        }        cmd.envs(std::mem::take(&mut self.envs));         let mut child = cmd.spawn().map_err(|err| client::Error::InvokeProgram {            source: err,            command: cmd_name.into_owned(),        })?;        let stdout: Box<dyn std::io::Read + Send> = match ssh_kind {            Some(ssh_kind) => Box::new(supervise_stderr(                ssh_kind,                child.stderr.take().expect("configured beforehand"),                child.stdout.take().expect("configured"),            )),            None => Box::new(child.stdout.take().expect("stdout configured")),        };        self.connection = Some(git::Connection::new_for_spawned_process(            stdout,            child.stdin.take().expect("stdin configured"),            self.desired_version,            self.path.clone(),        ));        self.child = Some(child);        self.connection            .as_mut()            .expect("connection to be there right after setting it")            .handshake(service, extra_parameters)    }} /// Connect to a locally readable repository at `path` using the given `desired_version`.////// This will spawn a `git` process locally.pub fn connect(    path: impl Into<BString>,    desired_version: Protocol,) -> Result<SpawnProcessOnDemand, std::convert::Infallible> {    Ok(SpawnProcessOnDemand::new_local(path.into(), desired_version))} #[cfg(test)]mod tests {    mod ssh {        mod connect {            use crate::{client::blocking_io::ssh::connect, Protocol};             #[test]            fn path() {                for (url, expected) in [                    ("ssh://host.xy/~/repo", "~/repo"),                    ("ssh://host.xy/~username/repo", "~username/repo"),                    ("user@host.xy:/username/repo", "/username/repo"),                    ("user@host.xy:username/repo", "username/repo"),                    ("user@host.xy:../username/repo", "../username/repo"),                    ("user@host.xy:~/repo", "~/repo"),                ] {                    let url = git_url::parse((*url).into()).expect("valid url");                    let cmd = connect(url, Protocol::V1, Default::default()).expect("parse success");                    assert_eq!(cmd.path, expected, "the path will be substituted by the remote shell");                }            }        }    }}