Skip to content

Commit f4192f7

Browse files
committed
refactor: add injectable env to expandEnvVars; remove $HOME dependency from tests
1 parent 79a7c2c commit f4192f7

5 files changed

Lines changed: 113 additions & 56 deletions

File tree

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
{
2-
"status" : "open",
3-
"dependencies" : [],
4-
"blocks" : []
5-
}
2+
"status" : "closed",
3+
"resolution" : "implemented",
4+
"dependencies" : [ ],
5+
"blocks" : [ ],
6+
"target_branch" : "v2.1"
7+
}

client/src/main/java/io/github/cowwoc/cat/claude/hook/ShellParser.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import java.nio.file.Path;
1313
import java.util.ArrayList;
1414
import java.util.List;
15+
import java.util.function.Function;
1516
import java.util.regex.Matcher;
1617
import java.util.regex.Pattern;
1718

@@ -139,8 +140,28 @@ public static Path resolvePath(String path, String base)
139140
* @throws NullPointerException if {@code target} is null
140141
*/
141142
public static String expandEnvVars(String target)
143+
{
144+
return expandEnvVars(target, System::getenv);
145+
}
146+
147+
/**
148+
* Expands {@code $VAR} and {@code ${VAR}} references in a string using the supplied lookup
149+
* function.
150+
* <p>
151+
* Returns {@code null} if any variable is unset (i.e., the lookup function returns {@code null}
152+
* for that variable name), so the caller can fall back to conservative behavior rather than
153+
* evaluating a partially-expanded path.
154+
*
155+
* @param target the string containing variable references to expand
156+
* @param envLookup a function mapping variable names to their values; returns {@code null} if
157+
* the variable is unset
158+
* @return the fully expanded string, or {@code null} if any variable was undefined
159+
* @throws NullPointerException if {@code target} or {@code envLookup} are null
160+
*/
161+
public static String expandEnvVars(String target, Function<String, String> envLookup)
142162
{
143163
requireThat(target, "target").isNotNull();
164+
requireThat(envLookup, "envLookup").isNotNull();
144165
Matcher varMatcher = ENV_VAR_EXPAND_PATTERN.matcher(target);
145166
StringBuilder result = new StringBuilder();
146167
int lastEnd = 0;
@@ -153,7 +174,7 @@ public static String expandEnvVars(String target)
153174
varName = varMatcher.group(1);
154175
else
155176
varName = varMatcher.group(2);
156-
String value = System.getenv(varName);
177+
String value = envLookup.apply(varName);
157178
if (value == null)
158179
return null;
159180
result.append(value);

client/src/main/java/io/github/cowwoc/cat/claude/hook/bash/BlockWorktreeIsolationViolation.java

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
import java.nio.file.Path;
1818
import java.util.ArrayList;
1919
import java.util.List;
20+
import java.util.Map;
21+
import java.util.function.Function;
2022
import java.util.regex.Matcher;
2123
import java.util.regex.Pattern;
2224

@@ -39,8 +41,9 @@
3941
* Only paths under the project directory are checked. Writes to {@code /tmp} or other locations
4042
* outside the project directory are allowed. If no session lock exists, all commands are allowed.
4143
* <p>
42-
* Write targets containing {@code $VAR} or {@code ${VAR}} references are expanded using
43-
* {@link ShellParser#expandEnvVars(String)} before the isolation check. If any variable is unset,
44+
* Write targets containing {@code $VAR} or {@code ${VAR}} references are expanded before the
45+
* isolation check. Use the second constructor to inject a controlled environment lookup for
46+
* testing, or the first constructor to use the system environment. If any variable is unset,
4447
* the path cannot be verified and the command is blocked conservatively. Backtick expressions
4548
* (e.g., {@code `cmd`}) cannot be expanded statically and are always blocked conservatively.
4649
*/
@@ -71,9 +74,12 @@ public final class BlockWorktreeIsolationViolation implements BashHandler
7174

7275
private final Path projectPath;
7376
private final JsonMapper mapper;
77+
private final Function<String, String> envLookup;
7478

7579
/**
7680
* Creates a new handler for blocking worktree isolation violations.
81+
* <p>
82+
* Uses the system environment when expanding shell variable references in write targets.
7783
*
7884
* @param scope the JVM scope providing access to shared resources
7985
* @throws NullPointerException if {@code scope} is null
@@ -82,6 +88,27 @@ public BlockWorktreeIsolationViolation(ClaudeHook scope)
8288
{
8389
this.projectPath = scope.getProjectPath();
8490
this.mapper = scope.getJsonMapper();
91+
this.envLookup = System::getenv;
92+
}
93+
94+
/**
95+
* Creates a new handler for blocking worktree isolation violations with an injectable
96+
* environment map.
97+
* <p>
98+
* Uses the provided environment map when expanding shell variable references in write targets.
99+
* This constructor is intended for testing, where a controlled fake environment can be
100+
* substituted for the system environment.
101+
*
102+
* @param scope the JVM scope providing access to shared resources
103+
* @param env the environment map used for shell variable expansion
104+
* @throws NullPointerException if {@code scope} or {@code env} are null
105+
*/
106+
public BlockWorktreeIsolationViolation(ClaudeHook scope, Map<String, String> env)
107+
{
108+
requireThat(env, "env").isNotNull();
109+
this.projectPath = scope.getProjectPath();
110+
this.mapper = scope.getJsonMapper();
111+
this.envLookup = env::get;
85112
}
86113

87114
@Override
@@ -118,7 +145,7 @@ public Result check(ClaudeHook scope)
118145
}
119146
if (target.contains("$"))
120147
{
121-
String expanded = ShellParser.expandEnvVars(target);
148+
String expanded = ShellParser.expandEnvVars(target, envLookup);
122149
if (expanded == null)
123150
{
124151
String message = """

client/src/test/java/io/github/cowwoc/cat/client/test/BlockWorktreeIsolationViolationTest.java

Lines changed: 53 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@
99
import io.github.cowwoc.cat.claude.hook.BashHandler;
1010

1111
import io.github.cowwoc.cat.claude.hook.bash.BlockWorktreeIsolationViolation;
12-
import org.testng.SkipException;
1312
import org.testng.annotations.Test;
1413

1514
import java.io.IOException;
1615
import java.nio.file.Files;
1716
import java.nio.file.Path;
17+
import java.util.Map;
1818

1919
import static io.github.cowwoc.requirements13.java.DefaultJavaValidators.requireThat;
2020

@@ -677,26 +677,28 @@ public void readOnlyCommandIsAllowed() throws IOException
677677
@Test
678678
public void allowsRedirectWhenEnvVarExpandsToWorktreePath() throws IOException
679679
{
680-
String home = System.getenv("HOME");
681-
if (home == null || home.isBlank())
682-
throw new SkipException("HOME environment variable is not set; skipping env-var expansion test");
683-
Path projectPath = Files.createTempDirectory(Path.of(home), "bwiv-test-");
684-
try (TestClaudeHook scope = new TestClaudeHook(projectPath, projectPath, projectPath))
680+
Path fakeHome = Files.createTempDirectory("fake-home-");
681+
try
685682
{
686-
TestUtils.writeLockFile(scope, ISSUE_ID, SESSION_ID);
687-
Path worktreeDir = TestUtils.createWorktreeDir(scope, ISSUE_ID);
688-
String relativePath = Path.of(home).relativize(worktreeDir.resolve("file.txt")).toString();
689-
String command = "echo foo > ${HOME}/" + relativePath;
683+
Path projectPath = Files.createTempDirectory(fakeHome, "bwiv-test-");
684+
try (TestClaudeHook scope = new TestClaudeHook(projectPath, projectPath, projectPath))
685+
{
686+
TestUtils.writeLockFile(scope, ISSUE_ID, SESSION_ID);
687+
Path worktreeDir = TestUtils.createWorktreeDir(scope, ISSUE_ID);
688+
String relativePath = fakeHome.relativize(worktreeDir.resolve("file.txt")).toString();
689+
String command = "echo foo > ${HOME}/" + relativePath;
690+
Map<String, String> env = Map.of("HOME", fakeHome.toString());
690691

691-
BlockWorktreeIsolationViolation handler = new BlockWorktreeIsolationViolation(scope);
692-
BashHandler.Result result = handler.check(
693-
TestUtils.bashHook(command, projectPath.toString(), SESSION_ID, scope));
692+
BlockWorktreeIsolationViolation handler = new BlockWorktreeIsolationViolation(scope, env);
693+
BashHandler.Result result = handler.check(
694+
TestUtils.bashHook(command, projectPath.toString(), SESSION_ID, scope));
694695

695-
requireThat(result.blocked(), "blocked").isFalse();
696+
requireThat(result.blocked(), "blocked").isFalse();
697+
}
696698
}
697699
finally
698700
{
699-
TestUtils.deleteDirectoryRecursively(projectPath);
701+
TestUtils.deleteDirectoryRecursively(fakeHome);
700702
}
701703
}
702704

@@ -711,26 +713,28 @@ public void allowsRedirectWhenEnvVarExpandsToWorktreePath() throws IOException
711713
@Test
712714
public void allowsRedirectWhenBareEnvVarExpandsToWorktreePath() throws IOException
713715
{
714-
String home = System.getenv("HOME");
715-
if (home == null || home.isBlank())
716-
throw new SkipException("HOME environment variable is not set; skipping env-var expansion test");
717-
Path projectPath = Files.createTempDirectory(Path.of(home), "bwiv-test-");
718-
try (TestClaudeHook scope = new TestClaudeHook(projectPath, projectPath, projectPath))
716+
Path fakeHome = Files.createTempDirectory("fake-home-");
717+
try
719718
{
720-
TestUtils.writeLockFile(scope, ISSUE_ID, SESSION_ID);
721-
Path worktreeDir = TestUtils.createWorktreeDir(scope, ISSUE_ID);
722-
String relativePath = Path.of(home).relativize(worktreeDir.resolve("file.txt")).toString();
723-
String command = "echo foo > $HOME/" + relativePath;
719+
Path projectPath = Files.createTempDirectory(fakeHome, "bwiv-test-");
720+
try (TestClaudeHook scope = new TestClaudeHook(projectPath, projectPath, projectPath))
721+
{
722+
TestUtils.writeLockFile(scope, ISSUE_ID, SESSION_ID);
723+
Path worktreeDir = TestUtils.createWorktreeDir(scope, ISSUE_ID);
724+
String relativePath = fakeHome.relativize(worktreeDir.resolve("file.txt")).toString();
725+
String command = "echo foo > $HOME/" + relativePath;
726+
Map<String, String> env = Map.of("HOME", fakeHome.toString());
724727

725-
BlockWorktreeIsolationViolation handler = new BlockWorktreeIsolationViolation(scope);
726-
BashHandler.Result result = handler.check(
727-
TestUtils.bashHook(command, projectPath.toString(), SESSION_ID, scope));
728+
BlockWorktreeIsolationViolation handler = new BlockWorktreeIsolationViolation(scope, env);
729+
BashHandler.Result result = handler.check(
730+
TestUtils.bashHook(command, projectPath.toString(), SESSION_ID, scope));
728731

729-
requireThat(result.blocked(), "blocked").isFalse();
732+
requireThat(result.blocked(), "blocked").isFalse();
733+
}
730734
}
731735
finally
732736
{
733-
TestUtils.deleteDirectoryRecursively(projectPath);
737+
TestUtils.deleteDirectoryRecursively(fakeHome);
734738
}
735739
}
736740

@@ -746,27 +750,29 @@ public void allowsRedirectWhenBareEnvVarExpandsToWorktreePath() throws IOExcepti
746750
@Test
747751
public void blocksRedirectWhenEnvVarExpandsOutsideWorktree() throws IOException
748752
{
749-
String home = System.getenv("HOME");
750-
if (home == null || home.isBlank())
751-
throw new SkipException("HOME environment variable is not set; skipping env-var expansion test");
752-
Path projectPath = Files.createTempDirectory(Path.of(home), "bwiv-test-");
753-
try (TestClaudeHook scope = new TestClaudeHook(projectPath, projectPath, projectPath))
754-
{
755-
TestUtils.writeLockFile(scope, ISSUE_ID, SESSION_ID);
756-
TestUtils.createWorktreeDir(scope, ISSUE_ID);
757-
String relativePath = Path.of(home).relativize(projectPath.resolve("plugin/file.txt")).toString();
758-
String command = "echo foo > ${HOME}/" + relativePath;
759-
760-
BlockWorktreeIsolationViolation handler = new BlockWorktreeIsolationViolation(scope);
761-
BashHandler.Result result = handler.check(
762-
TestUtils.bashHook(command, projectPath.toString(), SESSION_ID, scope));
763-
764-
requireThat(result.blocked(), "blocked").isTrue();
765-
requireThat(result.reason(), "reason").contains("isolation violation");
753+
Path fakeHome = Files.createTempDirectory("fake-home-");
754+
try
755+
{
756+
Path projectPath = Files.createTempDirectory(fakeHome, "bwiv-test-");
757+
try (TestClaudeHook scope = new TestClaudeHook(projectPath, projectPath, projectPath))
758+
{
759+
TestUtils.writeLockFile(scope, ISSUE_ID, SESSION_ID);
760+
TestUtils.createWorktreeDir(scope, ISSUE_ID);
761+
String relativePath = fakeHome.relativize(projectPath.resolve("plugin/file.txt")).toString();
762+
String command = "echo foo > ${HOME}/" + relativePath;
763+
Map<String, String> env = Map.of("HOME", fakeHome.toString());
764+
765+
BlockWorktreeIsolationViolation handler = new BlockWorktreeIsolationViolation(scope, env);
766+
BashHandler.Result result = handler.check(
767+
TestUtils.bashHook(command, projectPath.toString(), SESSION_ID, scope));
768+
769+
requireThat(result.blocked(), "blocked").isTrue();
770+
requireThat(result.reason(), "reason").contains("isolation violation");
771+
}
766772
}
767773
finally
768774
{
769-
TestUtils.deleteDirectoryRecursively(projectPath);
775+
TestUtils.deleteDirectoryRecursively(fakeHome);
770776
}
771777
}
772778

client/src/test/java/io/github/cowwoc/cat/client/test/EnforceJvmScopeEnvAccessTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@ public void whitelistedFilesCallSystemGetenv() throws IOException
146146
boolean fileExists = Files.exists(filePath);
147147
requireThat(fileExists, "fileExists").withContext(relativePath, "whitelistedFile").isTrue();
148148
String content = Files.readString(filePath);
149-
boolean hasGetenv = content.contains("System.getenv(");
149+
// Accept both direct call System.getenv( and method reference System::getenv
150+
boolean hasGetenv = content.contains("System.getenv(") || content.contains("System::getenv");
150151
requireThat(hasGetenv, "hasGetenv").withContext(relativePath, "whitelistedFile").isTrue();
151152
}
152153
}

0 commit comments

Comments
 (0)