svn 0.1.8

Async Rust SVN client for Subversion svn://, svn+ssh://, and ra_svn workflows.
Documentation
use super::*;

impl RaSvnConnection {
    pub(crate) async fn handle_auth_request_initial(&mut self) -> Result<(), SvnError> {
        let auth_req = self.read_command_response().await?;
        self.handle_auth_request_response(&auth_req).await
    }

    pub(crate) async fn handle_auth_request(&mut self) -> Result<(), SvnError> {
        let auth_req = self.read_command_response().await?;
        self.handle_auth_request_response(&auth_req).await
    }

    async fn handle_auth_request_response(
        &mut self,
        auth_req: &CommandResponse,
    ) -> Result<(), SvnError> {
        if auth_req.is_failure() {
            if let Some(first) = auth_req.errors.first().and_then(|e| e.as_list())
                && first.len() >= 4
            {
                let code = first[0].as_u64().unwrap_or_default();
                let message = first[1]
                    .as_string()
                    .unwrap_or_else(|| "<non-utf8>".to_string());
                let file = first[2]
                    .as_string()
                    .unwrap_or_else(|| "<non-utf8>".to_string());
                let line = first[3].as_u64().unwrap_or_default();
                debug!(code, message = %message, file = %file, line, "auth-request failed");
            }
            debug!(
                message = %auth_req.failure_message(),
                "auth-request command response is failure"
            );
        }
        let params = auth_req.success_params("auth-request")?;
        if params.len() < 2 {
            return Err(SvnError::Protocol("auth-request params too short".into()));
        }
        let mechs = params[0]
            .as_list()
            .ok_or_else(|| SvnError::Protocol("auth mechs not a list".into()))?;
        if mechs.is_empty() {
            debug!("auth-request has empty mechanism list (no auth required)");
            return Ok(());
        }
        let realm = params[1]
            .as_string()
            .unwrap_or_else(|| "<unknown>".to_string());
        debug!(realm = %realm, "server requires authentication");

        let mech_words: Vec<String> = mechs.into_iter().filter_map(|m| m.as_word()).collect();
        debug!(mechs = ?mech_words, "auth mechanisms offered");

        match self.handle_auth_request_builtin(&mech_words).await {
            Ok(()) => Ok(()),
            Err(builtin_err) => {
                #[cfg(feature = "cyrus-sasl")]
                {
                    if matches!(
                        &builtin_err,
                        SvnError::AuthUnavailable | SvnError::AuthFailed(_)
                    ) {
                        match self.handle_auth_request_cyrus_sasl(&mech_words).await {
                            Ok(()) => Ok(()),
                            Err(SvnError::AuthUnavailable) => Err(builtin_err),
                            Err(err) => Err(err),
                        }
                    } else {
                        Err(builtin_err)
                    }
                }

                #[cfg(not(feature = "cyrus-sasl"))]
                {
                    Err(builtin_err)
                }
            }
        }
    }

    async fn handle_auth_request_builtin(&mut self, mechs: &[String]) -> Result<(), SvnError> {
        let mechs_to_try = self.select_mechs(mechs)?;
        let mut last_failure = None::<String>;

        for (mech, initial) in mechs_to_try {
            debug!(mech = %mech, "trying auth mechanism");

            let token_tuple = match initial {
                Some(token) => SvnItem::List(vec![SvnItem::String(token)]),
                None => SvnItem::List(Vec::new()),
            };
            self.write_item(&SvnItem::List(vec![
                SvnItem::Word(mech.clone()),
                token_tuple,
            ]))
            .await?;

            loop {
                let challenge = self.read_item().await?;
                let SvnItem::List(parts) = challenge else {
                    return Err(SvnError::Protocol("invalid auth challenge".into()));
                };
                let Some(kind) = parts.first().and_then(|i| i.as_word()) else {
                    return Err(SvnError::Protocol("invalid auth challenge kind".into()));
                };
                match kind.as_str() {
                    "step" => {
                        debug!(mech = %mech, "auth challenge step");
                        let token = parts
                            .get(1)
                            .and_then(|i| i.as_list())
                            .and_then(|list| list.first().and_then(|i| i.as_bytes_string()))
                            .ok_or_else(|| SvnError::Protocol("auth step missing token".into()))?;
                        let reply = self.auth_step_reply(&mech, token)?;
                        self.write_item(&SvnItem::String(reply)).await?;
                    }
                    "success" => return Ok(()),
                    "failure" => {
                        let message = parts
                            .get(1)
                            .and_then(|i| i.as_list())
                            .and_then(|list| list.first().and_then(|i| i.as_string()))
                            .unwrap_or_else(|| "auth failed".to_string());
                        debug!(mech = %mech, message = %message, "auth mechanism failed");
                        last_failure = Some(message);
                        break;
                    }
                    other => {
                        return Err(SvnError::Protocol(format!(
                            "unexpected auth challenge: {other}"
                        )));
                    }
                }
            }
        }

        Err(SvnError::AuthFailed(
            last_failure.unwrap_or_else(|| "auth failed".to_string()),
        ))
    }

