libdav 0.10.3

CalDAV and CardDAV client implementations.
Documentation
// Copyright 2024 Hugo Osvaldo Barrera
//
// SPDX-License-Identifier: ISC

//! Service discovery helpers to perform automated client bootstrapping.
//!
//! Tools in this module allows finding the exact location of a CalDAV or CardDAV server. The
//! process involves a few steps:
//!
//! - Find the exact server using SRV Service Labels. See [`resolve_srv_record`].
//! - Find the context path via TXT records or the well-known URI. See [`find_context_url`]
//! - Find the current user's principal resource. See [`WebDavClient::find_current_user_principal`].
//!
//! # See also
//!
//! - [`crate::CalDavClient::bootstrap_via_service_discovery`]
//! - [`crate::CardDavClient::bootstrap_via_service_discovery`]
//! - <https://www.rfc-editor.org/rfc/rfc6764>

use std::io;

use domain::{
    base::{
        Name, Question, RelativeName, Rtype, ToName, ToRelativeName, name::LongChainError,
        wire::ParseError,
    },
    rdata::Txt,
    resolv::{StubResolver, lookup::srv::SrvError},
};
use http::{
    Response, Uri,
    uri::{InvalidUri, PathAndQuery, Scheme},
};
use hyper::body::Incoming;
use log::{info, warn};
use tower_service::Service;

use crate::{
    CheckSupportError,
    common::ServiceForUrlError,
    dav::{CheckSupport, WebDavClient},
};

/// Error type for [`find_context_url`].
#[derive(thiserror::Error, Debug)]
pub enum ContextUrlError {
    /// The provided URL is missing a host component.
    #[error("missing host in input URL")]
    MissingHost,

    /// Host in provided URL is not a valid domain name.
    #[error("host in input URL is not a valid domain: {0}")]
    InvalidDomain(domain::base::name::FromStrError),

    /// DNS error resolving SRV record.
    #[error("resolving DNS SRV records: {0}")]
    DnsError(SrvError),

    /// DNS error resolving TXT record.
    #[error("resolving context path via TXT records: {0}")]
    TxtError(TxtError),

    /// The service is decidedly not available.
    ///
    /// See <https://www.rfc-editor.org/rfc/rfc2782>, page 4
    #[error("the service is decidedly not available")]
    NotAvailable,

    /// The SRV record returned domain/port pairs that are unusable to build an [`Uri`].
    #[error("SRV records returned domain/port pair that could not be parsed: {0}")]
    UnusableSrv(http::Error),
}

