Skip to main content

pray_core/
ssh_publishers.rs

1use crate::ssh_identity::active_ssh_user_fingerprint;
2use crate::{PrayError, PrayResult};
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::Path;
6
7const SSH_PUBLISHERS_PATH: &str = "v1/ssh_publishers.json";
8
9#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
10pub struct SshPublisherConfig {
11    #[serde(default)]
12    pub publishers: Vec<SshPublisherEntry>,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub struct SshPublisherEntry {
17    pub fingerprint: String,
18    pub id: String,
19    #[serde(default)]
20    pub push: bool,
21}
22
23pub fn read_ssh_publishers(root: &Path) -> PrayResult<Option<SshPublisherConfig>> {
24    let path = root.join(SSH_PUBLISHERS_PATH);
25    let text = match fs::read_to_string(&path) {
26        Ok(text) => text,
27        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
28        Err(error) => return Err(error.into()),
29    };
30    let config: SshPublisherConfig =
31        serde_json::from_str(&text).map_err(|error| PrayError::Parse {
32            kind: "ssh publishers",
33            message: error.to_string(),
34        })?;
35    Ok(Some(config))
36}
37
38pub fn active_ssh_publisher_id() -> Option<String> {
39    active_ssh_user_fingerprint()
40}
41
42pub fn authorize_ssh_push(root: &Path) -> PrayResult<()> {
43    let Some(config) = read_ssh_publishers(root)? else {
44        return Ok(());
45    };
46    if config.publishers.is_empty() {
47        return Ok(());
48    }
49
50    let publisher_id = active_ssh_publisher_id().ok_or_else(|| {
51        PrayError::Resolution(
52            "ssh push requires an SSH user fingerprint (set PRAY_SSH_USER_FINGERPRINT, SSH_USER_FINGERPRINT, or PRAY_SSH_PUBLISHER) when v1/ssh_publishers.json is configured".to_string(),
53        )
54    })?;
55
56    let authorized = config.publishers.iter().any(|entry| {
57        entry.push
58            && (crate::ssh_identity::normalize_identity(&entry.id)
59                == crate::ssh_identity::normalize_identity(&publisher_id)
60                || crate::ssh_identity::normalize_identity(&entry.fingerprint)
61                    == crate::ssh_identity::normalize_identity(&publisher_id))
62    });
63    if authorized {
64        Ok(())
65    } else {
66        Err(PrayError::Resolution(format!(
67            "ssh publisher {publisher_id} is not authorized to push"
68        )))
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use std::io::Write;
76
77    #[test]
78    fn authorize_allows_push_when_publishers_file_missing() {
79        let workspace = std::env::temp_dir().join(format!(
80            "pray-ssh-publishers-missing-{}",
81            std::process::id()
82        ));
83        let _ = fs::remove_dir_all(&workspace);
84        fs::create_dir_all(&workspace).expect("workspace");
85        authorize_ssh_push(&workspace).expect("open mode");
86        let _ = fs::remove_dir_all(&workspace);
87    }
88
89    #[test]
90    fn authorize_requires_publisher_when_config_present() {
91        let workspace =
92            std::env::temp_dir().join(format!("pray-ssh-publishers-gate-{}", std::process::id()));
93        let _ = fs::remove_dir_all(&workspace);
94        fs::create_dir_all(workspace.join("v1")).expect("v1");
95        let mut file = fs::File::create(workspace.join(SSH_PUBLISHERS_PATH)).expect("publishers");
96        writeln!(
97            file,
98            r#"{{"publishers":[{{"fingerprint":"SHA256:abc","id":"team-ci","push":true}}]}}"#
99        )
100        .expect("write publishers");
101
102        let error = authorize_ssh_push(&workspace).expect_err("missing publisher");
103        assert!(error.to_string().contains("SSH user fingerprint"));
104
105        std::env::set_var("PRAY_SSH_USER_FINGERPRINT", "SHA256:abc");
106        authorize_ssh_push(&workspace).expect("authorized fingerprint");
107        std::env::remove_var("PRAY_SSH_USER_FINGERPRINT");
108        let _ = fs::remove_dir_all(&workspace);
109    }
110}