pray_core/
ssh_publishers.rs1use 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}