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