apt-swarm 0.6.0

🥸 Experimental p2p gossip network for OpenPGP signature transparency 🥸
Documentation
use crate::args::Args;
use crate::errors::*;
use crate::keyring::Keyring;
use crate::signed::Signed;
use bstr::BString;
use bytes::Bytes;
use sequoia_openpgp::armor;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::io::prelude::*;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use tokio::fs;

#[derive(Debug, PartialEq, Default)]
pub struct Config {
    pub data: ConfigData,
    pub config_path: Option<PathBuf>,
    pub data_path: Option<PathBuf>,
}

impl Config {
    pub async fn load_with_args(args: &Args) -> Result<Self> {
        let (config_path, data) = if let Some(path) = &args.config {
            if path.to_str() == Some("#") {
                debug!("Config loading has been explicitly disabled, using default config");
                (None, ConfigData::default())
            } else {
                let data = ConfigData::load_config_from(path)
                    .await
                    .with_context(|| anyhow!("Failed to load configuration from {:?}", path))?;
                (Some(path.to_owned()), data)
            }
        } else if let Some((path, buf)) = Self::find_config().await {
            debug!("Using configuration from {:?}", path);
            let data = ConfigData::load_config_from_str(&buf)?;
            (Some(path), data)
        } else {
            (None, ConfigData::default())
        };

        Ok(Config {
            data,
            config_path,
            data_path: args.data_path.clone(),
        })
    }

    async fn find_config() -> Option<(PathBuf, String)> {
        for path in [
            Self::default_config_path(),
            Ok("/etc/apt-swarm.conf".into()),
        ]
        .into_iter()
        .flatten()
        {
            match fs::read_to_string(&path).await {
                Ok(buf) => return Some((path, buf)),
                Err(err) => {
                    debug!("Attempt to read config from {path:?} failed: {err:#}");
                }
            }
        }

        None
    }

    pub fn apt_swarm_path(&self) -> Result<Cow<'_, PathBuf>> {
        let path = if let Some(path) = &self.data_path {
            Cow::Borrowed(path)
        } else {
            let data_dir = dirs::data_dir().context("Failed to detect data directory")?;
            let path = data_dir.join("apt-swarm");
            Cow::Owned(path)
        };

        Ok(path)
    }

    pub fn database_path(&self) -> Result<PathBuf> {
        let data_dir = self.apt_swarm_path()?;
        let path = data_dir.join("storage");
        Ok(path)
    }

    pub fn database_migrate_path(&self) -> Result<PathBuf> {
        let data_dir = self.apt_swarm_path()?;
        let path = data_dir.join("storage~");
        Ok(path)
    }

    pub fn database_delete_path(&self) -> Result<PathBuf> {
        let data_dir = self.apt_swarm_path()?;
        let path = data_dir.join("storage=");
        Ok(path)
    }

    pub fn db_socket_path(&self) -> Result<PathBuf> {
        let data_dir = self.apt_swarm_path()?;
        let path = data_dir.join("db.sock");
        Ok(path)
    }

    fn default_config_path() -> Result<PathBuf> {
        let config_dir = dirs::config_dir().context("Failed to detect config directory")?;
        let path = config_dir.join("apt-swarm.conf");
        Ok(path)
    }

    pub fn peerdb_path(&self) -> Result<PathBuf> {
        let data_dir = self.apt_swarm_path()?;
        let path = data_dir.join("peerdb.json");
        Ok(path)
    }

    pub fn peerdb_new_path(&self) -> Result<PathBuf> {
        let data_dir = self.apt_swarm_path()?;
        let path = data_dir.join("peerdb.json-");
        Ok(path)
    }
}

#[derive(Debug, PartialEq, Default, Serialize, Deserialize)]
pub struct ConfigData {
    #[serde(default)]
    pub system: System,
    #[serde(rename = "repository", default)]
    pub repositories: Vec<Repository>,
}

impl ConfigData {
    pub fn load_config_from_str(buf: &str) -> Result<Self> {
        let config = toml::from_str(buf)?;
        Ok(config)
    }

    pub async fn load_config_from(path: &Path) -> Result<Self> {
        let buf = fs::read_to_string(&path).await?;
        Self::load_config_from_str(&buf)
    }
}

#[derive(Debug, PartialEq, Default, Clone, Serialize, Deserialize)]
pub struct System {
    #[serde(default)]
    pub announce: Vec<SocketAddr>,
}

#[derive(Debug, PartialEq, Default, Clone, Serialize, Deserialize)]
pub struct Repository {
    #[serde(default)]
    pub urls: Vec<UrlSource>,
    pub keyring: String,
}

