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