dax_service_forgejo 0.1.0

Forgejo service definition for dax-rs
Documentation
// SPDX-FileCopyrightText: 2024 Yarmo Mackenbach
//
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT

use std::str::FromStr;

use dax_core::{
    claim::{Claim, ClaimMatchingError, ClaimVerificationError, ClaimVerificationResult},
    fetcher::FetchedData,
    proof::{find_proof_in_str, Proof, ProofRelation},
    serde_json::Value,
    service::{Service, ServiceInfo},
};
use dax_fetcher_http::{HttpFetcher, Url};
use regex::Regex;

#[cfg(not(test))]
static REGEX_STR: &str = r"^https:\/\/(.*)\/(.*)\/(.*)\/?";
#[cfg(test)]
static REGEX_STR: &str = r"^http:\/\/(.*)\/(.*)\/(.*)\/?";

/// Forgejo service
pub struct ForgejoService {
    info: ServiceInfo,
    re: Result<Regex, regex::Error>,
}

impl Service for ForgejoService {
    fn new() -> Self {
        ForgejoService {
            info: ServiceInfo {
                name: String::from("Forgejo"),
                id: String::from("forgejo"),
                homepage: String::from("forgejo"),
            },
            re: Regex::new(REGEX_STR),
        }
    }

    fn info(&self) -> &ServiceInfo {
        &self.info
    }

    fn match_claim(&self, claim: &Claim) -> Result<bool, ClaimMatchingError> {
        let re = self
            .re
            .as_ref()
            .map_err(|_| ClaimMatchingError::ClaimProcessing)?;
        Ok(re.is_match(&claim.uri))
    }

    fn verify_claim(
        &self,
        claim: &Claim,
        proofs: &[Proof],
    ) -> Result<ClaimVerificationResult, ClaimVerificationError> {
        // Process the claim
        let re = self
            .re
            .as_ref()
            .map_err(|_| ClaimVerificationError::ClaimProcessing)?;
        let caps = re
            .captures(&claim.uri)
            .ok_or(ClaimVerificationError::ServiceMismatch)?;

        let domain = caps
            .get(1)
            .ok_or(ClaimVerificationError::ClaimProcessing)?
            .as_str();
        let username = caps
            .get(2)
            .ok_or(ClaimVerificationError::ClaimProcessing)?
            .as_str();
        let repo = caps
            .get(3)
            .ok_or(ClaimVerificationError::ClaimProcessing)?
            .as_str();

        // Construct the URL
        #[cfg(not(test))]
        let scheme = "https";
        #[cfg(test)]
        let scheme = "http";

        let url =
            Url::from_str(format!("{scheme}://{domain}/api/v1/repos/{username}/{repo}").as_str())
                .map_err(|_| ClaimVerificationError::ClaimProcessing)?;

        let fetcher = HttpFetcher {};
        let result = fetcher.fetch_json(url)?;

        // Search through the JSON
        let data: Value = if let FetchedData::Json(data) = result {
            data
        } else {
            return Err(ClaimVerificationError::ProofProcessing);
        };

        let description = data
            .get("description")
            .ok_or(ClaimVerificationError::ProofProcessing)?;
        let description = description
            .as_str()
            .ok_or(ClaimVerificationError::ProofProcessing)?;

        Ok(ClaimVerificationResult {
            result: find_proof_in_str(proofs, description, ProofRelation::Contains),
        })
    }
}

#[cfg(test)]
mod tests {
    use dax_core::{
        claim::{Claim, ClaimVerificationError, ClaimVerificationResult},
        proof::Proof,
        serde_json::{json, Value},
        service::Service,
    };
    use httpmock::prelude::*;
    use once_cell::sync::Lazy;

    use super::ForgejoService;

    static API_RESPONSE: Lazy<Value> = Lazy::new(
        || json!({ "description": "My key fingerprint is openpgp4fpr:1234567890123456789012345678901234567890" }),
    );

    fn run_verification(address: String, proof_str: &str) -> bool {
        let claim = Claim::new(&format!("http://{address}/username/repo"));
        let proofs = vec![Proof::new(proof_str)];
        let service: Box<dyn Service> = Box::new(ForgejoService::new());
        let result: Result<ClaimVerificationResult, ClaimVerificationError> =
            service.verify_claim(&claim, &proofs);
        result.unwrap_or_default().result
    }

    #[test]
    fn match_forgejo_claims() {
        // Prepare
        let service: Box<dyn Service> = Box::new(ForgejoService::new());

        // Assert
        assert!(service
            .match_claim(&Claim::new("http://domain.org/alice/forgejo_proof"))
            .unwrap());
        assert!(service
            .match_claim(&Claim::new("http://domain.org/alice/forgejo_proof/"))
            .unwrap());
        assert!(service
            .match_claim(&Claim::new("http://domain.org/alice/other_proof"))
            .unwrap());
        assert!(!service
            .match_claim(&Claim::new("http://domain.org/alice"))
            .unwrap());
    }

    #[test]
    fn verify_forgejo_claim() {
        // Prepare
        let server = MockServer::start();

        let m = server.mock(|when, then| {
            when.method(GET).path("/api/v1/repos/username/repo");

            then.status(200)
                .header("content-type", "application/json")
                .json_body(API_RESPONSE.clone());
        });

        // Verify
        let result_valid = run_verification(
            server.address().to_string(),
            "openpgp4fpr:1234567890123456789012345678901234567890",
        );
        let result_invalid = run_verification(
            server.address().to_string(),
            "openpgp4fpr:abcdabcdabcdabcdabcdabcdabcdabcdabcdabcd",
        );

        // Assert
        assert!(m.hits() > 0);
        assert!(result_valid);
        assert!(!result_invalid);
    }
}