    #[cfg(feature = "cyrus-sasl")]
    async fn handle_auth_request_cyrus_sasl(&mut self, mechs: &[String]) -> Result<(), SvnError> {
        let mechstring = if mechs.iter().any(|m| m == "EXTERNAL") {
            "EXTERNAL".to_string()
        } else if mechs.iter().any(|m| m == "ANONYMOUS") {
            "ANONYMOUS".to_string()
        } else {
            mechs.join(" ")
        };

        let mechlist = std::ffi::CString::new(mechstring)
            .map_err(|_| SvnError::Protocol("SASL mech list contains NUL byte".into()))?;
        let mechlist = mechlist.as_c_str();

        let mut sasl = CyrusSasl::new(
            &self.host,
            self.username.as_deref(),
            self.password.as_deref(),
            false,
            self.local_addrport.as_deref(),
            self.remote_addrport.as_deref(),
        )?;

        let (mech, initial, mut rc) = sasl.client_start(mechlist)?;

        let mut initial_token = None;
        if initial.is_some() || mech == "EXTERNAL" {
            let raw = initial.unwrap_or_default();
            initial_token = Some(base64_encode(&raw));
        }

        let token_tuple = match initial_token {
            Some(token) => SvnItem::List(vec![SvnItem::String(token)]),
            None => SvnItem::List(Vec::new()),
        };
        self.write_item(&SvnItem::List(vec![
            SvnItem::Word(mech.clone()),
            token_tuple,
        ]))
        .await?;

        let mut last_status = None::<String>;
        while rc == SASL_CONTINUE {
            let challenge = self.read_item().await?;
            let SvnItem::List(parts) = challenge else {
                return Err(SvnError::Protocol("invalid SASL challenge".into()));
            };
            let Some(kind) = parts.first().and_then(|i| i.as_word()) else {
                return Err(SvnError::Protocol("invalid SASL challenge kind".into()));
            };
            last_status = Some(kind.clone());

            match kind.as_str() {
                "failure" => {
                    let message = parts
                        .get(1)
                        .and_then(|i| i.as_list())
                        .and_then(|list| list.first().and_then(|i| i.as_string()))
                        .unwrap_or_else(|| "auth failed".to_string());
                    return Err(SvnError::AuthFailed(message));
                }
                "success" | "step" => {}
                other => {
                    return Err(SvnError::Protocol(format!(
                        "unexpected SASL challenge: {other}"
                    )));
                }
            }

            let token = parts
                .get(1)
                .and_then(|i| i.as_list())
                .and_then(|list| list.first().and_then(|i| i.as_bytes_string()))
                .ok_or_else(|| SvnError::Protocol("SASL step missing token".into()))?;
            let token = if mech == "CRAM-MD5" {
                token
            } else {
                base64_decode(&token)?
            };

            let (out, next_rc) = sasl.client_step(&token)?;
            rc = next_rc;

            if kind == "success" {
                break;
            }

            let out = out.unwrap_or_default();
            let out = if mech == "CRAM-MD5" {
                out
            } else {
                base64_encode(&out)
            };
            self.write_item(&SvnItem::String(out)).await?;
        }

        if !matches!(last_status.as_deref(), Some("success")) {
            let item = self.read_item().await?;
            let SvnItem::List(parts) = item else {
                return Err(SvnError::Protocol("invalid SASL final response".into()));
            };
            let Some(kind) = parts.first().and_then(|i| i.as_word()) else {
                return Err(SvnError::Protocol("invalid SASL final kind".into()));
            };
            match kind.as_str() {
                "success" => {}
                "failure" => {
                    let message = parts
                        .get(1)
                        .and_then(|i| i.as_list())
                        .and_then(|list| list.first().and_then(|i| i.as_string()))
                        .unwrap_or_else(|| "auth failed".to_string());
                    return Err(SvnError::AuthFailed(message));
                }
                other => {
                    return Err(SvnError::Protocol(format!(
                        "unexpected SASL final response: {other}"
                    )));
                }
            }
        }

        let ssf = sasl.ssf()?;
        if ssf > 0 {
            if self.pos < self.buf.len() {
                let encrypted = self.buf[self.pos..].to_vec();
                self.buf.clear();
                self.pos = 0;

                let decoded = sasl.decode(&encrypted)?;
                self.buf.extend_from_slice(&decoded);
            } else if self.pos > 0 {
                self.buf.clear();
                self.pos = 0;
            }

            self.sasl = Some(Box::new(sasl));
        }
        Ok(())
    }

