use std::fs;
use std::path::Path;
use dusa_collection_utils::core::errors::{ErrorArrayItem, Errors};
use crate::custom_config::CustomConfig;
use crate::enviornment::definitions::Enviornment_V2;
pub const FIXED_CONFIG_ENTRY: &str = "runtime.toml";
pub const CUSTOM_CONFIG_ENTRY: &str = "custom.json";
pub const ENV_ENTRY: &str = ".env";
pub const DEFAULT_INDEX_REGION_SIZE: u64 = 1_048_576;
fn acai_err(context: &str, err: acai_core::AcaiError) -> ErrorArrayItem {
ErrorArrayItem::new(Errors::GeneralError, format!("{context}: {err}"))
}
fn io_err(context: &str, err: std::io::Error) -> ErrorArrayItem {
ErrorArrayItem::new(Errors::GeneralError, format!("{context}: {err}"))
}
pub fn build_bundle(
output_path: &Path,
fixed: &Enviornment_V2,
custom: &CustomConfig,
env_content: &str,
passphrase: &str,
) -> Result<(), ErrorArrayItem> {
let staging = tempfile::tempdir().map_err(|e| io_err("creating staging directory", e))?;
let fixed_toml = toml::to_string(fixed)
.map_err(|e| ErrorArrayItem::new(Errors::ConfigParsing, e.to_string()))?;
fs::write(staging.path().join(FIXED_CONFIG_ENTRY), fixed_toml)
.map_err(|e| io_err("staging fixed config", e))?;
let custom_json = custom.to_json()?;
fs::write(staging.path().join(CUSTOM_CONFIG_ENTRY), custom_json)
.map_err(|e| io_err("staging custom config", e))?;
fs::write(staging.path().join(ENV_ENTRY), env_content)
.map_err(|e| io_err("staging env content", e))?;
let request = serde_json::json!({
"flags": { "encrypted": true },
"passphrase": passphrase,
"index_region_size": DEFAULT_INDEX_REGION_SIZE,
});
let request_bytes = serde_json::to_vec(&request).map_err(ErrorArrayItem::from)?;
acai_core::build_container_file_from_directory(staging.path(), &request_bytes, output_path)
.map_err(|e| acai_err("building runtime bundle", e))
}
fn read_entry(bundle_path: &Path, entry: &str, passphrase: &str) -> Result<Vec<u8>, ErrorArrayItem> {
let container = fs::read(bundle_path).map_err(|e| io_err("reading bundle file", e))?;
acai_core::read_file(&container, entry, Some(passphrase))
.map_err(|e| acai_err(&format!("reading '{entry}' from bundle"), e))
}
pub fn read_fixed_config(bundle_path: &Path, passphrase: &str) -> Result<Enviornment_V2, ErrorArrayItem> {
let bytes = read_entry(bundle_path, FIXED_CONFIG_ENTRY, passphrase)?;
let text = String::from_utf8(bytes).map_err(ErrorArrayItem::from)?;
toml::from_str(&text).map_err(|e| ErrorArrayItem::new(Errors::ConfigParsing, e.to_string()))
}
pub fn read_custom_config(bundle_path: &Path, passphrase: &str) -> Result<CustomConfig, ErrorArrayItem> {
let bytes = read_entry(bundle_path, CUSTOM_CONFIG_ENTRY, passphrase)?;
let text = String::from_utf8(bytes).map_err(ErrorArrayItem::from)?;
CustomConfig::from_json(&text)
}
pub fn read_env(bundle_path: &Path, passphrase: &str) -> Result<String, ErrorArrayItem> {
let bytes = read_entry(bundle_path, ENV_ENTRY, passphrase)?;
String::from_utf8(bytes).map_err(ErrorArrayItem::from)
}
fn commit_entry(
bundle_path: &Path,
entry: &str,
content: &[u8],
passphrase: &str,
) -> Result<(), ErrorArrayItem> {
let staged = tempfile::NamedTempFile::new().map_err(|e| io_err("staging commit content", e))?;
fs::write(staged.path(), content).map_err(|e| io_err("writing commit content", e))?;
let changeset = acai_core::Changeset {
add: vec![acai_core::AddEntry {
source_path: staged.path().to_path_buf(),
container_path: entry.to_owned(),
}],
remove: Vec::new(),
passphrase: Some(passphrase.to_owned()),
threads: None,
compression_level: None,
compression_extreme: None,
compression_dict_size: None,
fast_compression_level: None,
};
let request_bytes = serde_json::to_vec(&changeset).map_err(ErrorArrayItem::from)?;
acai_core::commit_to_container(bundle_path, &request_bytes)
.map_err(|e| acai_err(&format!("committing '{entry}' to bundle"), e))
}
pub fn commit_fixed_config(
bundle_path: &Path,
fixed: &Enviornment_V2,
passphrase: &str,
) -> Result<(), ErrorArrayItem> {
let toml_text = toml::to_string(fixed)
.map_err(|e| ErrorArrayItem::new(Errors::ConfigParsing, e.to_string()))?;
commit_entry(bundle_path, FIXED_CONFIG_ENTRY, toml_text.as_bytes(), passphrase)
}
pub fn commit_custom_config(
bundle_path: &Path,
custom: &CustomConfig,
passphrase: &str,
) -> Result<(), ErrorArrayItem> {
let json_text = custom.to_json()?;
commit_entry(bundle_path, CUSTOM_CONFIG_ENTRY, json_text.as_bytes(), passphrase)
}
pub fn commit_env(bundle_path: &Path, env_content: &str, passphrase: &str) -> Result<(), ErrorArrayItem> {
commit_entry(bundle_path, ENV_ENTRY, env_content.as_bytes(), passphrase)
}
pub fn unpack_control_plane_config(
bundle_path: &Path,
passphrase: &str,
dest_dir: &Path,
) -> Result<(), ErrorArrayItem> {
fs::create_dir_all(dest_dir).map_err(|e| io_err("creating config directory", e))?;
let fixed = read_entry(bundle_path, FIXED_CONFIG_ENTRY, passphrase)?;
fs::write(dest_dir.join(FIXED_CONFIG_ENTRY), fixed)
.map_err(|e| io_err("writing unpacked fixed config", e))?;
let custom = read_entry(bundle_path, CUSTOM_CONFIG_ENTRY, passphrase)?;
fs::write(dest_dir.join(CUSTOM_CONFIG_ENTRY), custom)
.map_err(|e| io_err("writing unpacked custom config", e))?;
Ok(())
}
pub fn cleanup_unpacked_config(dest_dir: &Path) -> Result<(), ErrorArrayItem> {
for entry in [FIXED_CONFIG_ENTRY, CUSTOM_CONFIG_ENTRY] {
let path = dest_dir.join(entry);
if path.exists() {
fs::remove_file(&path).map_err(|e| io_err("removing unpacked config file", e))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use dusa_collection_utils::core::{logger::LogLevel, types::stringy::Stringy};
fn sample_fixed() -> Enviornment_V2 {
Enviornment_V2 {
app_name: Stringy::from("demo"),
max_ram_usage: 512,
max_cpu_usage: 0,
environment: Stringy::from("production"),
debug_mode: false,
log_level: LogLevel::Info,
git: None,
database: None,
aggregator: None,
interval_seconds: 30,
monitor_path: Stringy::from("/opt/artisan/src/demo"),
project_path: Stringy::from("/opt/artisan/src/demo"),
changes_needed: 1,
ignored_subdirs: vec![Stringy::from(".git")],
install_command: None,
build_command: None,
run_command: Stringy::from("node server.js"),
application_type: None,
execution_uid: Some(33),
execution_gid: Some(33),
primary_listening_port: Some(3000),
path_modifier: None,
pre_build_command: None,
}
}
fn scratch_bundle_path(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"artisan_runtime_bundle_test_{}_{}",
name,
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir.join("runtime.acai")
}
#[test]
fn round_trips_all_three_pieces() {
let path = scratch_bundle_path("round_trip");
let mut custom = CustomConfig::new();
custom.set("feature_flag", true).unwrap();
build_bundle(&path, &sample_fixed(), &custom, "API_KEY=abc123\n", "hunter2").unwrap();
let fixed = read_fixed_config(&path, "hunter2").unwrap();
assert_eq!(fixed.app_name.to_string(), "demo");
let custom_back = read_custom_config(&path, "hunter2").unwrap();
assert_eq!(custom_back.get::<bool>("feature_flag"), Some(true));
let env = read_env(&path, "hunter2").unwrap();
assert_eq!(env, "API_KEY=abc123\n");
}
#[test]
fn wrong_passphrase_is_rejected() {
let path = scratch_bundle_path("wrong_pass");
build_bundle(&path, &sample_fixed(), &CustomConfig::new(), "", "correct-horse").unwrap();
assert!(read_fixed_config(&path, "wrong-guess").is_err());
}
#[test]
fn commit_updates_a_single_entry_without_disturbing_others() {
let path = scratch_bundle_path("commit");
let mut custom = CustomConfig::new();
custom.set("version", 1u32).unwrap();
build_bundle(&path, &sample_fixed(), &custom, "OLD=1\n", "pw").unwrap();
commit_env(&path, "NEW=2\n", "pw").unwrap();
assert_eq!(read_env(&path, "pw").unwrap(), "NEW=2\n");
assert_eq!(read_fixed_config(&path, "pw").unwrap().app_name.to_string(), "demo");
assert_eq!(read_custom_config(&path, "pw").unwrap().get::<u32>("version"), Some(1));
}
#[test]
fn tampering_with_the_bundle_bytes_is_detected() {
let path = scratch_bundle_path("tamper");
build_bundle(&path, &sample_fixed(), &CustomConfig::new(), "SECRET=1\n", "pw").unwrap();
let mut bytes = fs::read(&path).unwrap();
for i in (0..bytes.len()).step_by(97) {
bytes[i] ^= 0xFF;
}
fs::write(&path, bytes).unwrap();
assert!(
read_env(&path, "pw").is_err(),
"a bit-flipped bundle must fail to read, not silently return corrupted content"
);
}
#[test]
fn unpack_writes_only_the_two_control_plane_files() {
let path = scratch_bundle_path("unpack");
build_bundle(&path, &sample_fixed(), &CustomConfig::new(), "SECRET=1\n", "pw").unwrap();
let dest = std::env::temp_dir().join(format!("artisan_runtime_unpack_test_{}", std::process::id()));
let _ = fs::remove_dir_all(&dest);
unpack_control_plane_config(&path, "pw", &dest).unwrap();
assert!(dest.join(FIXED_CONFIG_ENTRY).exists());
assert!(dest.join(CUSTOM_CONFIG_ENTRY).exists());
assert!(!dest.join(ENV_ENTRY).exists(), "env content must not be unpacked to disk");
cleanup_unpacked_config(&dest).unwrap();
assert!(!dest.join(FIXED_CONFIG_ENTRY).exists());
assert!(!dest.join(CUSTOM_CONFIG_ENTRY).exists());
let _ = fs::remove_dir_all(&dest);
}
}