nixfleet_control_plane/
rollouts_source.rs

1//! On-demand HTTP-fetched rollout manifests. This module is a thin
2//! signed-pair fetcher: it substitutes the canonical RolloutId
3//! (`{channel}@{channel_ref}` per RFC-0008 ยง6.3) into the URL templates
4//! and returns the raw (manifest, signature) byte pair. It performs no
5//! identifier validation. The caller (manifest_poll) is responsible for
6//! signature verification (`verify_rollout_manifest`) and identifier
7//! discrimination (parsed `RolloutId` equality against the requested id);
8//! both checks are mandated by the `verify_rollout_manifest` docstring.
9
10use std::path::PathBuf;
11use std::time::Duration;
12
13use anyhow::{Context, Result, anyhow};
14
15use crate::polling::signed_fetch;
16
17pub const ROLLOUT_ID_PLACEHOLDER: &str = "{rolloutId}";
18
19#[derive(Debug, Clone)]
20pub struct RolloutsSource {
21    /// Must contain `{rolloutId}`.
22    pub artifact_url_template: String,
23    /// Must contain `{rolloutId}`.
24    pub signature_url_template: String,
25    /// `None` -> unauthenticated GET.
26    pub token_file: Option<PathBuf>,
27    pub timeout: Duration,
28}
29
30impl RolloutsSource {
31    /// Bails if either template lacks the placeholder.
32    pub fn new(
33        artifact_url_template: String,
34        signature_url_template: String,
35        token_file: Option<PathBuf>,
36    ) -> Result<Self> {
37        if !artifact_url_template.contains(ROLLOUT_ID_PLACEHOLDER) {
38            return Err(anyhow!(
39                "rollouts source: artifact_url_template must contain {ROLLOUT_ID_PLACEHOLDER}"
40            ));
41        }
42        if !signature_url_template.contains(ROLLOUT_ID_PLACEHOLDER) {
43            return Err(anyhow!(
44                "rollouts source: signature_url_template must contain {ROLLOUT_ID_PLACEHOLDER}"
45            ));
46        }
47        Ok(Self {
48            artifact_url_template,
49            signature_url_template,
50            token_file,
51            timeout: Duration::from_secs(15),
52        })
53    }
54
55    /// Substitutes `rollout_id` into the URL templates and returns the
56    /// (manifest, signature) byte pair. Performs no identifier validation;
57    /// the caller is contractually required to invoke
58    /// `verify_rollout_manifest` (authenticity) and then assert that the
59    /// parsed manifest's `RolloutId::new(&m.channel, &m.channel_ref)` equals
60    /// the `rollout_id` passed here (identity-substitution defense).
61    pub async fn fetch_pair(&self, rollout_id: &str) -> Result<(Vec<u8>, Vec<u8>)> {
62        let artifact_url = self
63            .artifact_url_template
64            .replace(ROLLOUT_ID_PLACEHOLDER, rollout_id);
65        let signature_url = self
66            .signature_url_template
67            .replace(ROLLOUT_ID_PLACEHOLDER, rollout_id);
68
69        let token = signed_fetch::read_token(self.token_file.as_deref())?;
70        let client = reqwest::Client::builder()
71            .use_rustls_tls()
72            .timeout(self.timeout)
73            .build()
74            .context("build rollouts-source client")?;
75
76        signed_fetch::fetch_signed_pair(&client, &artifact_url, &signature_url, token.as_deref())
77            .await
78            .with_context(|| format!("fetch rollout pair for {rollout_id}"))
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn new_rejects_template_without_placeholder() {
88        let err = RolloutsSource::new(
89            "https://example/no-placeholder.json".to_string(),
90            "https://example/no-placeholder.json.sig".to_string(),
91            None,
92        )
93        .unwrap_err();
94        assert!(err.to_string().contains(ROLLOUT_ID_PLACEHOLDER));
95    }
96
97    #[test]
98    fn new_rejects_signature_template_without_placeholder() {
99        let err = RolloutsSource::new(
100            format!("https://example/{ROLLOUT_ID_PLACEHOLDER}.json"),
101            "https://example/no-placeholder.json.sig".to_string(),
102            None,
103        )
104        .unwrap_err();
105        assert!(err.to_string().contains("signature_url_template"));
106    }
107
108    #[test]
109    fn new_accepts_valid_templates() {
110        let s = RolloutsSource::new(
111            format!("https://example/rollouts/{ROLLOUT_ID_PLACEHOLDER}.json"),
112            format!("https://example/rollouts/{ROLLOUT_ID_PLACEHOLDER}.json.sig"),
113            Some(PathBuf::from("/run/agenix/token")),
114        )
115        .unwrap();
116        assert!(s.artifact_url_template.contains(ROLLOUT_ID_PLACEHOLDER));
117        assert!(s.signature_url_template.contains(ROLLOUT_ID_PLACEHOLDER));
118        assert_eq!(
119            s.token_file.as_deref(),
120            Some(std::path::Path::new("/run/agenix/token"))
121        );
122    }
123}