use std::collections::{BTreeMap, HashMap};
use std::ffi::OsString;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use anyhow::{Context, Result, bail};
use qk_config::env::Provider;
use serde_json::{Value, json};
pub fn load(
provider: &Provider,
inherited: &BTreeMap<OsString, OsString>,
workspace_root: &Path,
) -> Result<(BTreeMap<String, String>, Value)> {
let inputs = provider
.inputs
.as_ref()
.map(|inputs| {
inputs
.iter()
.map(|input| {
let digest = match std::fs::read(workspace_root.join(input)) {
Ok(bytes) => blake3::hash(&bytes).to_hex().to_string(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
"missing".to_owned()
}
Err(error) => {
return Err(error).with_context(|| format!("cannot read {input}"));
}
};
Ok((input.clone(), digest))
})
.collect::<Result<BTreeMap<_, _>>>()
})
.transpose()?;
let mut hasher = blake3::Hasher::new();
for part in [
provider.command.as_bytes(),
workspace_root.as_os_str().as_encoded_bytes(),
std::env::consts::OS.as_bytes(),
std::env::consts::ARCH.as_bytes(),
] {
hasher.update(part);
hasher.update(&[0]);
}
for (input, digest) in inputs.iter().flatten() {
hasher.update(input.as_bytes());
hasher.update(&[0]);
hasher.update(digest.as_bytes());
hasher.update(&[0]);
}
let digest = hasher.finalize().to_hex().to_string();
let variables = output(
provider,
inputs.is_some(),
&digest,
inherited,
workspace_root,
)?;
let keyed = match inputs {
Some(inputs) => json!({ "command": provider.command, "inputs": inputs }),
None => {
let digests: BTreeMap<&str, String> = variables
.iter()
.map(|(name, value)| {
(
name.as_str(),
blake3::hash(value.as_bytes()).to_hex().to_string(),
)
})
.collect();
json!({ "command": provider.command, "values": digests })
}
};
Ok((variables, keyed))
}
fn output(
provider: &Provider,
kept: bool,
digest: &str,
inherited: &BTreeMap<OsString, OsString>,
workspace_root: &Path,
) -> Result<BTreeMap<String, String>> {
type Outputs = Mutex<HashMap<String, std::result::Result<BTreeMap<String, String>, String>>>;
static RUN: OnceLock<Outputs> = OnceLock::new();
let outputs = RUN.get_or_init(Default::default);
let mut outputs = outputs.lock().unwrap();
if let Some(output) = outputs.get(digest) {
return output.clone().map_err(anyhow::Error::msg);
}
let file = kept
.then(|| crate::bins::cache_directory().ok())
.flatten()
.map(|directory| directory.join("env").join(format!("{digest}.json")));
let stored = file
.as_ref()
.and_then(|file| std::fs::read(file).ok())
.and_then(|bytes| serde_json::from_slice::<BTreeMap<String, String>>(&bytes).ok());
let output = match stored {
Some(variables) => Ok(variables),
None => run(provider, inherited, workspace_root).inspect(|variables| {
if let Some(file) = &file {
store(file, variables);
}
}),
};
let output = output.map_err(|error| format!("{error:#}"));
outputs.insert(digest.to_owned(), output.clone());
output.map_err(anyhow::Error::msg)
}
fn run(
provider: &Provider,
inherited: &BTreeMap<OsString, OsString>,
workspace_root: &Path,
) -> Result<BTreeMap<String, String>> {
let output = crate::process::shell(&provider.command)
.current_dir(workspace_root)
.env_clear()
.envs(inherited)
.stdin(std::process::Stdio::null())
.output()
.with_context(|| format!("cannot run {:?}", provider.command))?;
if !output.status.success() {
bail!(
"{:?} failed ({}): {}",
provider.command,
output.status,
String::from_utf8_lossy(&output.stderr).trim()
);
}
let value: Value = serde_json::from_slice(&output.stdout).with_context(|| {
format!(
"{:?} did not print an environment as JSON",
provider.command
)
})?;
parse(&value)
.with_context(|| format!("cannot read the environment {:?} printed", provider.command))
}
fn parse(value: &Value) -> Result<BTreeMap<String, String>> {
let object = value.as_object().context("it is not an object")?;
if let Some(Value::Object(variables)) = object.get("variables") {
const IGNORED: [&str; 16] = [
"BASHOPTS",
"HOME",
"NIX_BUILD_TOP",
"NIX_ENFORCE_PURITY",
"NIX_LOG_FD",
"NIX_REMOTE",
"PPID",
"SHELLOPTS",
"SSL_CERT_FILE",
"TEMP",
"TEMPDIR",
"TERM",
"TMP",
"TMPDIR",
"TZ",
"UID",
];
return Ok(variables
.iter()
.filter(|(name, _)| !IGNORED.contains(&name.as_str()))
.filter_map(|(name, variable)| {
(variable.get("type")? == "exported")
.then(|| Some((name.clone(), variable.get("value")?.as_str()?.to_owned())))
.flatten()
})
.collect());
}
object
.iter()
.filter(|(_, value)| !value.is_null())
.map(|(name, value)| {
let value = value
.as_str()
.with_context(|| format!("{name} is neither a string nor null"))?;
Ok((name.clone(), value.to_owned()))
})
.collect()
}
fn store(file: &Path, variables: &BTreeMap<String, String>) {
let Some(directory) = file.parent() else {
return;
};
if std::fs::create_dir_all(directory).is_err() {
return;
}
let partial = file.with_extension(format!("{}.partial", std::process::id()));
let Ok(bytes) = serde_json::to_vec(variables) else {
return;
};
if write_private(&partial, &bytes).is_err() || std::fs::rename(&partial, file).is_err() {
let _ = std::fs::remove_file(&partial);
}
}
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
options.open(path)?.write_all(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_nix_and_flat_environments() {
let nix = json!({ "variables": {
"PATH": { "type": "exported", "value": "/nix/store/a-tool/bin" },
"HOME": { "type": "exported", "value": "/homeless-shelter" },
"shellHook": { "type": "var", "value": "echo hi" },
"buildInputs": { "type": "array", "value": ["a"] },
}, "bashFunctions": {} });
assert_eq!(
parse(&nix).unwrap(),
BTreeMap::from([("PATH".to_owned(), "/nix/store/a-tool/bin".to_owned())])
);
let flat = json!({ "A": "a", "UNSET": null });
assert_eq!(
parse(&flat).unwrap(),
BTreeMap::from([("A".to_owned(), "a".to_owned())])
);
assert!(parse(&json!({ "A": 1 })).is_err());
assert!(parse(&json!(["A"])).is_err());
}
}