This commit is contained in:
atagen 2025-08-21 23:51:24 +10:00
commit b2e0936d4a
7 changed files with 982 additions and 0 deletions

373
src/main.rs Normal file
View file

@ -0,0 +1,373 @@
use std::{
collections::HashMap,
ffi::OsString,
fmt::{Debug, Display},
io::{BufRead, Read},
os::unix::process::CommandExt,
path::{Path, PathBuf},
str::{FromStr, SplitWhitespace},
};
use anyhow::{Context, Result, anyhow, bail};
use clap::{Parser, Subcommand};
#[repr(u8)]
#[derive(bincode::Encode, bincode::Decode, PartialEq, Debug)]
enum Permission {
Allowed,
Disallowed,
}
impl Display for Permission {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Debug::fmt(self, f)
}
}
#[derive(Subcommand)]
enum Action {
Activate,
Allow,
Disallow,
Edit,
}
#[derive(Parser)]
struct Cli {
#[command(subcommand)]
action: Action,
}
#[derive(Debug)]
enum Loadable {
Default,
Flake(String),
Shell(String),
Env(String),
}
#[derive(Debug)]
enum Keyword {
Pure,
Call(Vec<String>),
Load(Loadable),
}
#[derive(Debug)]
enum ParseError {
InvalidKeyword,
UnknownLoadable,
TooManyOptions,
TooFewOptions,
}
impl Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Debug::fmt(self, f)
}
}
impl FromStr for Keyword {
type Err = ParseError;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
let lower = s.to_lowercase();
use Keyword::*;
if s.len() >= 4 {
let res = match &lower.get(..4) {
Some("pure") => Pure,
Some("call") => {
let target: Vec<String> = lower[4..]
.split_whitespace()
.map(|s| s.to_owned())
.collect();
if target.is_empty() {
return Err(ParseError::TooFewOptions);
}
Call(target)
}
Some("load") => {
let mut words = lower[4..].split_whitespace();
if words.clone().count() > 2 {
return Err(ParseError::TooManyOptions);
} else {
match words.next() {
None => Load(Loadable::Default),
Some("shell") => {
Load(Loadable::Shell(s.get(11..).unwrap_or("").to_string()))
}
Some("flake") => {
Load(Loadable::Flake(s.get(11..).unwrap_or("").to_string()))
}
Some("env") => {
Load(Loadable::Env(s.get(9..).unwrap_or("").to_string()))
}
Some(_) => return Err(ParseError::UnknownLoadable),
}
}
}
Some(n) => {
eprintln!("found invalid command {n}, dying..");
return Err(ParseError::InvalidKeyword);
}
None => {
return Err(ParseError::InvalidKeyword);
}
};
Ok(res)
} else {
Err(ParseError::InvalidKeyword)
}
}
}
fn read_cade(path: &Path) -> Result<Vec<Keyword>> {
let contents = std::fs::read(path).context("reading cade file")?;
let mut accum = Vec::new();
let mut lines = contents.lines();
while let Some(Ok(line)) = lines.next() {
let instruction: Keyword = line
.parse()
.map_err(|e| anyhow!("while parsing cade file at {}: {e:?}", path.display()))?;
accum.push(instruction);
}
Ok(accum)
}
#[derive(Debug)]
enum EnvAction {
Add(String, String),
Clear(String),
Purify,
}
fn load_flake(path: &Path, output: Option<String>) -> Result<Vec<EnvAction>> {
let mut nix_cmd = String::from("nix develop ");
if let Some(flake_output) = output {
nix_cmd.push_str(&[&flake_output, " "].concat());
}
nix_cmd.push_str("-ic env --null");
let mut proc = std::process::Command::new("sh");
// proc.env_clear();
proc.arg("-c");
proc.arg(nix_cmd);
proc.current_dir(path);
let output = proc
.output()
.with_context(|| format!("loading flake at {}", path.display()))?
.stdout;
let text = String::from_utf8(output).context("converting output to text")?;
parse_envs(text)
}
fn load_shell(path: &Path, filename: String) -> Result<Vec<EnvAction>> {
let mut nix_cmd = String::from("nix-shell ");
if filename.is_empty() {
nix_cmd.push_str(&[&filename, " "].concat());
}
nix_cmd.push_str("--pure --command env --null");
let mut proc = std::process::Command::new("sh");
proc.env_clear();
proc.arg("-c");
proc.arg(nix_cmd);
proc.current_dir(path);
let output = proc
.output()
.with_context(|| format!("loading shell at {}", path.display()))?
.stdout;
let text = String::from_utf8(output).context("converting output to text")?;
parse_envs(text)
}
fn load_env(path: &Path, filename: String) -> Result<Vec<EnvAction>> {
let mut p = path.to_path_buf();
if filename.is_empty() {
p.push(".env");
} else {
p.push(filename);
}
let mut file = std::fs::File::open(p)
.with_context(|| format!("opening env file at {}", path.display()))?;
let mut buf = String::new();
file.read_to_string(&mut buf).context("reading env file")?;
parse_envs(buf)
}
fn call(path: &Path, argv: Vec<String>) -> Result<Vec<EnvAction>> {
let mut it = argv.iter();
// safety: already checked at parsing
let mut process = std::process::Command::new(it.next().unwrap());
process.current_dir(path);
process.args(it);
let output = process
.output()
.with_context(|| format!("running process {}", argv.concat()))?
.stdout;
let text = String::from_utf8(output)
.with_context(|| format!("converting call {} output to text", argv.concat()))?
.replace(' ', "\0");
parse_envs(text)
}
fn parse_envs(text: String) -> Result<Vec<EnvAction>> {
let mut envs = Vec::new();
use EnvAction::*;
for line in text.split('\0') {
let split: Vec<&str> = line.split('=').collect();
let parse = match split.len() {
0 => {
return Err(anyhow!("processing returned env var {text}"));
}
1 => Clear(split[0].to_string()),
_ => Add(split[0].to_string(), split[1..].concat().to_string()),
};
envs.push(parse);
}
Ok(envs)
}
fn interpret(cascade: HashMap<PathBuf, Vec<Keyword>>) -> Result<Vec<EnvAction>> {
let mut actions = Vec::new();
for (path, layer) in cascade {
for keyword in layer {
use EnvAction::*;
use Keyword::*;
use Loadable::*;
let acts = match keyword {
Pure => Ok(Vec::from([Purify])),
Call(argv) => call(&path, argv).context("calling process"),
Load(loadable) => match loadable {
Default => load_flake(&path, None).context("loading flake"),
Flake(output) => load_flake(&path, Some(output)),
Shell(filename) => load_shell(&path, filename).context("loading shell"),
Env(filename) => load_env(&path, filename).context("loading env file"),
},
}?;
actions.extend(acts);
}
}
Ok(actions)
}
fn do_activation() -> Result<()> {
let dir = ensure_dir()?;
let db = sled::open(dir).context("open database")?;
let cwd = std::env::current_dir()
.context("determine CWD")?
.into_os_string();
if db
.get(cwd.into_encoded_bytes())
.map(|perm| {
perm.map(|inner| {
let (p, _) =
bincode::decode_from_slice(inner.as_ref(), bincode::config::standard())
.unwrap_or((Permission::Disallowed, 0));
p
})
.unwrap_or(Permission::Disallowed)
})
.unwrap_or(Permission::Disallowed)
== Permission::Disallowed
{
bail!("cade is not permitted to operate here; use 'cade allow'.");
}
let mut current_dir = std::env::current_dir().context("determine CWD")?;
let base_env = std::env::vars().collect::<HashMap<String, String>>();
let mut cascade = HashMap::new();
cascade.insert(
current_dir.clone(),
read_cade(&current_dir.join(".cade")).context("reading cade file")?,
);
while let Some(parent) = current_dir.parent()
&& std::fs::exists(parent.join(".cade"))
.context("check for .cade file in parent directory")?
{
current_dir = parent.to_path_buf();
cascade.insert(
current_dir.clone(),
read_cade(&current_dir.join(".cade")).context("reading cade file")?,
);
}
let mut env = HashMap::new();
let adjustments = interpret(cascade)?;
use EnvAction::*;
for action in adjustments {
let _ = match action {
Add(k, v) => env.insert(k, v),
Clear(k) => env.remove(&k),
Purify => {
for (bk, bv) in base_env.iter() {
if env.get(bk).is_some_and(|inner| inner == bv) {
env.remove(bk);
}
}
None
}
};
}
for (k, v) in env {
println!("{k}={v}");
}
Ok(())
}
fn set_permission(permission: Permission) -> Result<()> {
let dir = ensure_dir()?;
let db = sled::open(dir).context("open database")?;
let cwd = std::env::current_dir()
.context("determine CWD")?
.into_os_string();
let encoded_permission = bincode::encode_to_vec(&permission, bincode::config::standard())
.context("encode permission value")?;
let _ = db
.insert(cwd.into_encoded_bytes(), encoded_permission)
.context("update permissions database")?;
eprintln!(
"cade is now {} here.",
permission.to_string().to_lowercase()
);
Ok(())
}
fn ensure_dir() -> Result<PathBuf> {
let xdg = microxdg::Xdg::new().context("establish XDG paths")?;
let mut path = xdg.state().context("find xdg state dir")?;
path.push("cade");
if !std::fs::exists(&path).is_ok_and(|v| v) {
std::fs::create_dir(&path).context("create cade's state path")?;
}
path.push("permissions.db");
Ok(path)
}
fn try_main() -> Result<()> {
let args = Cli::parse();
use Action::*;
match args.action {
// TODO recursively ascend until we hit a top level .cade, then activate downwards
Activate => do_activation().context("activate cade environment")?,
Allow => set_permission(Permission::Allowed)?,
Disallow => set_permission(Permission::Disallowed)?,
Edit => {
let editor = std::env::var("EDITOR").context("find EDITOR variable")?;
let mut session = std::process::Command::new(Into::<OsString>::into(editor))
.arg(Into::<OsString>::into(".cade"))
.spawn()
.context("spawn editor process")?;
session.wait().context("wait for editor process")?;
set_permission(Permission::Allowed)?;
}
};
Ok(())
}
fn main() {
if let Err(e) = try_main() {
eprintln!("failed to {e}\n{}", e.root_cause());
std::process::exit(1);
}
}