use crate::delete;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct EvalContext {
pub cwd: PathBuf,
pub root: PathBuf,
}
impl EvalContext {
pub fn new_for_preview(cwd: impl Into<PathBuf>, root: impl Into<PathBuf>) -> Self {
Self {
cwd: cwd.into(),
root: root.into(),
}
}
pub fn at(dir: impl Into<PathBuf>) -> Self {
let d: PathBuf = dir.into();
Self {
cwd: d.clone(),
root: d,
}
}
pub fn from_paths(cwd: impl Into<PathBuf>, paths: &crate::paths::Paths) -> Self {
let root = paths
.project_dir
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| paths.project_dir.clone());
Self {
cwd: cwd.into(),
root,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SensitiveShape {
UnexpandedVar,
OutsideRoot,
UnderUserProfile,
ProtectedName,
}
impl SensitiveShape {
pub fn label(self) -> &'static str {
match self {
SensitiveShape::UnexpandedVar => "depends on an unexpanded variable",
SensitiveShape::OutsideRoot => "resolves outside the project",
SensitiveShape::UnderUserProfile => "resolves to a user profile",
SensitiveShape::ProtectedName => "names the gate's own files",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum TargetRole {
Source,
Destination,
Removed,
}
impl TargetRole {
pub fn effect(self) -> &'static str {
match self {
TargetRole::Source => "read",
TargetRole::Destination => "written",
TargetRole::Removed => "removed",
}
}
pub fn is_destructive(self) -> bool {
matches!(self, TargetRole::Destination | TargetRole::Removed)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedTarget {
pub role: TargetRole,
pub as_written: String,
pub resolved: Option<PathBuf>,
pub shapes: Vec<SensitiveShape>,
}
impl ResolvedTarget {
pub fn is_unresolved(&self) -> bool {
self.resolved.is_none()
}
#[cfg(test)]
pub fn has(&self, s: SensitiveShape) -> bool {
self.shapes.contains(&s)
}
pub fn display(&self) -> String {
match &self.resolved {
Some(p) => p.display().to_string(),
None => self.as_written.clone(),
}
}
}
pub fn command_targets(segment: &str, ctx: &EvalContext) -> Vec<ResolvedTarget> {
let mut out: Vec<(String, TargetRole)> = Vec::new();
for seg in crate::shell::split_segments_deep(segment) {
for o in &seg.redirects {
out.push((o.target.clone(), TargetRole::Destination));
}
}
for t in crate::delete::extract_targets(segment) {
out.push((t, TargetRole::Removed));
}
for seg in crate::shell::split_segments_deep(segment) {
let tokens = crate::delete::tokenize_public(seg.command());
let Some((head, at)) = crate::delete::resolve_head(&tokens) else {
continue;
};
let args = &tokens[at + 1..];
match head.as_str() {
"cp" | "copy" => out.extend(copy_move_targets(&head, args, TargetRole::Source)),
"mv" | "move" => out.extend(copy_move_targets(&head, args, TargetRole::Removed)),
"tee" => out.extend(tee_targets(args)),
"dd" => out.extend(dd_targets(args)),
_ => {}
}
}
out.sort();
out.dedup();
out.into_iter()
.map(|(raw, role)| target(&raw, role, ctx))
.collect()
}
fn copy_move_targets(
head: &str,
args: &[String],
source_role: TargetRole,
) -> Vec<(String, TargetRole)> {
let mut operands: Vec<String> = Vec::new();
let mut explicit_dir: Option<String> = None;
let mut i = 0;
while i < args.len() {
let a = &args[i];
if let Some(rest) = a.strip_prefix("--target-directory=") {
explicit_dir = Some(rest.to_string());
} else if a == "-t" || a == "--target-directory" {
explicit_dir = args.get(i + 1).cloned();
i += 1;
} else if a == "-S" || a == "--suffix" {
i += 1; } else if crate::delete::is_flag(head, a) && a.len() > 1 {
} else {
operands.push(a.clone());
}
i += 1;
}
let mut out = Vec::new();
match explicit_dir {
Some(dir) => {
out.push((dir, TargetRole::Destination));
out.extend(operands.into_iter().map(|o| (o, source_role)));
}
None => {
if operands.len() >= 2 {
let dest = operands.pop().expect("len >= 2");
out.push((dest, TargetRole::Destination));
out.extend(operands.into_iter().map(|o| (o, source_role)));
}
}
}
out
}
fn tee_targets(args: &[String]) -> Vec<(String, TargetRole)> {
args.iter()
.filter(|a| !a.starts_with('-'))
.map(|a| (a.clone(), TargetRole::Destination))
.collect()
}
fn dd_targets(args: &[String]) -> Vec<(String, TargetRole)> {
let mut out = Vec::new();
for a in args {
if let Some(v) = a.strip_prefix("of=") {
out.push((v.to_string(), TargetRole::Destination));
} else if let Some(v) = a.strip_prefix("if=") {
out.push((v.to_string(), TargetRole::Source));
}
}
out
}
pub fn target(raw: &str, role: TargetRole, ctx: &EvalContext) -> ResolvedTarget {
let as_written = raw.trim().trim_matches('"').trim_matches('\'').to_string();
let mut shapes = Vec::new();
if has_unexpanded_var(&as_written) {
shapes.push(SensitiveShape::UnexpandedVar);
return ResolvedTarget {
role,
as_written,
resolved: None,
shapes,
};
}
let resolved = delete::resolve_path_in(&as_written, &ctx.cwd);
if !delete::is_inside(&resolved, &ctx.root) {
shapes.push(SensitiveShape::OutsideRoot);
}
if delete::is_user_profile(&resolved) {
shapes.push(SensitiveShape::UnderUserProfile);
}
if crate::protect::classify(
&ctx.cwd.display().to_string(),
&resolved.display().to_string(),
)
.is_some()
{
shapes.push(SensitiveShape::ProtectedName);
}
ResolvedTarget {
role,
as_written,
resolved: Some(resolved),
shapes,
}
}
fn has_unexpanded_var(s: &str) -> bool {
let b: Vec<char> = s.chars().collect();
for i in 0..b.len() {
if b[i] == '$' {
match b.get(i + 1) {
Some('(') => continue,
Some(c) if c.is_ascii_alphanumeric() || *c == '_' || *c == '{' => return true,
_ => {}
}
}
if b[i] == '%' {
if let Some(rest) = b.get(i + 1..) {
for (n, c) in rest.iter().enumerate() {
if *c == '%' && n > 0 {
return true;
}
if !(c.is_ascii_alphanumeric() || *c == '_') {
break;
}
}
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::TempTree;
fn roles(cmd: &str) -> Vec<(String, TargetRole)> {
let ctx = EvalContext::at(std::path::Path::new("/tmp/proj"));
let mut v: Vec<(String, TargetRole)> = command_targets(cmd, &ctx)
.into_iter()
.map(|t| (t.as_written, t.role))
.collect();
v.sort();
v
}
#[test]
fn cp_reads_its_sources_and_mv_removes_them() {
assert_eq!(
roles("cp normal.txt .env"),
vec![
(".env".into(), TargetRole::Destination),
("normal.txt".into(), TargetRole::Source),
],
"a copy's source survives - it must not read as at-risk"
);
assert_eq!(
roles("mv .env /tmp/foo"),
vec![
(".env".into(), TargetRole::Removed),
("/tmp/foo".into(), TargetRole::Destination),
],
"a move destroys its source, which is the case that goes missing \
if only the destination is reported"
);
}
#[test]
fn a_redirection_is_not_an_operand() {
assert_eq!(
roles("mv a b > log 2>&1"),
vec![
("a".into(), TargetRole::Removed),
("b".into(), TargetRole::Destination),
("log".into(), TargetRole::Destination),
]
);
assert_eq!(
roles("cp a b 2>/dev/null"),
vec![
("a".into(), TargetRole::Source),
("b".into(), TargetRole::Destination),
],
"a sink is not a destination and not an operand"
);
assert_eq!(
roles("rm -rf ./cache > /dev/null 2>&1"),
vec![("./cache".into(), TargetRole::Removed)]
);
}
#[test]
fn the_final_operand_is_the_destination_and_the_rest_are_not() {
assert_eq!(
roles("cp a b c dir/"),
vec![
("a".into(), TargetRole::Source),
("b".into(), TargetRole::Source),
("c".into(), TargetRole::Source),
("dir/".into(), TargetRole::Destination),
]
);
assert_eq!(
roles("mv a b c dir/"),
vec![
("a".into(), TargetRole::Removed),
("b".into(), TargetRole::Removed),
("c".into(), TargetRole::Removed),
("dir/".into(), TargetRole::Destination),
]
);
assert!(roles("cp x").is_empty());
}
#[test]
fn an_explicit_target_directory_inverts_the_grammar() {
assert_eq!(
roles("cp -t dir/ a b"),
vec![
("a".into(), TargetRole::Source),
("b".into(), TargetRole::Source),
("dir/".into(), TargetRole::Destination),
]
);
assert_eq!(
roles("cp --target-directory=dir/ a b"),
vec![
("a".into(), TargetRole::Source),
("b".into(), TargetRole::Source),
("dir/".into(), TargetRole::Destination),
]
);
assert_eq!(
roles("mv -t dir/ .env"),
vec![
(".env".into(), TargetRole::Removed),
("dir/".into(), TargetRole::Destination),
]
);
assert_eq!(
roles("cp -S .bak a b"),
vec![
("a".into(), TargetRole::Source),
("b".into(), TargetRole::Destination),
],
"-S consumes .bak, which must not read as an operand"
);
}
#[test]
fn a_cmd_switch_is_not_an_operand() {
assert_eq!(
roles("copy /Y backup.txt .env"),
vec![
(".env".into(), TargetRole::Destination),
("backup.txt".into(), TargetRole::Source),
]
);
assert_eq!(
roles("move /Y a b"),
vec![
("a".into(), TargetRole::Removed),
("b".into(), TargetRole::Destination),
]
);
assert!(
roles("copy /Y .env").is_empty(),
"a switch must not fill the source slot and promote .env"
);
assert_eq!(
roles("copy /Y /Z a b"),
vec![
("a".into(), TargetRole::Source),
("b".into(), TargetRole::Destination),
]
);
assert_eq!(
roles("cp /etc/hosts backup"),
vec![
("/etc/hosts".into(), TargetRole::Source),
("backup".into(), TargetRole::Destination),
],
"an absolute path is not a switch"
);
}
#[test]
fn tee_writes_every_operand_and_dd_names_its_roles() {
assert_eq!(
roles("tee a.log b.log"),
vec![
("a.log".into(), TargetRole::Destination),
("b.log".into(), TargetRole::Destination),
]
);
assert_eq!(
roles("tee -a log.txt"),
vec![("log.txt".into(), TargetRole::Destination)]
);
assert_eq!(
roles("dd if=/dev/zero of=.env"),
vec![
(".env".into(), TargetRole::Destination),
("/dev/zero".into(), TargetRole::Source),
]
);
assert_eq!(
roles("dd of=x bs=1M count=10"),
vec![("x".into(), TargetRole::Destination)]
);
}
#[test]
fn redirects_and_deletes_speak_the_same_vocabulary() {
assert_eq!(
roles("cat /dev/null > out.txt"),
vec![("out.txt".into(), TargetRole::Destination)]
);
assert_eq!(
roles("rm -rf ./dist"),
vec![("./dist".into(), TargetRole::Removed)]
);
}
#[test]
fn an_escaped_quote_resolves_to_the_file_the_shell_deletes() {
assert_eq!(
roles(r#"rm -rf "a\"b""#),
vec![("a\"b".to_string(), TargetRole::Removed)]
);
}
#[test]
fn a_plain_relative_target_resolves_against_the_given_cwd_and_is_ordinary() {
let t = TempTree::new("resolve-plain");
let root = t.path();
let r = target("./dist", TargetRole::Destination, &EvalContext::at(root));
assert_eq!(r.as_written, "./dist");
assert_eq!(r.resolved, Some(root.join("dist")));
assert!(!r.is_unresolved());
assert!(r.shapes.is_empty(), "ordinary work carries no shapes");
}
#[test]
fn the_two_readings_are_both_kept() {
let t = TempTree::new("resolve-readings");
let root = t.path();
let r = target(
"./sub/../dist",
TargetRole::Destination,
&EvalContext::at(root),
);
assert_eq!(r.as_written, "./sub/../dist");
assert_eq!(r.resolved, Some(root.join("dist")));
}
#[test]
fn an_unexpanded_variable_is_unresolved_rather_than_guessed() {
let t = TempTree::new("resolve-var");
let root = t.path();
for raw in ["$HOME/.ssh", "${HOME}/.ssh", "%USERPROFILE%\\.ssh", "$X"] {
let r = target(raw, TargetRole::Destination, &EvalContext::at(root));
assert!(r.is_unresolved(), "{raw} must not resolve");
assert!(r.has(SensitiveShape::UnexpandedVar), "{raw}");
assert_eq!(r.resolved, None);
assert_eq!(r.display(), raw);
}
}
#[test]
fn a_dollar_that_is_not_a_variable_does_not_count() {
let t = TempTree::new("resolve-dollar");
let root = t.path();
for raw in ["$(date).log", "cost$.txt", "100%", "50%-done"] {
let r = target(raw, TargetRole::Destination, &EvalContext::at(root));
assert!(
!r.has(SensitiveShape::UnexpandedVar),
"{raw} is not a variable reference"
);
}
}
#[test]
fn a_target_outside_the_project_carries_that_shape() {
let t = TempTree::new("resolve-outside");
let root = t.path().join("project");
std::fs::create_dir_all(&root).unwrap();
let r = target(
"../elsewhere",
TargetRole::Destination,
&EvalContext::at(&root),
);
assert!(r.has(SensitiveShape::OutsideRoot));
let inside = target("./src", TargetRole::Destination, &EvalContext::at(&root));
assert!(!inside.has(SensitiveShape::OutsideRoot));
}
#[test]
fn the_gates_own_files_are_recognised_through_protect() {
let t = TempTree::new("resolve-protected");
let root = t.path();
let r = target(
".termaxa/policy.yaml",
TargetRole::Destination,
&EvalContext::at(root),
);
assert!(
r.has(SensitiveShape::ProtectedName),
"the command path and the write path must agree on what is protected"
);
let ordinary = target(
"src/main.rs",
TargetRole::Destination,
&EvalContext::at(root),
);
assert!(!ordinary.has(SensitiveShape::ProtectedName));
}
#[test]
fn shapes_accumulate_rather_than_shadowing_each_other() {
let t = TempTree::new("resolve-multi");
let root = t.path().join("project");
std::fs::create_dir_all(&root).unwrap();
let r = target(
"../other/.termaxa/policy.yaml",
TargetRole::Destination,
&EvalContext::at(&root),
);
assert!(r.has(SensitiveShape::OutsideRoot), "{:?}", r.shapes);
assert!(r.has(SensitiveShape::ProtectedName), "{:?}", r.shapes);
}
#[test]
fn every_shape_has_a_label_that_reads_after_a_target() {
for s in [
SensitiveShape::UnexpandedVar,
SensitiveShape::OutsideRoot,
SensitiveShape::UnderUserProfile,
SensitiveShape::ProtectedName,
] {
let l = s.label();
assert!(!l.is_empty());
assert!(
l.chars().next().unwrap().is_lowercase(),
"{l}: labels continue a sentence, they do not start one"
);
}
}
}