/// Error type for [`crate::CalDavClient::bootstrap_via_service_discovery`] and
/// [`crate::CardDavClient::bootstrap_via_service_discovery`].
#[derive(thiserror::Error, Debug)]
pub enum BootstrapError {
    /// The scheme for the URL is for an unknown service.
    #[error("cannot determine service for this url: {0}")]
    ServiceForUrl(#[from] ServiceForUrlError),

    /// Failed to discovery context URL.
    #[error("discovering context url: {0}")]
    ContextUrl(#[from] ContextUrlError),

    /// No usable context URL was found for this service.
    #[error("no usable URL found for service")]
    NoUsableUrl,
}

/// Return type for the [`find_context_url`] function.
pub enum FindContextUrlResult {
    /// The WebDAV client's `base_url` is a valid context url for the given service.
    BaseUrl,
    /// Found a usable URL via service discovery.
    Found(Uri),
    /// No usable URL found for this service.
    NoneFound,
    /// Non-recoverable error occurred.
    Error(ContextUrlError),
}

/// Find a CalDAV or CardDAV context path via client bootstrap sequence.
///
/// Determines the server's real host and the context path of the resources for a server,
/// following the discovery mechanism described in [rfc6764].
///
/// [rfc6764]: https://www.rfc-editor.org/rfc/rfc6764
///
/// Resolves from "user friendly" URLs to the real URL where the CalDAV or CardDAV server is
/// advertised as running. For example, a user may understand their CalDAV server as being
/// `https://example.com` but bootstrapping would reveal it to actually run under
/// `https://instance31.example.com/users/john@example.com/calendars/`.
///
/// # Errors
///
/// If any of the underlying DNS or HTTP requests fail, or if any of the responses fail to
/// parse.
///
/// Does not return an error if DNS records are missing, only if they contain invalid data.
pub async fn find_context_url<C>(
    client: &WebDavClient<C>,
    service: DiscoverableService,
) -> FindContextUrlResult
where
    C: Service<http::Request<String>, Response = Response<Incoming>> + Sync + Send,
    <C as Service<http::Request<String>>>::Error: std::error::Error + Send + Sync,
{
    // If the initially provided URL reports that it supports CalDAV, use that one.
    match client
        .request(CheckSupport::new(&client.base_url, service.access_field()))
        .await
    {
        Ok(()) => return FindContextUrlResult::BaseUrl,
        Err(err) => info!("Original URL does not report {service} capabilities: {err}"),
    }

    let Some(domain) = client.base_url.host() else {
        return FindContextUrlResult::Error(ContextUrlError::MissingHost);
    };
    let port = client.base_url.port_u16().unwrap_or(service.default_port());

    let dname = match Name::bytes_from_str(domain) {
        Ok(d) => d,
        Err(err) => return FindContextUrlResult::Error(ContextUrlError::InvalidDomain(err)),
    };
    let host_candidates = match resolve_srv_record(service, &dname, port).await {
        Ok(Some(hc)) => hc,
        Ok(None) => return FindContextUrlResult::Error(ContextUrlError::NotAvailable),
        Err(err) => return FindContextUrlResult::Error(ContextUrlError::DnsError(err)),
    };

    let txt_record = match find_context_path_via_txt_records(service, &dname).await {
        Ok(record) => record,
        Err(err) => return FindContextUrlResult::Error(ContextUrlError::TxtError(err)),
    };
    for candidate in &host_candidates {
        if let Some(ref path) = txt_record {
            let test_uri = match Uri::builder()
                .scheme(service.scheme())
                .authority(format!("{}:{}", candidate.0, candidate.1))
                .path_and_query(path.clone())
                .build()
            {
                Ok(uri) => uri,
                Err(err) => return FindContextUrlResult::Error(ContextUrlError::UnusableSrv(err)),
            };

            let result = client
                .request(CheckSupport::new(&test_uri, service.access_field()))
                .await;
            match result {
                Ok(()) => return FindContextUrlResult::Found(test_uri),
                Err(CheckSupportError::NotAdvertised) => {
                    // Server does not advertise support for this protocol.
                    // We ignore this because NextCloud reports a lack of support for
                    // CalDav and CardDav. See https://github.com/nextcloud/server/issues/37374
                    return FindContextUrlResult::Found(test_uri);
                }
                Err(_) => {
                    warn!("Found path that doesn't report {service} capabilities: {test_uri}");
                }
            }
        } else if let Ok(Some(url)) = client
            .find_context_path(service, &candidate.0, candidate.1)
            .await
        {
            return FindContextUrlResult::Found(url);
        }
    }

    FindContextUrlResult::NoneFound
}

/// Services for which automatic discovery is possible.
#[derive(Debug, Clone, Copy)]
pub enum DiscoverableService {
    /// Caldav over HTTPS.
    CalDavs,
    /// Caldav over plain-text HTTP.
    CalDav,
    /// Carddav over HTTPS.
    CardDavs,
    /// Carddav over plain-text HTTP.
    CardDav,
}

impl DiscoverableService {
    /// Relative domain suitable for querying this service type.
    #[must_use]
    #[allow(clippy::missing_panics_doc)]
    pub fn relative_domain(self) -> &'static RelativeName<[u8]> {
        match self {
            DiscoverableService::CalDavs => RelativeName::from_slice(b"\x08_caldavs\x04_tcp"),
            DiscoverableService::CalDav => RelativeName::from_slice(b"\x07_caldav\x04_tcp"),
            DiscoverableService::CardDavs => RelativeName::from_slice(b"\x09_carddavs\x04_tcp"),
            DiscoverableService::CardDav => RelativeName::from_slice(b"\x08_carddav\x04_tcp"),
        }
        .expect("well known relative prefix is valid")
    }

    /// The scheme for this service type (e.g.: HTTP or HTTPS).
    #[must_use]
    pub fn scheme(self) -> Scheme {
        match self {
            DiscoverableService::CalDavs | DiscoverableService::CardDavs => Scheme::HTTPS,
            DiscoverableService::CalDav | DiscoverableService::CardDav => Scheme::HTTP,
        }
    }

    /// The well-known path for context-path discovery.
    #[must_use]
    pub fn well_known_path(self) -> &'static str {
        match self {
            DiscoverableService::CalDavs | DiscoverableService::CalDav => "/.well-known/caldav",
            DiscoverableService::CardDavs | DiscoverableService::CardDav => "/.well-known/carddav",
        }
    }

