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