Skip to main content

alien_core/
embedded_config.rs

1//! Embedded configuration support for alien-deploy-cli and alien-agent binaries.
2//!
3//! The package builder appends a JSON-encoded config struct to the end of the
4//! binary, followed by a 4-byte little-endian length and 8-byte magic trailer.
5//! This allows a single binary to be customized per deployment group without
6//! recompilation.
7
8use serde::{de::DeserializeOwned, Deserialize, Serialize};
9
10/// Magic bytes at the end of a binary with embedded config.
11pub const MAGIC_BYTES: &[u8; 8] = b"ALIENCFG";
12
13/// Size of the footer: 4 bytes (length) + 8 bytes (magic).
14pub const FOOTER_SIZE: usize = 12;
15
16/// Configuration embedded in alien-deploy-cli binaries.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct DeployCliConfig {
20    // --- Connection (for pre-configured/OSS binaries) ---
21    /// Manager URL to connect to.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub manager_url: Option<String>,
24    /// Authentication token for the manager API.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub token: Option<String>,
27    /// Deployment group ID.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub deployment_group_id: Option<String>,
30    /// Default platform for deployments.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub default_platform: Option<String>,
33    // --- Branding (for white-labeled SaaS binaries) ---
34    /// Binary name (e.g., "acme-deploy").
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub name: Option<String>,
37    /// Human-friendly display name (e.g., "Acme Deploy CLI").
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub display_name: Option<String>,
40}
41
42/// Configuration embedded in alien-agent binaries.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub struct AgentConfig {
46    // --- Connection (for pre-configured/OSS binaries) ---
47    /// Manager URL to connect to.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub manager_url: Option<String>,
50    /// Authentication token for the manager API.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub token: Option<String>,
53    /// Deployment ID this agent manages.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub deployment_id: Option<String>,
56    /// Sync interval in seconds (default: 30).
57    #[serde(default = "default_sync_interval")]
58    pub sync_interval_secs: u64,
59    // --- Branding (for white-labeled SaaS binaries) ---
60    /// Binary name (e.g., "acme-agent").
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub name: Option<String>,
63    /// Human-friendly display name (e.g., "Acme Agent").
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub display_name: Option<String>,
66}
67
68fn default_sync_interval() -> u64 {
69    30
70}
71
72/// Load embedded configuration from the current binary.
73///
74/// Reads the binary's own file, checks for the magic trailer, extracts the
75/// JSON payload, and deserializes it into the requested type.
76///
77/// Returns `None` if the binary has no embedded config (no magic trailer).
78pub fn load_embedded_config<T: DeserializeOwned>() -> Result<Option<T>, EmbeddedConfigError> {
79    let exe_path = std::env::current_exe().map_err(EmbeddedConfigError::Io)?;
80    load_embedded_config_from_path(&exe_path)
81}
82
83/// Load embedded configuration from a specific binary path.
84pub fn load_embedded_config_from_path<T: DeserializeOwned>(
85    path: &std::path::Path,
86) -> Result<Option<T>, EmbeddedConfigError> {
87    let data = std::fs::read(path).map_err(EmbeddedConfigError::Io)?;
88
89    if data.len() < FOOTER_SIZE {
90        return Ok(None);
91    }
92
93    // Check magic bytes at the end
94    let magic_start = data.len() - MAGIC_BYTES.len();
95    if &data[magic_start..] != MAGIC_BYTES {
96        return Ok(None);
97    }
98
99    // Read the 4-byte little-endian length before the magic
100    let len_start = magic_start - 4;
101    let len_bytes: [u8; 4] = data[len_start..magic_start]
102        .try_into()
103        .map_err(|_| EmbeddedConfigError::InvalidFormat("invalid length bytes".into()))?;
104    let json_len = u32::from_le_bytes(len_bytes) as usize;
105
106    if json_len == 0 || len_start < json_len {
107        return Err(EmbeddedConfigError::InvalidFormat(
108            "config length exceeds binary size".into(),
109        ));
110    }
111
112    let json_start = len_start - json_len;
113    let json_bytes = &data[json_start..len_start];
114
115    let config: T =
116        serde_json::from_slice(json_bytes).map_err(EmbeddedConfigError::Deserialization)?;
117
118    Ok(Some(config))
119}
120
121/// Append embedded configuration to a binary.
122///
123/// Writes: original binary bytes + JSON payload + 4-byte LE length + magic bytes.
124pub fn append_embedded_config<T: Serialize>(
125    binary_data: &[u8],
126    config: &T,
127) -> Result<Vec<u8>, EmbeddedConfigError> {
128    let json_bytes = serde_json::to_vec(config).map_err(EmbeddedConfigError::Deserialization)?;
129    let json_len = json_bytes.len() as u32;
130
131    let mut result = Vec::with_capacity(binary_data.len() + json_bytes.len() + FOOTER_SIZE);
132    result.extend_from_slice(binary_data);
133    result.extend_from_slice(&json_bytes);
134    result.extend_from_slice(&json_len.to_le_bytes());
135    result.extend_from_slice(MAGIC_BYTES);
136
137    Ok(result)
138}
139
140/// Errors from embedded config operations.
141#[derive(Debug)]
142pub enum EmbeddedConfigError {
143    Io(std::io::Error),
144    InvalidFormat(String),
145    Deserialization(serde_json::Error),
146}
147
148impl std::fmt::Display for EmbeddedConfigError {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        match self {
151            Self::Io(e) => write!(f, "IO error reading embedded config: {}", e),
152            Self::InvalidFormat(msg) => write!(f, "invalid embedded config format: {}", msg),
153            Self::Deserialization(e) => write!(f, "failed to deserialize embedded config: {}", e),
154        }
155    }
156}
157
158impl std::error::Error for EmbeddedConfigError {}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn test_roundtrip_deploy_cli_config() {
166        let config = DeployCliConfig {
167            manager_url: Some("https://manager.example.com".into()),
168            token: Some("tok_abc123".into()),
169            deployment_group_id: Some("dg_xyz".into()),
170            display_name: Some("Production".into()),
171            default_platform: Some("aws".into()),
172            name: Some("acme-deploy".into()),
173        };
174
175        let binary = b"fake binary content";
176        let embedded = append_embedded_config(binary, &config).unwrap();
177
178        let loaded: Option<DeployCliConfig> =
179            load_embedded_config_from_path_bytes(&embedded).unwrap();
180        let loaded = loaded.unwrap();
181
182        assert_eq!(loaded.manager_url, config.manager_url);
183        assert_eq!(loaded.token, config.token);
184        assert_eq!(loaded.deployment_group_id, config.deployment_group_id);
185        assert_eq!(loaded.display_name, config.display_name);
186        assert_eq!(loaded.name, config.name);
187    }
188
189    #[test]
190    fn test_no_embedded_config() {
191        let binary = b"just a regular binary";
192        let result: Option<DeployCliConfig> =
193            load_embedded_config_from_path_bytes(binary).unwrap();
194        assert!(result.is_none());
195    }
196
197    #[test]
198    fn test_roundtrip_agent_config() {
199        let config = AgentConfig {
200            manager_url: Some("https://manager.example.com".into()),
201            token: Some("tok_agent123".into()),
202            deployment_id: Some("dep_abc".into()),
203            sync_interval_secs: 60,
204            name: Some("acme-agent".into()),
205            display_name: Some("Acme Agent".into()),
206        };
207
208        let binary = b"agent binary";
209        let embedded = append_embedded_config(binary, &config).unwrap();
210
211        let loaded: Option<AgentConfig> =
212            load_embedded_config_from_path_bytes(&embedded).unwrap();
213        let loaded = loaded.unwrap();
214
215        assert_eq!(loaded.manager_url, config.manager_url);
216        assert_eq!(loaded.deployment_id, config.deployment_id);
217        assert_eq!(loaded.sync_interval_secs, 60);
218        assert_eq!(loaded.name, config.name);
219    }
220
221    /// Helper that works on in-memory bytes (for tests that don't need files).
222    fn load_embedded_config_from_path_bytes<T: DeserializeOwned>(
223        data: &[u8],
224    ) -> Result<Option<T>, EmbeddedConfigError> {
225        if data.len() < FOOTER_SIZE {
226            return Ok(None);
227        }
228
229        let magic_start = data.len() - MAGIC_BYTES.len();
230        if &data[magic_start..] != MAGIC_BYTES {
231            return Ok(None);
232        }
233
234        let len_start = magic_start - 4;
235        let len_bytes: [u8; 4] = data[len_start..magic_start]
236            .try_into()
237            .map_err(|_| EmbeddedConfigError::InvalidFormat("invalid length bytes".into()))?;
238        let json_len = u32::from_le_bytes(len_bytes) as usize;
239
240        if json_len == 0 || len_start < json_len {
241            return Err(EmbeddedConfigError::InvalidFormat(
242                "config length exceeds binary size".into(),
243            ));
244        }
245
246        let json_start = len_start - json_len;
247        let json_bytes = &data[json_start..len_start];
248
249        let config: T =
250            serde_json::from_slice(json_bytes).map_err(EmbeddedConfigError::Deserialization)?;
251
252        Ok(Some(config))
253    }
254}