    /// Default port to use if no port is explicitly provided.
    #[must_use]
    pub fn default_port(self) -> u16 {
        match self {
            DiscoverableService::CalDavs | DiscoverableService::CardDavs => 443,
            DiscoverableService::CalDav | DiscoverableService::CardDav => 80,
        }
    }

    /// Value that must be present in the `DAV:` header when checking for support.
    ///
    /// # See also
    ///
    /// - <https://www.rfc-editor.org/rfc/rfc4791#section-5.1>
    /// - <https://www.rfc-editor.org/rfc/rfc6352#section-6.1>
    #[must_use]
    pub fn access_field(self) -> &'static str {
        match self {
            DiscoverableService::CalDavs | DiscoverableService::CalDav => "calendar-access",
            DiscoverableService::CardDavs | DiscoverableService::CardDav => "addressbook",
        }
    }
}

impl std::fmt::Display for DiscoverableService {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            DiscoverableService::CalDavs => write!(f, "caldavs"),
            DiscoverableService::CalDav => write!(f, "caldav"),
            DiscoverableService::CardDavs => write!(f, "carddavs"),
            DiscoverableService::CardDav => write!(f, "carddav"),
        }
    }
}

/// Resolves SRV to locate the caldav server.
///
/// If the query is successful and the service is available, returns `Ok(Some(_))` with a `Vec` of
/// host/ports, in the order in which they should be tried.
///
/// If the query is successful but the service is decidedly not available, returns `Ok(None)`.
///
/// # Errors
///
/// If the underlying DNS request fails or the SRV record cannot be parsed.
///
/// # See also
///
/// - <https://www.rfc-editor.org/rfc/rfc2782>
/// - <https://www.rfc-editor.org/rfc/rfc6764>
pub async fn resolve_srv_record(
    service: DiscoverableService,
    domain: &impl ToName,
    fallback_port: u16,
) -> Result<Option<Vec<(String, u16)>>, SrvError> {
    Ok(StubResolver::new()
        .lookup_srv(service.relative_domain(), domain, fallback_port)
        .await?
        .map(|found| {
            found
                .into_srvs()
                .map(|entry| (entry.target().to_string(), entry.port()))
                .collect()
        }))
}

/// Error returned by [`find_context_path_via_txt_records`].
#[derive(thiserror::Error, Debug)]
pub enum TxtError {
    /// I/O error performing DNS request.
    #[error("I/O error performing DNS request: {0}")]
    Network(#[from] io::Error),

    /// Domain name is too long.
    #[error("domain name is too long and cannot be queried: {0}")]
    DomainTooLong(#[from] LongChainError),

    /// Error parsing DNS response.
    #[error("parsing DNS response: {0}")]
    ParseError(#[from] ParseError),

    /// DNS response contained data invalid for building a URL path.
    #[error("invalid data in response: {0}")]
    InvalidData(#[from] InvalidUri),

    /// DNS response is missing the expected prefix `path=`.
    #[error("missing expected prefix path= from TXT record.")]
    BadTxt,
}

/// Resolves a context path via TXT records.
///
/// Returns a path where the default context path should be used for a given domain.
/// The domain provided should be in the format of `example.com` or `posteo.de`.
///
/// Returns an empty list of no relevant record was found.
///
/// # Errors
///
/// See [`TxtError`]
///
/// # See also
///
/// <https://www.rfc-editor.org/rfc/rfc6764>
pub async fn find_context_path_via_txt_records(
    service: DiscoverableService,
    domain: impl ToName,
) -> Result<Option<PathAndQuery>, TxtError> {
    let resolver = StubResolver::new();
    let full_domain = service.relative_domain().chain(domain)?;
    let question = Question::new_in(full_domain, Rtype::TXT);

    let response = resolver.query(question).await?;
    let Some(record) = response.answer()?.next() else {
        return Ok(None);
    };
    let Some(parsed_record) = record?.into_record::<Txt<_>>()? else {
        return Ok(None);
    };

    let path_result = parsed_record
        .data()
        .text::<Vec<u8>>()
        .strip_prefix(b"path=")
        .ok_or(TxtError::BadTxt)
        .map(PathAndQuery::try_from)?
        .map_err(TxtError::InvalidData);
    Some(path_result).transpose()
}