    fn select_mechs(&self, mechs: &[String]) -> Result<AuthMechanismChoices, SvnError> {
        let has_user = self.username.as_ref().is_some_and(|u| !u.trim().is_empty());
        let has_pass = self.password.as_ref().is_some();

        let mut out = Vec::new();

        if self.is_tunneled && mechs.iter().any(|m| m == "EXTERNAL") {
            out.push(("EXTERNAL".to_string(), Some(Vec::new())));
        }

        if has_user && has_pass && mechs.iter().any(|m| m == "CRAM-MD5") {
            out.push(("CRAM-MD5".to_string(), None));
        }

        if has_user && has_pass && mechs.iter().any(|m| m == "PLAIN") {
            let user = self.username.clone().unwrap_or_default();
            let pass = self.password.clone().unwrap_or_default();
            let mut token = Vec::with_capacity(user.len() + pass.len() + 2);
            token.push(0);
            token.extend_from_slice(user.as_bytes());
            token.push(0);
            token.extend_from_slice(pass.as_bytes());
            out.push(("PLAIN".to_string(), Some(token)));
        }

        if mechs.iter().any(|m| m == "ANONYMOUS") {
            out.push(("ANONYMOUS".to_string(), Some(Vec::new())));
        }

        if out.is_empty() && mechs.iter().any(|m| m == "EXTERNAL") {
            out.push(("EXTERNAL".to_string(), Some(Vec::new())));
        }

        if out.is_empty() {
            Err(SvnError::AuthUnavailable)
        } else {
            Ok(out)
        }
    }

    #[cfg(test)]
    pub(super) fn select_mech(&self, mechs: &[String]) -> Result<AuthMechanismChoice, SvnError> {
        let Some(choice) = self.select_mechs(mechs)?.into_iter().next() else {
            return Err(SvnError::AuthUnavailable);
        };
        Ok(choice)
    }

    pub(super) fn auth_step_reply(
        &self,
        mech: &str,
        challenge: Vec<u8>,
    ) -> Result<Vec<u8>, SvnError> {
        match mech {
            "CRAM-MD5" => {
                let user = self
                    .username
                    .clone()
                    .ok_or_else(|| SvnError::AuthFailed("missing username".into()))?;
                let pass = self
                    .password
                    .clone()
                    .ok_or_else(|| SvnError::AuthFailed("missing password".into()))?;
                let mut mac = Hmac::<Md5>::new_from_slice(pass.as_bytes())
                    .map_err(|_| SvnError::Protocol("failed to create HMAC-MD5".into()))?;
                mac.update(&challenge);
                let digest = mac.finalize().into_bytes();
                let hex = hex::encode(digest);
                Ok(format!("{user} {hex}").into_bytes())
            }
            "PLAIN" | "ANONYMOUS" | "EXTERNAL" => Err(SvnError::Protocol(format!(
                "unexpected auth step for {mech}"
            ))),
            other => Err(SvnError::Protocol(format!(
                "auth step not implemented for {other}"
            ))),
        }
    }
}