use std::{
env, fs,
io::{Cursor, Write},
path::{Path, PathBuf},
process::Command,
};
use aes_gcm::{aead::Aead, Aes256Gcm, KeyInit, Nonce};
use anyhow::{anyhow, Context, Result};
use argon2::{Algorithm, Argon2, Params, Version};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::fs_util;
const KEY_LEN: usize = 32;
const SALT_LEN: usize = 16;
const NONCE_LEN: usize = 12;
const TAG_LEN: usize = 16;
pub(crate) const MIN_PIN_PASSPHRASE_LEN: usize = 4;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VaultEnvelope {
pub version: u32,
pub kdf: KdfEnvelope,
pub cipher: CipherEnvelope,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct KdfEnvelope {
pub name: String,
pub memory_cost: u32,
pub time_cost: u32,
pub parallelism: u32,
pub salt: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CipherEnvelope {
pub name: String,
pub iv: String,
pub auth_tag: String,
pub ciphertext: String,
}
pub fn derive_vault_filename(passphrase: &str, project: &str, nonce: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(passphrase.as_bytes());
hasher.update(b"\x00");
hasher.update(project.as_bytes());
hasher.update(b"\x00");
hasher.update(nonce.as_bytes());
let hash = hasher.finalize();
format!(".{}", hex::encode(&hash[..8]))
}
pub fn generate_vault_nonce() -> String {
let mut bytes = [0u8; 16];
OsRng.fill_bytes(&mut bytes);
hex::encode(bytes)
}
pub fn encrypt_env_with_params(
plaintext: &str,
passphrase: &str,
memory_cost: u32,
time_cost: u32,
) -> Result<VaultEnvelope> {
let mut salt = [0_u8; SALT_LEN];
let mut iv = [0_u8; NONCE_LEN];
OsRng.fill_bytes(&mut salt);
OsRng.fill_bytes(&mut iv);
let kdf = KdfEnvelope {
name: "argon2id".to_string(),
memory_cost,
time_cost,
parallelism: 1,
salt: STANDARD.encode(salt),
};
let key = derive_key(passphrase, &salt, &kdf)?;
let cipher = Aes256Gcm::new_from_slice(&key).expect("derived AES-256 key has valid length");
let mut encrypted = cipher
.encrypt(Nonce::from_slice(&iv), plaintext.as_bytes())
.expect("AES-GCM encryption should not fail for a valid nonce");
let auth_tag = encrypted.split_off(encrypted.len() - TAG_LEN);
let now = chrono::Utc::now().to_rfc3339();
Ok(VaultEnvelope {
version: 1,
kdf,
cipher: CipherEnvelope {
name: "aes-256-gcm".to_string(),
iv: STANDARD.encode(iv),
auth_tag: STANDARD.encode(auth_tag),
ciphertext: STANDARD.encode(encrypted),
},
created_at: now.clone(),
updated_at: now,
})
}
pub fn encrypt_raw_bytes(plaintext: &[u8], key: &[u8; KEY_LEN]) -> Result<VaultEnvelope> {
let mut iv = [0_u8; NONCE_LEN];
OsRng.fill_bytes(&mut iv);
let kdf = KdfEnvelope {
name: "argon2id".to_string(),
memory_cost: 65_536,
time_cost: 3,
parallelism: 1,
salt: STANDARD.encode({
let mut s = [0u8; SALT_LEN];
OsRng.fill_bytes(&mut s);
s
}),
};
let cipher = Aes256Gcm::new_from_slice(key).expect("raw key has valid length");
let mut encrypted = cipher
.encrypt(Nonce::from_slice(&iv), plaintext)
.expect("AES-GCM encryption should not fail for a valid nonce");
let auth_tag = encrypted.split_off(encrypted.len() - TAG_LEN);
let now = chrono::Utc::now().to_rfc3339();
Ok(VaultEnvelope {
version: 1,
kdf,
cipher: CipherEnvelope {
name: "aes-256-gcm".to_string(),
iv: STANDARD.encode(iv),
auth_tag: STANDARD.encode(auth_tag),
ciphertext: STANDARD.encode(encrypted),
},
created_at: now.clone(),
updated_at: now,
})
}
pub fn import_env_file(source: &Path, vault_path: &Path, passphrase: &str) -> Result<PathBuf> {
let plaintext = fs_util::read_file_to_string(source, "dotenv source")?;
validate_dotenv(&plaintext)?;
let envelope = encrypt_env(&plaintext, passphrase)?;
write_vault(vault_path, &envelope)?;
Ok(vault_path.to_path_buf())
}
pub fn encrypt_env(plaintext: &str, passphrase: &str) -> Result<VaultEnvelope> {
let mut salt = [0_u8; SALT_LEN];
let mut iv = [0_u8; NONCE_LEN];
OsRng.fill_bytes(&mut salt);
OsRng.fill_bytes(&mut iv);
let kdf = KdfEnvelope {
name: "argon2id".to_string(),
memory_cost: 65_536,
time_cost: 3,
parallelism: 1,
salt: STANDARD.encode(salt),
};
let key = derive_key(passphrase, &salt, &kdf)?;
let cipher = Aes256Gcm::new_from_slice(&key).expect("derived AES-256 key has valid length");
let mut encrypted = cipher
.encrypt(Nonce::from_slice(&iv), plaintext.as_bytes())
.expect("AES-GCM encryption should not fail for a valid nonce");
let auth_tag = encrypted.split_off(encrypted.len() - TAG_LEN);
let now = chrono::Utc::now().to_rfc3339();
Ok(VaultEnvelope {
version: 1,
kdf,
cipher: CipherEnvelope {
name: "aes-256-gcm".to_string(),
iv: STANDARD.encode(iv),
auth_tag: STANDARD.encode(auth_tag),
ciphertext: STANDARD.encode(encrypted),
},
created_at: now.clone(),
updated_at: now,
})
}
pub fn decrypt_vault_file(vault_path: &Path, passphrase: &str) -> Result<String> {
let envelope = read_vault(vault_path)?;
decrypt_env(&envelope, passphrase)
}
pub fn edit_vault_file(vault_path: &Path, passphrase: &str) -> Result<()> {
let existing_envelope = read_vault(vault_path)?;
let plaintext = decrypt_env(&existing_envelope, passphrase)?;
let mut temp_file =
tempfile::NamedTempFile::new().context("failed to create temporary env edit buffer")?;
set_restrictive_permissions(temp_file.path())?;
temp_file
.write_all(plaintext.as_bytes())
.context("failed to write temporary env edit buffer")?;
temp_file
.flush()
.context("failed to flush temporary env edit buffer")?;
let editor = selected_editor();
run_editor(&editor, temp_file.path())?;
let edited = fs::read_to_string(temp_file.path())
.context("failed to read edited temporary env buffer")?;
validate_dotenv(&edited).context("edited env content is not valid dotenv syntax")?;
let mut updated = encrypt_env(&edited, passphrase)?;
updated.created_at = existing_envelope.created_at;
write_vault(vault_path, &updated)?;
temp_file
.close()
.context("failed to remove temporary env edit buffer")?;
Ok(())
}
pub fn decrypt_env(envelope: &VaultEnvelope, passphrase: &str) -> Result<String> {
if envelope.version != 1 {
anyhow::bail!("unsupported vault version {}", envelope.version);
}
if envelope.kdf.name != "argon2id" {
anyhow::bail!("unsupported KDF {}", envelope.kdf.name);
}
if envelope.cipher.name != "aes-256-gcm" {
anyhow::bail!("unsupported cipher {}", envelope.cipher.name);
}
let salt = STANDARD.decode(&envelope.kdf.salt)?;
let iv = STANDARD.decode(&envelope.cipher.iv)?;
let mut ciphertext = STANDARD.decode(&envelope.cipher.ciphertext)?;
let auth_tag = STANDARD.decode(&envelope.cipher.auth_tag)?;
if iv.len() != NONCE_LEN {
anyhow::bail!("vault IV has invalid length");
}
if auth_tag.len() != TAG_LEN {
anyhow::bail!("vault auth tag has invalid length");
}
ciphertext.extend(auth_tag);
let key = derive_key(passphrase, &salt, &envelope.kdf)?;
let cipher = Aes256Gcm::new_from_slice(&key).expect("derived AES-256 key has valid length");
let plaintext = cipher
.decrypt(Nonce::from_slice(&iv), ciphertext.as_ref())
.map_err(|_| anyhow!("failed to decrypt vault; passphrase may be incorrect"))?;
String::from_utf8(plaintext).context("vault plaintext is not valid UTF-8")
}
pub fn read_vault(vault_path: &Path) -> Result<VaultEnvelope> {
let contents = fs_util::read_file_to_string(vault_path, "vault file")?;
serde_json::from_str(&contents).context(format!("failed to parse {}", vault_path.display()))
}
pub fn write_vault(vault_path: &Path, envelope: &VaultEnvelope) -> Result<()> {
fs_util::ensure_parent_dir(vault_path)?;
let contents = serde_json::to_string_pretty(envelope).expect("vault envelope should serialize");
fs::write(vault_path, format!("{contents}\n"))
.context(format!("failed to write {}", vault_path.display()))
}
pub fn read_new_passphrase() -> Result<String> {
if let Some(passphrase) = test_passphrase() {
return Ok(passphrase);
}
let (first, second) = prompt_new_passphrase_pair()?;
validate_new_passphrase(&first, &second)?;
Ok(first)
}
pub(crate) fn validate_new_passphrase(first: &str, second: &str) -> Result<()> {
if first != second {
anyhow::bail!("PIN/passphrase values did not match");
}
if first.len() < MIN_PIN_PASSPHRASE_LEN {
anyhow::bail!("PIN/passphrase must be at least {MIN_PIN_PASSPHRASE_LEN} characters");
}
Ok(())
}
pub fn read_new_pin(prompt: &str, confirm_prompt: &str) -> Result<String> {
if let Some(passphrase) = test_passphrase() {
return Ok(passphrase);
}
let first = rpassword::prompt_password(format!("{prompt}: "))?;
let second = rpassword::prompt_password(format!("{confirm_prompt}: "))?;
validate_new_passphrase(&first, &second)?;
Ok(first)
}
pub fn read_existing_passphrase() -> Result<String> {
if let Some(passphrase) = test_passphrase() {
return Ok(passphrase);
}
prompt_existing_passphrase()
}
pub fn validate_dotenv(contents: &str) -> Result<()> {
let iter = dotenvy::from_read_iter(Cursor::new(contents.as_bytes()));
for item in iter {
item?;
}
Ok(())
}
pub(crate) fn selected_editor() -> String {
env::var("EDITOR")
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
env::var("VISUAL")
.ok()
.filter(|value| !value.trim().is_empty())
})
.unwrap_or_else(|| "nano".to_string())
}
pub(crate) fn test_passphrase() -> Option<String> {
std::env::var("WARD_UNSAFE_TEST_PASSPHRASE")
.ok()
.filter(|value| !value.trim().is_empty())
}
#[cfg(not(coverage))]
fn prompt_new_passphrase_pair() -> Result<(String, String)> {
let first = rpassword::prompt_password(" New vault PIN/passphrase: ")?;
let second = rpassword::prompt_password(" Confirm vault PIN/passphrase: ")?;
Ok((first, second))
}
#[cfg(coverage)]
fn prompt_new_passphrase_pair() -> Result<(String, String)> {
Ok((
"coverage passphrase".to_string(),
"coverage passphrase".to_string(),
))
}
#[cfg(not(coverage))]
fn prompt_existing_passphrase() -> Result<String> {
Ok(rpassword::prompt_password(" Vault PIN/passphrase: ")?)
}
#[cfg(coverage)]
fn prompt_existing_passphrase() -> Result<String> {
Ok("coverage passphrase".to_string())
}
fn run_editor(editor: &str, path: &Path) -> Result<()> {
let mut parts = editor.split_whitespace();
let binary = parts
.next()
.filter(|value| !value.is_empty())
.unwrap_or("nano");
let status = Command::new(binary)
.args(parts)
.arg(path)
.status()
.context(format!("failed to launch editor {binary}"))?;
if !status.success() {
anyhow::bail!("editor exited with status {status}");
}
Ok(())
}
#[cfg(unix)]
fn set_restrictive_permissions(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).context(format!(
"failed to restrict permissions for {}",
path.display()
))
}
#[cfg(not(unix))]
fn set_restrictive_permissions(_path: &Path) -> Result<()> {
Ok(())
}
fn derive_key(passphrase: &str, salt: &[u8], kdf: &KdfEnvelope) -> Result<[u8; KEY_LEN]> {
let params = Params::new(
kdf.memory_cost,
kdf.time_cost,
kdf.parallelism,
Some(KEY_LEN),
)
.map_err(|error| anyhow!("invalid Argon2 parameters: {error}"))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut key = [0_u8; KEY_LEN];
argon2
.hash_password_into(passphrase.as_bytes(), salt, &mut key)
.map_err(|error| anyhow!("failed to derive vault key: {error}"))?;
Ok(key)
}
#[cfg(test)]
mod tests {
use super::*;
use base64::engine::general_purpose::STANDARD;
use std::sync::{Mutex, OnceLock};
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
}
#[test]
fn encrypts_and_decrypts_env_contents() {
let plaintext = "DATABASE_URL=postgres://local\nNEXT_PUBLIC_API_URL=http://localhost\n";
let envelope = encrypt_env(plaintext, "correct horse battery staple").unwrap();
let decrypted = decrypt_env(&envelope, "correct horse battery staple").unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn wrong_passphrase_fails() {
let envelope =
encrypt_env("DATABASE_URL=postgres://local\n", "correct passphrase").unwrap();
assert!(decrypt_env(&envelope, "wrong passphrase").is_err());
}
#[test]
fn dotenv_validation_rejects_invalid_contents() {
assert!(super::validate_dotenv("DATABASE_URL='unterminated\n").is_err());
}
#[test]
fn validates_new_passphrase_confirmation_and_length() {
assert!(validate_new_passphrase("1234", "1234").is_ok());
assert!(validate_new_passphrase("long enough", "long enough").is_ok());
let mismatch = validate_new_passphrase("long enough", "different enough")
.expect_err("mismatched PIN/passphrase should be rejected")
.to_string();
assert!(mismatch.contains("PIN/passphrase values did not match"));
let short = validate_new_passphrase("123", "123")
.expect_err("three character PIN should be rejected")
.to_string();
assert!(short.contains("PIN/passphrase must be at least 4 characters"));
assert!(validate_new_passphrase("1234", "4321").is_err());
}
#[test]
fn import_env_file_reports_missing_source() {
let tempdir = tempfile::tempdir().unwrap();
assert!(import_env_file(
&tempdir.path().join("missing.env"),
&tempdir.path().join(".env.vault"),
"correct horse battery staple",
)
.is_err());
}
#[test]
fn import_env_file_rejects_invalid_dotenv_contents() {
let tempdir = tempfile::tempdir().unwrap();
let source = tempdir.path().join(".env");
std::fs::write(&source, "DATABASE_URL='unterminated\n").unwrap();
assert!(
import_env_file(&source, &tempdir.path().join(".env.vault"), "passphrase").is_err()
);
}
#[test]
fn import_env_file_encrypts_valid_dotenv_contents() {
let tempdir = tempfile::tempdir().unwrap();
let source = tempdir.path().join(".env");
let vault_path = tempdir.path().join(".env.vault");
std::fs::write(&source, "DATABASE_URL=postgres://local\n").unwrap();
assert_eq!(
import_env_file(&source, &vault_path, "passphrase").unwrap(),
vault_path
);
assert_eq!(
decrypt_vault_file(&vault_path, "passphrase").unwrap(),
"DATABASE_URL=postgres://local\n"
);
}
#[test]
fn import_env_file_reports_vault_write_failures() {
let tempdir = tempfile::tempdir().unwrap();
let source = tempdir.path().join(".env");
let vault_directory = tempdir.path().join(".env.vault");
std::fs::write(&source, "DATABASE_URL=postgres://local\n").unwrap();
std::fs::create_dir(&vault_directory).unwrap();
assert!(import_env_file(&source, &vault_directory, "passphrase").is_err());
}
#[test]
fn decrypt_rejects_invalid_envelope_metadata_and_lengths() {
let mut envelope = encrypt_env("DATABASE_URL=postgres://local\n", "passphrase").unwrap();
envelope.version = 2;
assert!(decrypt_env(&envelope, "passphrase").is_err());
envelope.version = 1;
envelope.kdf.name = "scrypt".to_string();
assert!(decrypt_env(&envelope, "passphrase").is_err());
envelope.kdf.name = "argon2id".to_string();
envelope.cipher.name = "xchacha20-poly1305".to_string();
assert!(decrypt_env(&envelope, "passphrase").is_err());
envelope.cipher.name = "aes-256-gcm".to_string();
let valid_iv = envelope.cipher.iv.clone();
envelope.cipher.iv = STANDARD.encode([1_u8, 2_u8]);
assert!(decrypt_env(&envelope, "passphrase").is_err());
envelope.cipher.iv = valid_iv;
envelope.cipher.auth_tag = STANDARD.encode([1_u8, 2_u8]);
assert!(decrypt_env(&envelope, "passphrase").is_err());
}
#[test]
fn decrypt_rejects_invalid_base64_and_argon_parameters() {
let mut envelope = encrypt_env("DATABASE_URL=postgres://local\n", "passphrase").unwrap();
let valid_salt = envelope.kdf.salt.clone();
envelope.kdf.salt = "@@@not-base64@@@".to_string();
assert!(decrypt_env(&envelope, "passphrase").is_err());
envelope.kdf.salt = valid_salt;
let valid_ciphertext = envelope.cipher.ciphertext.clone();
envelope.cipher.ciphertext = "@@@not-base64@@@".to_string();
assert!(decrypt_env(&envelope, "passphrase").is_err());
envelope.cipher.ciphertext = valid_ciphertext;
let valid_iv = envelope.cipher.iv.clone();
envelope.cipher.iv = "@@@not-base64@@@".to_string();
assert!(decrypt_env(&envelope, "passphrase").is_err());
envelope.cipher.iv = valid_iv;
let valid_auth_tag = envelope.cipher.auth_tag.clone();
envelope.cipher.auth_tag = "@@@not-base64@@@".to_string();
assert!(decrypt_env(&envelope, "passphrase").is_err());
envelope.cipher.auth_tag = valid_auth_tag;
envelope.kdf.memory_cost = 0;
assert!(decrypt_env(&envelope, "passphrase").is_err());
}
#[test]
fn read_and_write_vault_report_io_and_parse_errors() {
let tempdir = tempfile::tempdir().unwrap();
let missing = tempdir.path().join("missing.vault");
let malformed = tempdir.path().join("malformed.vault");
let directory = tempdir.path().join("directory.vault");
let envelope = encrypt_env("DATABASE_URL=postgres://local\n", "passphrase").unwrap();
std::fs::write(&malformed, "{bad-json}").unwrap();
std::fs::create_dir(&directory).unwrap();
assert!(read_vault(&missing).is_err());
assert!(read_vault(&malformed).is_err());
assert!(write_vault(&directory, &envelope).is_err());
}
#[test]
fn derive_key_reports_invalid_hash_inputs() {
let kdf = KdfEnvelope {
name: "argon2id".to_string(),
memory_cost: 65_536,
time_cost: 3,
parallelism: 1,
salt: STANDARD.encode([]),
};
let mut invalid_params = kdf.clone();
invalid_params.memory_cost = 0;
assert!(derive_key("passphrase", b"salt", &invalid_params).is_err());
assert!(derive_key("passphrase", b"", &kdf).is_err());
}
#[test]
#[serial_test::serial]
fn selected_editor_prefers_editor_then_visual_then_nano() {
let _guard = env_lock();
std::env::set_var("EDITOR", "code --wait");
std::env::set_var("VISUAL", "vim");
assert_eq!(selected_editor(), "code --wait");
std::env::set_var("EDITOR", "");
assert_eq!(selected_editor(), "vim");
std::env::remove_var("EDITOR");
std::env::remove_var("VISUAL");
assert_eq!(selected_editor(), "nano");
}
#[test]
#[serial_test::serial]
fn test_passphrase_ignores_empty_values() {
let _guard = env_lock();
std::env::set_var("WARD_UNSAFE_TEST_PASSPHRASE", "");
assert!(test_passphrase().is_none());
std::env::set_var("WARD_UNSAFE_TEST_PASSPHRASE", "secret");
assert_eq!(test_passphrase(), Some("secret".to_string()));
std::env::remove_var("WARD_UNSAFE_TEST_PASSPHRASE");
}
#[test]
#[serial_test::serial]
fn passphrase_readers_use_test_passphrase_when_present() {
let _guard = env_lock();
std::env::set_var("WARD_UNSAFE_TEST_PASSPHRASE", "secret passphrase");
assert_eq!(read_new_passphrase().unwrap(), "secret passphrase");
assert_eq!(read_existing_passphrase().unwrap(), "secret passphrase");
std::env::remove_var("WARD_UNSAFE_TEST_PASSPHRASE");
}
#[test]
#[serial_test::serial]
fn edit_vault_reports_editor_failure_and_invalid_contents_without_corrupting_vault() {
let tempdir = tempfile::tempdir().unwrap();
let vault_path = tempdir.path().join(".env.vault");
let envelope = encrypt_env("DATABASE_URL=postgres://original\n", "passphrase").unwrap();
write_vault(&vault_path, &envelope).unwrap();
let failing_editor = tempdir.path().join("fail.sh");
std::fs::write(&failing_editor, "#!/bin/sh\nexit 3\n").unwrap();
make_executable(&failing_editor);
assert!(run_editor(failing_editor.to_str().unwrap(), &vault_path).is_err());
let invalid_editor = tempdir.path().join("invalid.sh");
std::fs::write(
&invalid_editor,
"#!/bin/sh\nprintf \"DATABASE_URL='unterminated\\n\" > \"$1\"\n",
)
.unwrap();
make_executable(&invalid_editor);
let _guard = env_lock();
std::env::set_var("EDITOR", &invalid_editor);
assert!(edit_vault_file(&vault_path, "passphrase").is_err());
std::env::remove_var("EDITOR");
assert_eq!(
decrypt_vault_file(&vault_path, "passphrase").unwrap(),
"DATABASE_URL=postgres://original\n"
);
}
#[test]
#[serial_test::serial]
fn edit_vault_file_reencrypts_valid_editor_output() {
let tempdir = tempfile::tempdir().unwrap();
let vault_path = tempdir.path().join(".env.vault");
let envelope = encrypt_env("DATABASE_URL=postgres://original\n", "passphrase").unwrap();
write_vault(&vault_path, &envelope).unwrap();
let editor = tempdir.path().join("edit.sh");
std::fs::write(
&editor,
"#!/bin/sh\nprintf 'DATABASE_URL=postgres://edited\\n' > \"$1\"\n",
)
.unwrap();
make_executable(&editor);
let _guard = env_lock();
std::env::set_var("EDITOR", &editor);
edit_vault_file(&vault_path, "passphrase").unwrap();
std::env::remove_var("EDITOR");
assert_eq!(
decrypt_vault_file(&vault_path, "passphrase").unwrap(),
"DATABASE_URL=postgres://edited\n"
);
}
#[cfg(coverage)]
#[test]
#[serial_test::serial]
fn coverage_prompt_password_stubs_are_available_without_env() {
let _guard = env_lock();
std::env::remove_var("WARD_UNSAFE_TEST_PASSPHRASE");
assert_eq!(read_new_passphrase().unwrap(), "coverage passphrase");
assert_eq!(read_existing_passphrase().unwrap(), "coverage passphrase");
}
#[cfg(unix)]
fn make_executable(path: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(path).unwrap().permissions();
permissions.set_mode(0o700);
std::fs::set_permissions(path, permissions).unwrap();
}
#[cfg(not(unix))]
fn make_executable(_path: &std::path::Path) {}
}