impl Repository {
    pub fn contains_fingerprint(&self, fingerprints: &[sequoia_openpgp::Fingerprint]) -> bool {
        let Ok(keyring) = Keyring::new(self.keyring.as_bytes()) else {
            return false;
        };
        fingerprints.iter().any(|fp| keyring.get(fp).is_some())
    }
}

#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum UrlSource {
    Url(String),
    Detached { content: String, sig: String },
}

impl UrlSource {
    pub async fn fetch(&self, client: &reqwest::Client) -> Result<Vec<Signed>> {
        match self {
            UrlSource::Url(url) => {
                let body = Self::fetch_data(client, url).await?;

                let signed = Signed::find_all_in_text(&body)
                    .context("Failed to parse signed message from http response")?;

                if signed.is_empty() {
                    bail!("Failed to find any signed data in http response");
                }

                Ok(signed)
            }
            UrlSource::Detached { content, sig } => {
                let content = Self::fetch_data(client, content).await?;
                if !content.ends_with(b"\n") {
                    bail!("Detached signatures are currently only supported if the signed data ends with a newline");
                }
                let sig = Self::fetch_data(client, sig).await?;

                let mut reader = armor::Reader::from_bytes(
                    &sig,
                    armor::ReaderMode::Tolerant(Some(armor::Kind::Signature)),
                );

                let mut signature = Vec::new();
                reader.read_to_end(&mut signature)?;

                Ok(vec![Signed {
                    content: BString::new(content.into()),
                    signature,
                }])
            }
        }
    }

    async fn fetch_data(client: &reqwest::Client, url: &str) -> Result<Bytes> {
        info!("Fetching url {:?}...", url);
        let r = client
            .get(url)
            .send()
            .await
            .context("Failed to send request")?
            .error_for_status()
            .context("Received http error")?;
        let body = r
            .bytes()
            .await
            .context("Failed to download http response")?;
        Ok(body)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};

    const KEYRING: &str = "-----BEGIN PGP PUBLIC KEY BLOCK-----

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
N+O606NOXLwcmq5KZL0g
=zyVo
-----END PGP PUBLIC KEY BLOCK-----
";

    #[test]
    fn test_contains_fingerprint() {
        let repo = Repository {
            urls: vec![],
            keyring: KEYRING.to_string(),
        };

        // Test with matching fingerprint
        let fingerprints = vec!["DBA36B5181D0C816F630E889D980A17457F6FB06"
            .parse::<sequoia_openpgp::Fingerprint>()
            .unwrap()];
        assert!(repo.contains_fingerprint(&fingerprints));

        // Test with non-matching fingerprint (Debian bookworm keys)
        let fingerprints = vec![
            "4D64FEC119C2029067D6E791F8D2585B8783D481"
                .parse::<sequoia_openpgp::Fingerprint>()
                .unwrap(),
            "05AB90340C0C5E797F44A8C8254CF3B5AEC0A8F0"
                .parse::<sequoia_openpgp::Fingerprint>()
                .unwrap(),
        ];
        assert!(!repo.contains_fingerprint(&fingerprints));

        // Test with matching and non-matching fingerprints
        let fingerprints = vec![
            "4D64FEC119C2029067D6E791F8D2585B8783D481"
                .parse::<sequoia_openpgp::Fingerprint>()
                .unwrap(),
            "DBA36B5181D0C816F630E889D980A17457F6FB06"
                .parse::<sequoia_openpgp::Fingerprint>()
                .unwrap(),
            "05AB90340C0C5E797F44A8C8254CF3B5AEC0A8F0"
                .parse::<sequoia_openpgp::Fingerprint>()
                .unwrap(),
        ];
        assert!(repo.contains_fingerprint(&fingerprints));

        // Test with an empty fingerprint list
        assert!(!repo.contains_fingerprint(&[]));
    }

    #[test]
    fn test_parse_empty_config() {
        let buf = "";
        let config = ConfigData::load_config_from_str(buf).unwrap();
        assert_eq!(config, ConfigData::default());
    }

    #[test]
    fn test_parse_empty_system_config() {
        let buf = "[system]";
        let config = ConfigData::load_config_from_str(buf).unwrap();
        assert_eq!(config, ConfigData::default());
    }

    #[test]
    fn test_parse_system_config_announce() {
        let buf = r#"
        [system]
        announce = ["[2001:db8::1]:1337", "192.168.0.1:16169"]
        "#;
        let config = ConfigData::load_config_from_str(buf).unwrap();
        assert_eq!(
            config,
            ConfigData {
                system: System {
                    announce: vec![
                        SocketAddr::new(
                            IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)),
                            1337
                        ),
                        SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1)), 16169),
                    ],
                },
                ..Default::default()
            }
        );
    }
}