The-Agency/lib/src/utils/bash/command_splitter.dart

265 lines
7.6 KiB
Dart

// Dart port of splitCommand_DEPRECATED from old_repo/utils/bash/commands.ts.
//
// Splits a bash compound command into individual subcommands by tokenizing
// the input the same way a shell-quote parser would — respecting single/double
// quotes, backslash escapes, and the standard control operators. Redirections
// (>, >>, >&, 2>&1, etc.) are stripped so they don't appear as separate
// "commands" in the permission-prompt list.
const _controlOperators = {
'&&', '||', ';', ';;', '|',
};
// Control + redirection operators that are filtered out when building the final
// subcommand list.
const _allSupportedOperators = {
'&&', '||', ';', ';;', '|', '>&', '>', '>>',
};
const _allowedFileDescriptors = {'0', '1', '2'};
/// Checks whether a redirect target is a plain static path (no shell expansion).
bool _isStaticRedirectTarget(String target) {
if (target.contains(RegExp(r"[\s'" + '"' + "]"))) return false;
if (target.isEmpty) return false;
if (target.startsWith('#')) return false;
return !target.startsWith('!') &&
!target.startsWith('=') &&
!target.contains('\$') &&
!target.contains('`') &&
!target.contains('*') &&
!target.contains('?') &&
!target.contains('[') &&
!target.contains('{') &&
!target.contains('~') &&
!target.contains('(') &&
!target.contains('<') &&
!target.startsWith('&');
}
/// Tokenizes a bash command string into a flat list of token strings and
/// operator strings. Handles single-quoted, double-quoted, and
/// backslash-escaped input. Newlines outside quotes are treated as ;
/// separators (same as bash IFS behaviour in non-interactive mode).
///
/// Returns each token or operator as a separate list element.
List<String> _tokenize(String command) {
final tokens = <String>[];
final buf = StringBuffer();
int i = 0;
final len = command.length;
void flush() {
final s = buf.toString();
if (s.isNotEmpty) {
tokens.add(s);
buf.clear();
}
}
while (i < len) {
final c = command[i];
if (c == '\\' && i + 1 < len) {
// backslash-newline: line continuation — skip both chars
if (command[i + 1] == '\n') {
i += 2;
continue;
}
// otherwise backslash-escape: absorb both chars into current token
buf.write(c);
buf.write(command[i + 1]);
i += 2;
continue;
}
if (c == "'") {
// single-quoted: everything literal until next '
buf.write(c);
i++;
while (i < len && command[i] != "'") {
buf.write(command[i]);
i++;
}
if (i < len) {
buf.write(command[i]); // closing '
i++;
}
continue;
}
if (c == '"') {
// double-quoted: allow \x escapes but nothing else is special
buf.write(c);
i++;
while (i < len && command[i] != '"') {
if (command[i] == '\\' && i + 1 < len) {
buf.write(command[i]);
buf.write(command[i + 1]);
i += 2;
} else {
buf.write(command[i]);
i++;
}
}
if (i < len) {
buf.write(command[i]); // closing "
i++;
}
continue;
}
// Newline outside quotes acts as a command separator
if (c == '\n') {
flush();
tokens.add(';'); // treat as ;
i++;
continue;
}
// Whitespace separates tokens
if (c == ' ' || c == '\t') {
flush();
i++;
continue;
}
// check for two-char operators first: &&, ||, >>, >&, ;;
if (i + 1 < len) {
final two = command.substring(i, i + 2);
if (two == '&&' || two == '||' || two == '>>' || two == '>&' || two == ';;') {
flush();
tokens.add(two);
i += 2;
continue;
}
}
// single-char operators: ;, |, >
if (c == ';' || c == '|' || c == '>') {
flush();
tokens.add(c);
i++;
continue;
}
// Comment — skip to end of line
if (c == '#' && buf.isEmpty) {
while (i < len && command[i] != '\n') {
i++;
}
continue;
}
buf.write(c);
i++;
}
flush();
return tokens;
}
/// Splits a compound bash command into individual subcommands by tokenizing
/// and then stripping redirections and filtering out control operators.
///
/// This is the Dart equivalent of splitCommand_DEPRECATED in commands.ts.
List<String> splitCommand(String command) {
final tokens = _tokenize(command);
// Strip output redirections (>, >>, >&, 2>&1 etc.)
// Same logic as the JS implementation — mark tokens for removal.
final parts = List<String?>.from(tokens);
for (int i = 0; i < parts.length; i++) {
final part = parts[i];
if (part == null) continue;
if (part == '>&' || part == '>' || part == '>>') {
final prevPart = i > 0 ? parts[i - 1]?.trimRight() : null;
final nextPart = i + 1 < parts.length ? parts[i + 1]?.trim() : null;
final afterNext = i + 2 < parts.length ? parts[i + 2]?.trim() : null;
if (nextPart == null) continue;
bool shouldStrip = false;
bool stripThird = false;
if (part == '>&' && _allowedFileDescriptors.contains(nextPart)) {
// 2>&1 style
shouldStrip = true;
} else if (part == '>' &&
nextPart == '&' &&
afterNext != null &&
_allowedFileDescriptors.contains(afterNext)) {
shouldStrip = true;
stripThird = true;
} else if (part == '>' &&
nextPart.startsWith('&') &&
nextPart.length > 1 &&
_allowedFileDescriptors.contains(nextPart.substring(1))) {
shouldStrip = true;
} else if ((part == '>' || part == '>>') && _isStaticRedirectTarget(nextPart)) {
shouldStrip = true;
}
if (shouldStrip) {
// strip trailing file descriptor from previous token (e.g., '2' from 'echo foo 2')
if (prevPart != null &&
prevPart.length >= 3 &&
_allowedFileDescriptors.contains(prevPart[prevPart.length - 1]) &&
prevPart[prevPart.length - 2] == ' ') {
parts[i - 1] = prevPart.substring(0, prevPart.length - 2);
}
parts[i] = null;
parts[i + 1] = null;
if (stripThird && i + 2 < parts.length) {
parts[i + 2] = null;
}
}
}
}
// Filter nulls and empty strings, then filter control operators
return parts
.where((p) => p != null && p.isNotEmpty)
.cast<String>()
.where((p) => !_controlOperators.contains(p))
.where((p) => !_allSupportedOperators.contains(p))
.toList();
}
/// Returns true when the command contains a cd subcommand (used by the
/// cd + git security check in pathValidation).
bool commandHasAnyCd(String command) {
final subs = splitCommand(command);
return subs.any((s) {
final trimmed = s.trim();
return trimmed == 'cd' ||
trimmed.startsWith('cd ') ||
trimmed.startsWith('cd\t');
});
}
/// Returns true when the given subcommand (already split) is a plain cd invocation.
bool isNormalizedCdCommand(String command) {
final trimmed = command.trim();
return trimmed == 'cd' ||
trimmed.startsWith('cd ') ||
trimmed.startsWith('cd\t');
}
/// Returns true when the given subcommand is a git invocation
/// (with optional leading safe env var assignments stripped off).
bool isNormalizedGitCommand(String command) {
// strip safe env vars at the front — simple heuristic
var trimmed = command.trim();
while (true) {
final m = RegExp(r'^[A-Za-z_]\w*=[^ \t]* ').firstMatch(trimmed);
if (m == null) break;
trimmed = trimmed.substring(m.end);
}
return trimmed == 'git' ||
trimmed.startsWith('git ') ||
trimmed.startsWith('git\t');
}