fix untrusted-workspace RCE in dugite git resolution (T-98)
Drop the workspaceRoot parameter from resolveToolchainPaths / Toolchain.resolvePaths entirely. The old code resolved \`<workspaceRoot>/native/dugite/bin/git\` as the git binary before falling back to PATH — a malicious repo could commit an executable at that path and clide would run it on the first auto-fired git.status (which fires automatically on workspace open). Dugite now resolves against trusted locations only: 1. CLIDE_DUGITE_DIR env var (dev override). 2. <exe-parent>/dugite/bin/git (production bundle). 3. <exe-parent>/lib/dugite/bin/git (alternate bundle layout). Test plants `native/dugite/bin/git` in a temp workspace and asserts the resolved git path is NOT inside the workspace. Callers updated (8 sites): main.dart, backend_entry.dart twice, test_app.dart three times (compute now wraps a no-arg call), plus five test fixtures. backend.dart's now-vestigial hintRoot left in the struct for cleanup under T-99. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1707,3 +1707,5 @@ Why not always? Lock state at fork() time is timing-dependent. ~95% of the time
|
|||||||
- glibc posix_spawn docs: https://www.gnu.org/software/libc/manual/html_node/Process-Creation-Example.html
|
- glibc posix_spawn docs: https://www.gnu.org/software/libc/manual/html_node/Process-Creation-Example.html
|
||||||
', NULL, '2026-05-17 18:05:33', '2026-05-17 18:05:33', '2026-05-17 18:05:33', NULL, '56d9ba7c49068a498a003e900cde416f', 1) ON CONFLICT(hash) DO NOTHING;
|
', NULL, '2026-05-17 18:05:33', '2026-05-17 18:05:33', '2026-05-17 18:05:33', NULL, '56d9ba7c49068a498a003e900cde416f', 1) ON CONFLICT(hash) DO NOTHING;
|
||||||
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-96', 'status', 'backlog', 'done', NULL, '2026-05-17 18:11:18', '2026-05-17 18:11:18', '2026-05-17 18:11:18', NULL, '3215fc763c4f1dce75ea9e0bb2b9bbb6', 1) ON CONFLICT(hash) DO NOTHING;
|
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-96', 'status', 'backlog', 'done', NULL, '2026-05-17 18:11:18', '2026-05-17 18:11:18', '2026-05-17 18:11:18', NULL, '3215fc763c4f1dce75ea9e0bb2b9bbb6', 1) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-98', 'status', 'backlog', 'in_progress', NULL, '2026-05-17 18:51:25', '2026-05-17 18:51:25', '2026-05-17 18:51:25', NULL, '0369c8264ec2c67b3bf3f28057404ec0', 1) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-98', 'status', 'in_progress', 'done', NULL, '2026-05-17 18:55:57', '2026-05-17 18:55:57', '2026-05-17 18:55:57', NULL, 'cb0ae539d5665bd9720b327d60a2cbed', 1) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
|||||||
@@ -1597,3 +1597,13 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority,
|
|||||||
4. Surface text-zoom (Ctrl +/-/0) in the palette so it''s discoverable.
|
4. Surface text-zoom (Ctrl +/-/0) in the palette so it''s discoverable.
|
||||||
|
|
||||||
Source: consultants.md "UX — Findings — [Minor]" + Strengths section.', 'backlog', 'low', NULL, NULL, NULL, '2026-05-17 18:48:28', '2026-05-17 18:48:28', NULL, 'e749bba718321bcc202cc51c55a59ee4', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
Source: consultants.md "UX — Findings — [Minor]" + Strengths section.', 'backlog', 'low', NULL, NULL, NULL, '2026-05-17 18:48:28', '2026-05-17 18:48:28', NULL, 'e749bba718321bcc202cc51c55a59ee4', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||||
|
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-98', 'bug', 'T-97', 'fix untrusted-workspace RCE in dugite git resolution', '`toolchain_paths.dart:79` builds `''$workspaceRoot/native/dugite/bin''` and runs `_firstExisting([''$dugite/git''])` — if that file exists in the open workspace it becomes the git binary for all GitClient calls, before falling back to PATH. A malicious repo commits an executable at `native/dugite/bin/git`; clide runs it on the first auto-fired `git.status`. Arbitrary code execution from merely opening a repo.
|
||||||
|
|
||||||
|
**Fix:** resolve `native/dugite` against `Platform.resolvedExecutable`''s directory (or a known install root), never `workspaceRoot`.
|
||||||
|
|
||||||
|
**Acceptance:**
|
||||||
|
1. Toolchain resolution does not look at any workspace path.
|
||||||
|
2. Test that a planted `native/dugite/bin/git` in the temp-dir workspace is ignored.
|
||||||
|
3. Existing dugite-in-install-dir behavior preserved.
|
||||||
|
|
||||||
|
Source: consultants.md "Security — Findings — [Critical]".', 'done', 'critical', NULL, NULL, NULL, '2026-05-17 18:47:13', '2026-05-17 18:55:57', NULL, '40e1ed24c3cce28e2420150f1fac8fbd', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||||
|
|||||||
@@ -153,6 +153,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
|
- Toolchain no longer resolves the dugite git binary against the open
|
||||||
|
workspace — a malicious repo could plant `native/dugite/bin/git`
|
||||||
|
and clide would run it on auto-fired `git.status`. Dugite now
|
||||||
|
resolves against the install dir + `CLIDE_DUGITE_DIR` env override
|
||||||
|
only (T-98).
|
||||||
- `files.read` and `files.ls` now reject paths that resolve outside
|
- `files.read` and `files.ls` now reject paths that resolve outside
|
||||||
the workspace root. Previously a relative path containing `..`
|
the workspace root. Previously a relative path containing `..`
|
||||||
could read arbitrary files via path traversal.
|
could read arbitrary files via path traversal.
|
||||||
|
|||||||
@@ -45,11 +45,10 @@ void backendEntry(BackendBootMessage boot) {
|
|||||||
late Toolchain toolchain;
|
late Toolchain toolchain;
|
||||||
|
|
||||||
// Phase 1: resolve toolchain — just find binaries, don't init services.
|
// Phase 1: resolve toolchain — just find binaries, don't init services.
|
||||||
// We need a project root for dugite paths. Use a sensible default;
|
// Dugite is resolved against the install dir; per T-98 the project
|
||||||
// the real project comes from project.open.
|
// root is never inspected during toolchain resolution.
|
||||||
final resolveRoot = boot.hintRoot ?? Platform.environment['HOME'] ?? '/tmp';
|
|
||||||
toolchain = Toolchain();
|
toolchain = Toolchain();
|
||||||
toolchain.applyResolved(resolveToolchainPaths(resolveRoot));
|
toolchain.applyResolved(resolveToolchainPaths());
|
||||||
|
|
||||||
// Listen for messages from the frontend.
|
// Listen for messages from the frontend.
|
||||||
requestPort.listen((message) async {
|
requestPort.listen((message) async {
|
||||||
@@ -77,10 +76,10 @@ void backendEntry(BackendBootMessage boot) {
|
|||||||
final projectPath = message['path'] as String;
|
final projectPath = message['path'] as String;
|
||||||
final workDir = Directory(projectPath);
|
final workDir = Directory(projectPath);
|
||||||
|
|
||||||
// Re-resolve toolchain with the actual project root (finds
|
// Re-resolve toolchain. Project path is not inspected (T-98);
|
||||||
// dugite in native/dugite/, etc.)
|
// dugite still comes from the install dir + env override.
|
||||||
toolchain = Toolchain();
|
toolchain = Toolchain();
|
||||||
toolchain.applyResolved(resolveToolchainPaths(projectPath));
|
toolchain.applyResolved(resolveToolchainPaths());
|
||||||
|
|
||||||
// Clear existing handlers and re-register with new project.
|
// Clear existing handlers and re-register with new project.
|
||||||
dispatcher.clear();
|
dispatcher.clear();
|
||||||
|
|||||||
@@ -78,6 +78,6 @@ class Toolchain extends ChangeNotifier implements ToolchainView {
|
|||||||
|
|
||||||
/// Pure function — runs in a background isolate. All file I/O happens
|
/// Pure function — runs in a background isolate. All file I/O happens
|
||||||
/// here, off the main thread. Delegates to the Flutter-free
|
/// here, off the main thread. Delegates to the Flutter-free
|
||||||
/// [resolveToolchainPaths].
|
/// [resolveToolchainPaths]. Takes no workspace argument: see T-98.
|
||||||
static ResolvedPaths resolvePaths({required String workspaceRoot}) => resolveToolchainPaths(workspaceRoot);
|
static ResolvedPaths resolvePaths() => resolveToolchainPaths();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,14 +73,18 @@ class _StaticToolchain implements ToolchainView {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Top-level function for compute/isolate use. Takes a single String
|
/// Top-level function for compute/isolate use. Returns a plain-data
|
||||||
/// argument (the workspace root) and returns a plain-data result.
|
/// result with all tool paths resolved against trusted locations only.
|
||||||
ResolvedPaths resolveToolchainPaths(String workspaceRoot) {
|
///
|
||||||
final dugite = '$workspaceRoot/native/dugite/bin';
|
/// Critically does NOT take a workspace path: per T-98, resolving the
|
||||||
|
/// dugite-bundled git against the open workspace was a code-execution
|
||||||
|
/// vector (a malicious repo could plant `native/dugite/bin/git`).
|
||||||
|
/// Dugite is resolved against the install directory + an explicit env
|
||||||
|
/// override; everything else comes from PATH.
|
||||||
|
ResolvedPaths resolveToolchainPaths() {
|
||||||
String? git;
|
String? git;
|
||||||
Map<String, String>? gitEnv;
|
Map<String, String>? gitEnv;
|
||||||
final dugiteGit = _firstExisting(['$dugite/git']);
|
final dugiteGit = _resolveDugiteGit();
|
||||||
if (dugiteGit != null) {
|
if (dugiteGit != null) {
|
||||||
git = dugiteGit;
|
git = dugiteGit;
|
||||||
final dugiteRoot = File(dugiteGit).parent.parent.path;
|
final dugiteRoot = File(dugiteGit).parent.parent.path;
|
||||||
@@ -101,6 +105,29 @@ ResolvedPaths resolveToolchainPaths(String workspaceRoot) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Locate the dugite-bundled git binary in trusted install locations
|
||||||
|
/// only. **Never inspects workspace-relative paths** — see T-98.
|
||||||
|
///
|
||||||
|
/// Search order:
|
||||||
|
/// 1. `CLIDE_DUGITE_DIR` env var (dev override; points at a dugite
|
||||||
|
/// root that contains `bin/git`).
|
||||||
|
/// 2. `<exe-parent>/dugite/bin/git` — production bundle layout.
|
||||||
|
/// 3. `<exe-parent>/lib/dugite/bin/git` — alternate bundle layout
|
||||||
|
/// (mirrors Linux's INSTALL_BUNDLE_LIB_DIR convention).
|
||||||
|
///
|
||||||
|
/// Returns null if no dugite is found; caller falls back to PATH git.
|
||||||
|
String? _resolveDugiteGit() {
|
||||||
|
final candidates = <String>[];
|
||||||
|
final envDir = Platform.environment['CLIDE_DUGITE_DIR'];
|
||||||
|
if (envDir != null && envDir.isNotEmpty) {
|
||||||
|
candidates.add('$envDir/bin/git');
|
||||||
|
}
|
||||||
|
final exeDir = File(Platform.resolvedExecutable).parent.path;
|
||||||
|
candidates.add('$exeDir/dugite/bin/git');
|
||||||
|
candidates.add('$exeDir/lib/dugite/bin/git');
|
||||||
|
return _firstExisting(candidates);
|
||||||
|
}
|
||||||
|
|
||||||
String? _findOnPath(String name) {
|
String? _findOnPath(String name) {
|
||||||
for (final dir in _expandedPath().split(':')) {
|
for (final dir in _expandedPath().split(':')) {
|
||||||
if (dir.isEmpty) continue;
|
if (dir.isEmpty) continue;
|
||||||
|
|||||||
+1
-3
@@ -65,9 +65,7 @@ Future<void> main() async {
|
|||||||
// With proper signing (Developer ID), no sandbox or isolate needed.
|
// With proper signing (Developer ID), no sandbox or isolate needed.
|
||||||
final toolchain = Toolchain();
|
final toolchain = Toolchain();
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
const workspace = String.fromEnvironment('CLIDE_PROJECT');
|
toolchain.applyResolved(resolveToolchainPaths());
|
||||||
final root = workspace.isNotEmpty ? workspace : Directory.current.path;
|
|
||||||
toolchain.applyResolved(resolveToolchainPaths(root));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
InProcessClient? ipcClient;
|
InProcessClient? ipcClient;
|
||||||
|
|||||||
+4
-4
@@ -86,7 +86,7 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
|||||||
_say('');
|
_say('');
|
||||||
|
|
||||||
final tc = Toolchain();
|
final tc = Toolchain();
|
||||||
tc.applyResolved(Toolchain.resolvePaths(workspaceRoot: workDir));
|
tc.applyResolved(Toolchain.resolvePaths());
|
||||||
|
|
||||||
if (runToolchain) await _runToolchainTests(tc, workDir);
|
if (runToolchain) await _runToolchainTests(tc, workDir);
|
||||||
if (runIpc) await _runIpcTests(workDir);
|
if (runIpc) await _runIpcTests(workDir);
|
||||||
@@ -159,12 +159,12 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
|||||||
_say('--- boot sequence ---');
|
_say('--- boot sequence ---');
|
||||||
|
|
||||||
await _testAsync('compute(resolveToolchainPaths)', () async {
|
await _testAsync('compute(resolveToolchainPaths)', () async {
|
||||||
final paths = await compute(resolveToolchainPaths, workDir);
|
final paths = await compute((_) => resolveToolchainPaths(), null);
|
||||||
return 'git=${paths.git} pql=${paths.pql}';
|
return 'git=${paths.git} pql=${paths.pql}';
|
||||||
});
|
});
|
||||||
|
|
||||||
await _testAsync('Isolate.run(resolveToolchainPaths)', () async {
|
await _testAsync('Isolate.run(resolveToolchainPaths)', () async {
|
||||||
final paths = await Isolate.run(() => resolveToolchainPaths(workDir));
|
final paths = await Isolate.run(resolveToolchainPaths);
|
||||||
return 'git=${paths.git} pql=${paths.pql}';
|
return 'git=${paths.git} pql=${paths.pql}';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ class _ClideTestAppState extends State<ClideTestApp> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await _testAsync('compute + immediate Process.run', () async {
|
await _testAsync('compute + immediate Process.run', () async {
|
||||||
final paths = await compute(resolveToolchainPaths, workDir);
|
final paths = await compute((_) => resolveToolchainPaths(), null);
|
||||||
final tc2 = Toolchain();
|
final tc2 = Toolchain();
|
||||||
tc2.applyResolved(paths);
|
tc2.applyResolved(paths);
|
||||||
final r = await Process.run(tc2.git, ['rev-parse', '--show-toplevel'], workingDirectory: workDir, environment: tc2.gitEnv);
|
final r = await Process.run(tc2.git, ['rev-parse', '--show-toplevel'], workingDirectory: workDir, environment: tc2.gitEnv);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ void main() {
|
|||||||
|
|
||||||
sink = RecordingEventSink();
|
sink = RecordingEventSink();
|
||||||
dispatcher = DaemonDispatcher();
|
dispatcher = DaemonDispatcher();
|
||||||
final toolchain = ToolchainView.resolved(resolveToolchainPaths(sandbox.path));
|
final toolchain = ToolchainView.resolved(resolveToolchainPaths());
|
||||||
final gitClient = GitClient(toolchain: toolchain, workDir: sandbox);
|
final gitClient = GitClient(toolchain: toolchain, workDir: sandbox);
|
||||||
registerGitCommands(dispatcher, gitClient, sink);
|
registerGitCommands(dispatcher, gitClient, sink);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ void main() {
|
|||||||
late PqlClient pql;
|
late PqlClient pql;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
final toolchain = ToolchainView.resolved(resolveToolchainPaths(Directory.current.path));
|
final toolchain = ToolchainView.resolved(resolveToolchainPaths());
|
||||||
pql = PqlClient(workDir: Directory.current, toolchain: toolchain);
|
pql = PqlClient(workDir: Directory.current, toolchain: toolchain);
|
||||||
dispatcher = DaemonDispatcher();
|
dispatcher = DaemonDispatcher();
|
||||||
registerPqlCommands(dispatcher, pql);
|
registerPqlCommands(dispatcher, pql);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import 'package:clide/src/git/client.dart';
|
|||||||
import 'package:clide/src/git/operations.dart' show GitException;
|
import 'package:clide/src/git/operations.dart' show GitException;
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
ToolchainView _toolchain() => ToolchainView.resolved(resolveToolchainPaths(Directory.current.path));
|
ToolchainView _toolchain() => ToolchainView.resolved(resolveToolchainPaths());
|
||||||
|
|
||||||
Future<Directory> _newRepo({String filename = 'file.txt', String contents = 'hello\n'}) async {
|
Future<Directory> _newRepo({String filename = 'file.txt', String contents = 'hello\n'}) async {
|
||||||
final dir = await Directory.systemTemp.createTemp('clide-git-client-');
|
final dir = await Directory.systemTemp.createTemp('clide-git-client-');
|
||||||
|
|||||||
@@ -63,45 +63,59 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('Toolchain.resolvePaths (static)', () {
|
group('Toolchain.resolvePaths (static)', () {
|
||||||
test('returns a ResolvedPaths against the current workspace', () {
|
test('returns a ResolvedPaths with pql resolved from PATH', () {
|
||||||
final paths = Toolchain.resolvePaths(workspaceRoot: Directory.current.path);
|
final paths = Toolchain.resolvePaths();
|
||||||
// Whatever was found, the result must be a ResolvedPaths.
|
|
||||||
expect(paths, isA<ResolvedPaths>());
|
expect(paths, isA<ResolvedPaths>());
|
||||||
// On this CI host pql is installed (per repo memory).
|
// On this CI host pql is installed (per repo memory).
|
||||||
expect(paths.pql, isNotNull);
|
expect(paths.pql, isNotNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('uses the dugite git when present in the workspace', () {
|
test('git falls back to PATH when no install-dir dugite is found', () {
|
||||||
// The clide repo bundles dugite under native/dugite/bin/git.
|
final paths = Toolchain.resolvePaths();
|
||||||
final paths = Toolchain.resolvePaths(workspaceRoot: Directory.current.path);
|
// No dugite is bundled next to the test runner binary, so git
|
||||||
final dugitePath = '${Directory.current.path}/native/dugite/bin/git';
|
// resolves via PATH; gitEnv stays null because dugite paths are
|
||||||
if (File(dugitePath).existsSync()) {
|
// not in effect.
|
||||||
expect(paths.git, dugitePath);
|
|
||||||
expect(paths.gitEnv?['GIT_EXEC_PATH'], isNotNull);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('falls back to PATH git when no dugite is present', () {
|
|
||||||
final paths = Toolchain.resolvePaths(workspaceRoot: '/tmp/clide-no-dugite-${DateTime.now().microsecondsSinceEpoch}');
|
|
||||||
// Either PATH git or null — the point is that gitEnv is null when
|
|
||||||
// not using dugite.
|
|
||||||
if (paths.git != null) {
|
if (paths.git != null) {
|
||||||
expect(paths.gitEnv, isNull);
|
expect(paths.gitEnv, isNull);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('resolveToolchainPaths (top-level, for isolates)', () {
|
group('resolveToolchainPaths — security (T-98)', () {
|
||||||
test('matches Toolchain.resolvePaths shape', () {
|
test('does NOT execute a planted git in the open workspace', () async {
|
||||||
final viaStatic = Toolchain.resolvePaths(workspaceRoot: Directory.current.path);
|
// Plant a fake dugite tree inside a temp dir that mimics what a
|
||||||
final viaTopLevel = resolveToolchainPaths(Directory.current.path);
|
// malicious repo could ship. The old code would resolve
|
||||||
// Both must agree on the pql binary (or both null if missing).
|
// `<workspaceRoot>/native/dugite/bin/git` as the git binary.
|
||||||
expect(viaTopLevel.pql, viaStatic.pql);
|
final tmp = await Directory.systemTemp.createTemp('clide_t98_workspace_');
|
||||||
|
addTearDown(() async {
|
||||||
|
if (await tmp.exists()) await tmp.delete(recursive: true);
|
||||||
|
});
|
||||||
|
final plantedBin = Directory('${tmp.path}/native/dugite/bin')..createSync(recursive: true);
|
||||||
|
final plantedGit = File('${plantedBin.path}/git')..writeAsStringSync('#!/bin/sh\nexit 99\n');
|
||||||
|
// chmod+x so the file is executable — exercises the worst case.
|
||||||
|
await Process.run('chmod', ['+x', plantedGit.path]);
|
||||||
|
expect(plantedGit.existsSync(), isTrue, reason: 'planted git must exist for the test to be meaningful');
|
||||||
|
|
||||||
|
final paths = resolveToolchainPaths();
|
||||||
|
|
||||||
|
// The resolved git binary must never be inside the workspace.
|
||||||
|
// Belt-and-suspenders: also assert it doesn't equal the planted
|
||||||
|
// path verbatim.
|
||||||
|
expect(paths.git, isNot(startsWith(tmp.path)));
|
||||||
|
expect(paths.git, isNot(plantedGit.path));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns a ResolvedPaths for an arbitrary path', () {
|
test('CLIDE_DUGITE_DIR env var is honored as an install-dir override', () {
|
||||||
final paths = resolveToolchainPaths('/tmp/clide-arbitrary');
|
// We can't mutate Platform.environment from a test, so we just
|
||||||
expect(paths, isA<ResolvedPaths>());
|
// assert the *contract* by checking the behavior in absence of
|
||||||
|
// the var. The env-var branch is documented and exercised by the
|
||||||
|
// dev workflow `make run` when CLIDE_DUGITE_DIR is set.
|
||||||
|
// The negative case: no env var → no workspace lookup → git
|
||||||
|
// resolves via PATH only.
|
||||||
|
final paths = resolveToolchainPaths();
|
||||||
|
if (paths.git != null) {
|
||||||
|
expect(paths.gitEnv, isNull, reason: 'gitEnv must be null unless dugite is found in a trusted location');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import 'package:test/test.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
late PqlClient pql;
|
late PqlClient pql;
|
||||||
setUp(() {
|
setUp(() {
|
||||||
final toolchain = ToolchainView.resolved(resolveToolchainPaths(Directory.current.path));
|
final toolchain = ToolchainView.resolved(resolveToolchainPaths());
|
||||||
pql = PqlClient(workDir: Directory.current, toolchain: toolchain);
|
pql = PqlClient(workDir: Directory.current, toolchain: toolchain);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user