// Bash AST 解析器的完整测试. // // 覆盖场景: // - 基础命令解析 // - 引号处理(单引号,双引号,ANSI-C 引号) // - 环境变量赋值前缀 // - 管道和列表操作符 // - 子 shell // - 命令替换 // - Heredoc // - 重定向 // - 复合命令 // - 算术展开 vs heredoc 区分 // - 进程替换 // - 变量展开 // - 边界情况和安全相关 edge case package bash import ( "strings" "testing" "time" ) // ================== AST 解析器基础测试 ================== func TestParseBasicCommand(t *testing.T) { tests := []struct { name string input string wantCmd string // 期望的第一个命令名 wantN int // 期望的子节点数量(Program 层) }{ {"简单命令", "ls -la", "ls", 1}, {"echo 多参数", "echo hello world", "echo", 1}, {"空输入", "", "", 0}, {"只有空白", " ", "", 0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { root := Parse(tt.input) if root == nil { t.Fatal("Parse 返回 nil") } if root.Type != NodeProgram { t.Fatalf("根节点类型: %v, 期望 Program", root.Type) } // 过滤掉注释节点 stmts := filterChildren(root, NodeComment) if len(stmts) != tt.wantN { t.Fatalf("子节点数量: %d, 期望 %d", len(stmts), tt.wantN) } if tt.wantN > 0 { cmds := ExtractCommands(root) if len(cmds) == 0 { t.Fatal("ExtractCommands 未提取到命令") } if cmds[0].Name != tt.wantCmd { t.Errorf("命令名: %q, 期望 %q", cmds[0].Name, tt.wantCmd) } } }) } } func TestParseCommandWithArgs(t *testing.T) { root := Parse("ls -la /tmp") cmds := ExtractCommands(root) if len(cmds) != 1 { t.Fatalf("命令数量: %d, 期望 1", len(cmds)) } cmd := cmds[0] if cmd.Name != "ls" { t.Errorf("命令名: %q, 期望 ls", cmd.Name) } if len(cmd.Args) != 2 { t.Fatalf("参数数量: %d, 期望 2, 实际: %v", len(cmd.Args), cmd.Args) } if cmd.Args[0] != "-la" { t.Errorf("参数[0]: %q, 期望 -la", cmd.Args[0]) } if cmd.Args[1] != "/tmp" { t.Errorf("参数[1]: %q, 期望 /tmp", cmd.Args[1]) } } // ================== 引号处理测试 ================== func TestParseQuotedStrings(t *testing.T) { tests := []struct { name string input string wantArgs []string // 期望的参数列表(不含命令名) }{ { "双引号", `echo "hello world"`, []string{"hello world"}, }, { "单引号", `echo 'hello world'`, []string{"hello world"}, }, { "混合引号", `echo "hello" 'world'`, []string{"hello", "world"}, }, { "空双引号", `echo ""`, []string{""}, }, { "空单引号", `echo ''`, []string{""}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { root := Parse(tt.input) cmds := ExtractCommands(root) if len(cmds) != 1 { t.Fatalf("命令数量: %d, 期望 1", len(cmds)) } if len(cmds[0].Args) != len(tt.wantArgs) { t.Fatalf("参数数量: %d, 期望 %d\n 实际: %v", len(cmds[0].Args), len(tt.wantArgs), cmds[0].Args) } for i, arg := range cmds[0].Args { if arg != tt.wantArgs[i] { t.Errorf("参数[%d]: %q, 期望 %q", i, arg, tt.wantArgs[i]) } } }) } } func TestParseAnsiCQuoted(t *testing.T) { // ANSI-C 引号 $'text' root := Parse(`echo $'tab\there'`) cmds := ExtractCommands(root) if len(cmds) != 1 { t.Fatalf("命令数量: %d, 期望 1", len(cmds)) } // ANSI-C 引号内容保留原始文本(解析器不求值转义序列) if len(cmds[0].Args) != 1 { t.Fatalf("参数数量: %d, 期望 1", len(cmds[0].Args)) } // 值应包含 tab\there(原始文本,去除 $' 和 ') arg := cmds[0].Args[0] if !strings.Contains(arg, "tab") { t.Errorf("参数应包含 'tab': %q", arg) } } func TestParseSingleQuoteWorkaround(t *testing.T) { // 'it'\''s fine' - 三段拼接 root := Parse(`echo 'it'\''s fine'`) cmds := ExtractCommands(root) if len(cmds) != 1 { t.Fatalf("命令数量: %d, 期望 1", len(cmds)) } // 三段拼接在 word 层面会被合并为一个参数 if len(cmds[0].Args) < 1 { t.Fatal("没有参数") } } // ================== 环境变量赋值前缀测试 ================== func TestParseAssignmentPrefix(t *testing.T) { tests := []struct { name string input string wantCmd string wantAssignN int wantAssignName string }{ { "单个赋值", "VAR=value command arg", "command", 1, "VAR", }, { "多个赋值", "A=1 B=2 npm start", "npm", 2, "A", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { root := Parse(tt.input) cmds := ExtractCommands(root) if len(cmds) != 1 { t.Fatalf("命令数量: %d, 期望 1", len(cmds)) } cmd := cmds[0] if cmd.Name != tt.wantCmd { t.Errorf("命令名: %q, 期望 %q", cmd.Name, tt.wantCmd) } if len(cmd.Assignments) != tt.wantAssignN { t.Fatalf("赋值数量: %d, 期望 %d", len(cmd.Assignments), tt.wantAssignN) } if cmd.Assignments[0].Name != tt.wantAssignName { t.Errorf("赋值名: %q, 期望 %q", cmd.Assignments[0].Name, tt.wantAssignName) } }) } } func TestParseEnvPrefix(t *testing.T) { root := Parse("env VAR=x command arg") cmds := ExtractCommands(root) if len(cmds) != 1 { t.Fatalf("命令数量: %d, 期望 1", len(cmds)) } cmd := cmds[0] // env 是命令名,VAR=x 是参数(不是赋值前缀,因为 env 后面的赋值由 env 处理) // 通过 ExtractCommandName 跳过 env 前缀 realCmd, _ := ExtractCommandName(cmd) if realCmd != "command" { t.Errorf("真正的命令名: %q, 期望 command", realCmd) } } func TestParseSudoPrefix(t *testing.T) { root := Parse("sudo -u user command arg") cmds := ExtractCommands(root) if len(cmds) != 1 { t.Fatalf("命令数量: %d, 期望 1", len(cmds)) } cmd := cmds[0] realCmd, _ := ExtractCommandName(cmd) if realCmd != "command" { t.Errorf("真正的命令名: %q, 期望 command", realCmd) } } // ================== 管道和列表测试 ================== func TestParsePipeline(t *testing.T) { root := Parse("cmd1 | cmd2 | cmd3") cmds := ExtractCommands(root) if len(cmds) != 3 { t.Fatalf("命令数量: %d, 期望 3", len(cmds)) } for i, name := range []string{"cmd1", "cmd2", "cmd3"} { if cmds[i].Name != name { t.Errorf("命令[%d]: %q, 期望 %q", i, cmds[i].Name, name) } if !cmds[i].InPipeline { t.Errorf("命令[%d] 应该在管道中", i) } if cmds[i].PipePosition != i { t.Errorf("命令[%d] 管道位置: %d, 期望 %d", i, cmds[i].PipePosition, i) } } } func TestParseListAndOr(t *testing.T) { root := Parse("cmd1 && cmd2 || cmd3") cmds := ExtractCommands(root) if len(cmds) != 3 { t.Fatalf("命令数量: %d, 期望 3", len(cmds)) } if cmds[0].Name != "cmd1" { t.Errorf("命令[0]: %q, 期望 cmd1", cmds[0].Name) } if cmds[1].Name != "cmd2" { t.Errorf("命令[1]: %q, 期望 cmd2", cmds[1].Name) } if cmds[2].Name != "cmd3" { t.Errorf("命令[2]: %q, 期望 cmd3", cmds[2].Name) } } func TestParseSequential(t *testing.T) { root := Parse("cmd1; cmd2; cmd3") cmds := ExtractCommands(root) if len(cmds) != 3 { t.Fatalf("命令数量: %d, 期望 3", len(cmds)) } for i, name := range []string{"cmd1", "cmd2", "cmd3"} { if cmds[i].Name != name { t.Errorf("命令[%d]: %q, 期望 %q", i, cmds[i].Name, name) } } } // ================== 子 shell 测试 ================== func TestParseSubshell(t *testing.T) { root := Parse("(cd /tmp && rm -rf *)") cmds := ExtractCommands(root) if len(cmds) != 2 { t.Fatalf("命令数量: %d, 期望 2", len(cmds)) } if cmds[0].Name != "cd" { t.Errorf("命令[0]: %q, 期望 cd", cmds[0].Name) } if cmds[1].Name != "rm" { t.Errorf("命令[1]: %q, 期望 rm", cmds[1].Name) } // 子 shell 中的命令应标记 InSubshell for _, cmd := range cmds { if !cmd.InSubshell { t.Errorf("命令 %q 应标记为 InSubshell", cmd.Name) } } } func TestParseCommandSubstitution(t *testing.T) { root := Parse("echo $(date +%Y)") cmds := ExtractCommands(root) if len(cmds) != 1 { t.Fatalf("命令数量: %d, 期望 1", len(cmds)) } if cmds[0].Name != "echo" { t.Errorf("命令名: %q, 期望 echo", cmds[0].Name) } } // ================== ExtractAllCommands 测试 ================== func TestExtractAllCommandsSimple(t *testing.T) { // 无命令替换时,结果与 ExtractCommands 一致 root := Parse("ls -la") cmds := ExtractAllCommands(root) if len(cmds) != 1 { t.Fatalf("命令数量: %d, 期望 1", len(cmds)) } if cmds[0].Name != "ls" { t.Errorf("命令名: %q, 期望 ls", cmds[0].Name) } } func TestExtractAllCommandsNestedDollarParen(t *testing.T) { // echo $(rm -rf /) 应同时提取 echo 和 rm root := Parse("echo $(rm -rf /)") cmds := ExtractAllCommands(root) names := make([]string, 0, len(cmds)) for _, c := range cmds { names = append(names, c.Name) } foundEcho := false foundRm := false for _, n := range names { if n == "echo" { foundEcho = true } if n == "rm" { foundRm = true } } if !foundEcho { t.Errorf("未提取到 echo,提取到: %v", names) } if !foundRm { t.Errorf("未提取到命令替换内的 rm,提取到: %v", names) } } func TestExtractAllCommandsNestedBacktick(t *testing.T) { // echo `rm -rf /` 反引号形式同样能递归提取 root := Parse("echo `rm -rf /`") cmds := ExtractAllCommands(root) names := make([]string, 0, len(cmds)) for _, c := range cmds { names = append(names, c.Name) } foundRm := false for _, n := range names { if n == "rm" { foundRm = true } } if !foundRm { t.Errorf("未提取到反引号内的 rm,提取到: %v", names) } } func TestExtractAllCommandsDoubleNested(t *testing.T) { // echo $(echo $(rm -rf /)) 双重嵌套,rm 也能被提取 root := Parse("echo $(echo $(rm -rf /))") cmds := ExtractAllCommands(root) names := make([]string, 0, len(cmds)) for _, c := range cmds { names = append(names, c.Name) } foundRm := false for _, n := range names { if n == "rm" { foundRm = true } } if !foundRm { t.Errorf("未提取到双重嵌套内的 rm,提取到: %v", names) } } func TestStripCommandSubstitution(t *testing.T) { tests := []struct { input string expected string }{ {"$(rm -rf /)", "rm -rf /"}, {"`rm -rf /`", "rm -rf /"}, {"rm -rf /", "rm -rf /"}, // 无外壳,原样返回 {"$( ls )", "ls"}, // 内部空白修剪 {"", ""}, } for _, tt := range tests { got := stripCommandSubstitution(tt.input) if got != tt.expected { t.Errorf("stripCommandSubstitution(%q) = %q, 期望 %q", tt.input, got, tt.expected) } } } // ================== Heredoc 测试 ================== func TestParseHeredoc(t *testing.T) { input := "cat < len(tt.source) || hd.BodyStart > hd.BodyEnd { t.Errorf("heredoc[%d] offsets out of range: [%d,%d) len(source)=%d", i, hd.BodyStart, hd.BodyEnd, len(tt.source)) continue } if got := tt.source[hd.BodyStart:hd.BodyEnd]; got != hd.Body { t.Errorf("heredoc[%d] source[%d:%d] = %q, want Body = %q", i, hd.BodyStart, hd.BodyEnd, got, hd.Body) } } }) } } func TestPreprocessHeredocs(t *testing.T) { tests := []struct { name string input string wantN int wantTag string wantBody string }{ { "基础 heredoc", "cat < 0 { hd := heredocs[0] if hd.Tag != tt.wantTag { t.Errorf("tag: %q, 期望 %q", hd.Tag, tt.wantTag) } if hd.Body != tt.wantBody { t.Errorf("body: %q, 期望 %q", hd.Body, tt.wantBody) } } }) } } // ================== 重定向测试 ================== func TestParseRedirection(t *testing.T) { tests := []struct { name string input string wantCmd string wantRedirN int wantOp string wantTarget string }{ { "输出重定向", "echo hello > file.txt", "echo", 1, ">", "file.txt", }, { "追加重定向", "echo hello >> file.txt", "echo", 1, ">>", "file.txt", }, { "输入重定向", "sort < data.txt", "sort", 1, "<", "data.txt", }, { "stderr 重定向", "cmd 2>/dev/null", "cmd", 1, "2>", "/dev/null", }, { "stdout+stderr 重定向", "cmd &>/dev/null", "cmd", 1, "&>", "/dev/null", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { root := Parse(tt.input) cmds := ExtractCommands(root) if len(cmds) != 1 { t.Fatalf("命令数量: %d, 期望 1", len(cmds)) } cmd := cmds[0] if cmd.Name != tt.wantCmd { t.Errorf("命令名: %q, 期望 %q", cmd.Name, tt.wantCmd) } if len(cmd.Redirections) != tt.wantRedirN { t.Fatalf("重定向数量: %d, 期望 %d", len(cmd.Redirections), tt.wantRedirN) } if cmd.Redirections[0].Operator != tt.wantOp { t.Errorf("操作符: %q, 期望 %q", cmd.Redirections[0].Operator, tt.wantOp) } if cmd.Redirections[0].Target != tt.wantTarget { t.Errorf("目标: %q, 期望 %q", cmd.Redirections[0].Target, tt.wantTarget) } }) } } func TestParseStaticRedirectTarget(t *testing.T) { // 静态目标 if !IsStaticRedirectTarget("file.txt") { t.Error("file.txt 应该是静态目标") } if !IsStaticRedirectTarget("/dev/null") { t.Error("/dev/null 应该是静态目标") } // 动态目标 if IsStaticRedirectTarget("$file") { t.Error("$file 不应该是静态目标") } if IsStaticRedirectTarget("`pwd`/file") { t.Error("`pwd`/file 不应该是静态目标") } if IsStaticRedirectTarget("*.log") { t.Error("*.log 不应该是静态目标") } if IsStaticRedirectTarget("") { t.Error("空字符串不应该是静态目标") } // 升华改进(ELEVATED): ~/file 现在视为静态(可解析为当前用户绝对路径), // 但 ~otheruser/file 仍拒绝(无法确定其他用户的家目录). if !IsStaticRedirectTarget("~/file") { t.Error("~/file 应该是静态目标(可解析为当前用户的绝对路径)") } if IsStaticRedirectTarget("~otheruser/file") { t.Error("~otheruser/file 不应该是静态目标(无法确定其他用户路径)") } } func TestParseFdDup(t *testing.T) { root := Parse("cmd 2>&1 > /dev/null") cmds := ExtractCommands(root) if len(cmds) != 1 { t.Fatalf("命令数量: %d, 期望 1", len(cmds)) } cmd := cmds[0] if len(cmd.Redirections) < 2 { t.Fatalf("重定向数量: %d, 期望至少 2", len(cmd.Redirections)) } } // ================== 复合命令测试 ================== func TestParseCompoundBrace(t *testing.T) { root := Parse("{ cmd1; cmd2; }") cmds := ExtractCommands(root) if len(cmds) != 2 { t.Fatalf("命令数量: %d, 期望 2", len(cmds)) } if cmds[0].Name != "cmd1" { t.Errorf("命令[0]: %q, 期望 cmd1", cmds[0].Name) } if cmds[1].Name != "cmd2" { t.Errorf("命令[1]: %q, 期望 cmd2", cmds[1].Name) } } func TestParseIfStatement(t *testing.T) { root := Parse("if true; then echo yes; fi") cmds := ExtractCommands(root) if len(cmds) < 2 { t.Fatalf("命令数量: %d, 期望至少 2(true + echo)", len(cmds)) } // 应该包含 true 和 echo names := make([]string, len(cmds)) for i, cmd := range cmds { names[i] = cmd.Name } if !containsStr(names, "true") { t.Errorf("应包含 true, 实际: %v", names) } if !containsStr(names, "echo") { t.Errorf("应包含 echo, 实际: %v", names) } } func TestParseForLoop(t *testing.T) { root := Parse("for i in 1 2 3; do echo $i; done") cmds := ExtractCommands(root) if len(cmds) < 1 { t.Fatalf("命令数量: %d, 期望至少 1", len(cmds)) } // 至少应该有 echo names := make([]string, len(cmds)) for i, cmd := range cmds { names[i] = cmd.Name } if !containsStr(names, "echo") { t.Errorf("应包含 echo, 实际: %v", names) } } func TestParseWhileLoop(t *testing.T) { root := Parse("while true; do sleep 1; done") cmds := ExtractCommands(root) if len(cmds) < 2 { t.Fatalf("命令数量: %d, 期望至少 2", len(cmds)) } } // ================== 算术展开 vs Heredoc 区分测试 ================== func TestArithmeticVsHeredoc(t *testing.T) { // 算术展开中的 << 是位移操作符,不是 heredoc root := Parse("echo $((1 << 2))") cmds := ExtractCommands(root) if len(cmds) != 1 { t.Fatalf("命令数量: %d, 期望 1(不应被误认为 heredoc)", len(cmds)) } if cmds[0].Name != "echo" { t.Errorf("命令名: %q, 期望 echo", cmds[0].Name) } } // ================== 变量展开测试 ================== func TestParseVariableExpansion(t *testing.T) { root := Parse("echo $HOME") if root == nil { t.Fatal("Parse 返回 nil") } cmds := ExtractCommands(root) if len(cmds) != 1 { t.Fatalf("命令数量: %d, 期望 1", len(cmds)) } } func TestParseBraceExpansion(t *testing.T) { root := Parse("echo ${HOME:-/tmp}") cmds := ExtractCommands(root) if len(cmds) != 1 { t.Fatalf("命令数量: %d, 期望 1", len(cmds)) } } // ================== 进程替换测试 ================== func TestParseProcessSubstitution(t *testing.T) { root := Parse("diff <(sort file1) <(sort file2)") cmds := ExtractCommands(root) if len(cmds) != 1 { t.Fatalf("命令数量: %d, 期望 1", len(cmds)) } if cmds[0].Name != "diff" { t.Errorf("命令名: %q, 期望 diff", cmds[0].Name) } } // ================== ExtractCommandName 测试 ================== func TestExtractCommandNameFromAST(t *testing.T) { tests := []struct { name string input string wantCmd string wantSubCmd string }{ {"简单命令", "npm install", "npm", "install"}, {"带选项", "ls -la", "ls", ""}, {"git 子命令", "git push --force", "git", "push"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { root := Parse(tt.input) cmds := ExtractCommands(root) if len(cmds) != 1 { t.Fatalf("命令数量: %d, 期望 1", len(cmds)) } cmd, sub := ExtractCommandName(cmds[0]) if cmd != tt.wantCmd { t.Errorf("command: %q, 期望 %q", cmd, tt.wantCmd) } if sub != tt.wantSubCmd { t.Errorf("subcommand: %q, 期望 %q", sub, tt.wantSubCmd) } }) } } func TestExtractCommandNameSkipsPrefix(t *testing.T) { tests := []struct { name string input string wantCmd string }{ {"跳过 env", "env FOO=bar npm i", "npm"}, {"跳过 sudo", "sudo rm -rf /", "rm"}, {"跳过 sudo -u", "sudo -u user command", "command"}, {"跳过 nohup", "nohup node server.js", "node"}, {"跳过 time", "time make build", "make"}, {"跳过 nice", "nice -n 10 heavy_job", "heavy_job"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { root := Parse(tt.input) cmds := ExtractCommands(root) if len(cmds) != 1 { t.Fatalf("命令数量: %d, 期望 1", len(cmds)) } cmd, _ := ExtractCommandName(cmds[0]) if cmd != tt.wantCmd { t.Errorf("command: %q, 期望 %q", cmd, tt.wantCmd) } }) } } // ================== GetCommandPrefixes 测试 ================== func TestGetCommandPrefixesFromAST(t *testing.T) { root := Parse("npm install lodash") cmds := ExtractCommands(root) if len(cmds) != 1 { t.Fatalf("命令数量: %d, 期望 1", len(cmds)) } prefixes := GetCommandPrefixes(cmds[0]) want := []string{"npm", "npm install", "npm install lodash"} if len(prefixes) != len(want) { t.Fatalf("前缀数量: %d, 期望 %d\n 实际: %v", len(prefixes), len(want), prefixes) } for i, p := range prefixes { if p != want[i] { t.Errorf("前缀[%d]: %q, 期望 %q", i, p, want[i]) } } } // ================== 注释测试 ================== func TestParseComment(t *testing.T) { root := Parse("echo hello # this is a comment") cmds := ExtractCommands(root) if len(cmds) != 1 { t.Fatalf("命令数量: %d, 期望 1", len(cmds)) } if cmds[0].Name != "echo" { t.Errorf("命令名: %q, 期望 echo", cmds[0].Name) } } func TestParseCommentLine(t *testing.T) { root := Parse("# just a comment\necho hello") cmds := ExtractCommands(root) if len(cmds) != 1 { t.Fatalf("命令数量: %d, 期望 1", len(cmds)) } if cmds[0].Name != "echo" { t.Errorf("命令名: %q, 期望 echo", cmds[0].Name) } } // ================== 多行命令测试 ================== func TestParseMultilineCommand(t *testing.T) { input := `echo hello echo world echo done` root := Parse(input) cmds := ExtractCommands(root) if len(cmds) != 3 { t.Fatalf("命令数量: %d, 期望 3", len(cmds)) } } // ================== 转义字符测试 ================== func TestParseEscapedChars(t *testing.T) { // 反斜杠转义空格 root := Parse(`echo hello\ world`) cmds := ExtractCommands(root) if len(cmds) != 1 { t.Fatalf("命令数量: %d, 期望 1", len(cmds)) } if len(cmds[0].Args) != 1 { t.Fatalf("参数数量: %d, 期望 1(空格被转义)", len(cmds[0].Args)) } } // ================== 性能测试 ================== func TestParsePerformance(t *testing.T) { // 构造一个 1000 字符的命令 var builder strings.Builder for i := 0; i < 50; i++ { if i > 0 { builder.WriteString(" | ") } builder.WriteString("echo 'hello world'") } longCmd := builder.String() if len(longCmd) < 1000 { t.Logf("命令长度: %d(需要至少 1000)", len(longCmd)) } start := time.Now() root := Parse(longCmd) elapsed := time.Since(start) if root == nil { t.Fatal("Parse 返回 nil") } if elapsed > time.Millisecond { t.Errorf("解析耗时 %v,超过 1ms 限制", elapsed) } t.Logf("解析 %d 字符的命令耗时: %v", len(longCmd), elapsed) } func TestParsePerformanceLongPipeline(t *testing.T) { // 更严格的性能测试:超长管道 parts := make([]string, 100) for i := range parts { parts[i] = "grep -r 'pattern' /some/dir" } longCmd := strings.Join(parts, " | ") start := time.Now() root := Parse(longCmd) elapsed := time.Since(start) if root == nil { t.Fatal("Parse 返回 nil") } t.Logf("解析 %d 字符的命令耗时: %v", len(longCmd), elapsed) cmds := ExtractCommands(root) if len(cmds) != 100 { t.Errorf("命令数量: %d, 期望 100", len(cmds)) } if elapsed > 5*time.Millisecond { t.Errorf("解析耗时 %v,超过 5ms", elapsed) } } // ================== 宽容解析测试 ================== func TestParseToleratesInvalidSyntax(t *testing.T) { // 不完整的命令不应 panic tests := []string{ "echo 'unclosed quote", `echo "unclosed double`, "echo $(unclosed", "echo ${unclosed", "echo $((unclosed", "if true; then", // 缺少 fi "for i in; do echo; done extra", // 额外内容 "", // 空 " ", // 只有空白 "|||", // 无效语法 "&&&&", // 无效语法 } for _, input := range tests { t.Run(input, func(t *testing.T) { // 不应 panic root := Parse(input) if root == nil { t.Error("Parse 不应返回 nil(即使输入无效)") } }) } } // ================== 复杂真实世界命令测试 ================== func TestParseRealWorldCommands(t *testing.T) { tests := []struct { name string input string wantN int wantCmd string }{ { "npm 安装并测试", "npm install && npm test", 2, "npm", }, { "Docker 构建", "docker build -t myapp:latest . && docker push myapp:latest", 2, "docker", }, { "grep 管道", "cat /var/log/syslog | grep error | wc -l", 3, "cat", }, { "curl 下载", `curl -fsSL https://example.com/install.sh | bash`, 2, "curl", }, { "Git 操作", "git add -A && git commit -m 'update' && git push origin main", 3, "git", }, { "条件创建目录", "test -d /tmp/build || mkdir -p /tmp/build", 2, "test", }, { "后台进程", "sleep 10 &", 1, "sleep", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { root := Parse(tt.input) cmds := ExtractCommands(root) if len(cmds) != tt.wantN { names := make([]string, len(cmds)) for i, c := range cmds { names[i] = c.Name } t.Fatalf("命令数量: %d, 期望 %d, 命令: %v", len(cmds), tt.wantN, names) } if cmds[0].Name != tt.wantCmd { t.Errorf("第一个命令: %q, 期望 %q", cmds[0].Name, tt.wantCmd) } }) } } // ================== Heredoc 预处理测试 ================== func TestHeredocPreprocessMultiple(t *testing.T) { // 同一行多个 heredoc input := "cat < output.txt") cmds := ExtractCommands(root) if len(cmds) != 2 { t.Fatalf("命令数量: %d, 期望 2", len(cmds)) } // grep 应该有重定向 grepCmd := cmds[1] if grepCmd.Name != "grep" { t.Errorf("命令名: %q, 期望 grep", grepCmd.Name) } if len(grepCmd.Redirections) != 1 { t.Fatalf("重定向数量: %d, 期望 1", len(grepCmd.Redirections)) } if grepCmd.Redirections[0].Target != "output.txt" { t.Errorf("重定向目标: %q, 期望 output.txt", grepCmd.Redirections[0].Target) } } func TestParseNestedSubshellInPipeline(t *testing.T) { root := Parse("(echo hello; echo world) | sort") cmds := ExtractCommands(root) if len(cmds) < 3 { t.Fatalf("命令数量: %d, 期望至少 3", len(cmds)) } } // ================== Case 语句测试 ================== func TestParseCaseStatement(t *testing.T) { input := `case "$1" in start) echo "Starting";; stop) echo "Stopping";; *) echo "Unknown";; esac` root := Parse(input) if root == nil { t.Fatal("Parse 返回 nil") } cmds := ExtractCommands(root) // 应该提取出 echo 命令 if len(cmds) < 1 { t.Fatal("应该提取出至少一个命令") } } // ================== 递归深度限制测试 ================== func TestExtractCommands_RecursionDepthLimit(t *testing.T) { // 构造一个超深嵌套的子 shell:((((... echo hi ...)))) // 每层子 shell 增加一层递归深度 var builder strings.Builder depth := MaxRecursionDepth + 5 for i := 0; i < depth; i++ { builder.WriteString("(") } builder.WriteString("echo deeply_nested") for i := 0; i < depth; i++ { builder.WriteString(")") } root := Parse(builder.String()) cmds := ExtractCommands(root) // 超深层的命令应该被跳过(提取不到) // 注意:解析器可能不会生成完美的 25 层嵌套 AST, // 但关键是 extractFromNode 不会栈溢出 t.Logf("深度 %d 的嵌套提取了 %d 个命令", depth, len(cmds)) // 正常深度应该能提取 root2 := Parse("(echo hello)") cmds2 := ExtractCommands(root2) if len(cmds2) < 1 { t.Error("正常深度的子 shell 命令应能提取") } } func TestExtractCommands_MaxRecursionDepthConstant(t *testing.T) { // 确认常量值 if MaxRecursionDepth != 20 { t.Errorf("MaxRecursionDepth = %d, 期望 20", MaxRecursionDepth) } } // ================== IsStaticRedirectTarget 增强测试 ================== func TestIsStaticRedirectTarget_Enhanced(t *testing.T) { tests := []struct { name string target string want bool }{ // 安全的静态目标 {"普通文件", "file.txt", true}, {"/dev/null", "/dev/null", true}, {"绝对路径", "/tmp/output.log", true}, {"相对路径", "./output.log", true}, {"带点的路径", "../file.txt", true}, // 不安全的动态目标 {"空字符串", "", false}, {"空格", "out /etc/passwd", false}, {"tab", "out\t/etc/passwd", false}, {"换行", "out\n/etc/passwd", false}, {"回车", "out\r/etc/passwd", false}, {"单引号", "'file'", false}, {"双引号", "\"file\"", false}, {"# 前缀", "#file", false}, {"! 前缀(历史展开)", "!file", false}, {"= 前缀(zsh 展开)", "=cmd", false}, {"$ 变量", "$OUTPUT", false}, {"${} 变量", "${OUTPUT}", false}, {"反引号", "`pwd`/file", false}, {"* 通配符", "*.log", false}, {"? 通配符", "file?.txt", false}, {"[ 通配符", "file[1].txt", false}, {"~ 当前用户", "~/file", true}, // 升华改进: ~/path 可解析 {"~ 其他用户", "~otheruser/file", false}, // 无法确定其他用户路径 {"& 文件描述符", "&2", false}, {"< 进程替换", "<(cmd)", false}, {"> 进程替换", ">(cmd)", false}, {"{ 花括号展开", "{a,b}.txt", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := IsStaticRedirectTarget(tt.target) if got != tt.want { t.Errorf("IsStaticRedirectTarget(%q) = %v, want %v", tt.target, got, tt.want) } }) } } // ================== 辅助函数 ================== func filterChildren(node *Node, excludeType NodeType) []*Node { var result []*Node for _, child := range node.Children { if child.Type != excludeType { result = append(result, child) } } return result } func containsStr(slice []string, s string) bool { for _, item := range slice { if item == s { return true } } return false } // ================== Heredoc canonical-field wire regression tests ================== // 一 sub-claim 一 test. 锁的是 godoc 承诺的行为 (canonical field 暴露 + body // 递归), 不是 scanner 过关的最低形式 wire. // // One test per godoc sub-claim. Locks the promised behavior (canonical // field surface + body recursion), not the minimum formal-wire that // makes the scanner pass. // ================== Background `&` wire regression tests ================== // 一 sub-claim 一 test. godoc "List 节点 - true 表示 & 后台执行" 4 条 sub-claim. // Sub-claim (Background-a): parser 在构造 NodeList 时, 当连接符是 `&` // 把 Background 置 true. 未置 true 时所有下游消费者只看到 false, // dead 字段的根源之一是 parser 从未写入. // // Parser write-site: asserts parseList stores Background=true when the // connector token is `&`. Without this write, every consumer sees the // zero value -- one of the classic dead-field root causes. func TestParseList_BackgroundWriteSite(t *testing.T) { cases := []struct { name string src string want bool }{ {"& connector", "cmd1 & cmd2", true}, {"&& connector", "cmd1 && cmd2", false}, {"; connector", "cmd1 ; cmd2", false}, {"| pipeline (no List)", "cmd1 | cmd2", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { root := Parse(tc.src) // 找顶层 NodeList (若存在) var list *Node var walk func(*Node) walk = func(n *Node) { if n == nil || list != nil { return } if n.Type == NodeList { list = n return } for _, c := range n.Children { walk(c) } } walk(root) if tc.want && list == nil { t.Fatalf("no NodeList found in %q", tc.src) } got := list != nil && list.Background if got != tc.want { t.Errorf("NodeList.Background = %v, want %v", got, tc.want) } }) } } // Sub-claim (Background-b): extractContext 传播逻辑正确 -- // `cmd1 & cmd2` 左 bg 右 fg; `cmd1 && cmd2 & cmd3` 整个 && 组 bg, cmd3 fg. // 语义锁: 只锁 "left 继承 / right 清掉", 不锁更激进的 flattening. // // Propagation semantics: `cmd1 & cmd2` -> cmd1 bg, cmd2 fg. // `cmd1 && cmd2 & cmd3` -> left `&&`-subtree inherits bg (cmd1+cmd2), // cmd3 foreground. Locks the "left inherits / right clears" rule only. func TestExtractCommands_BackgroundPropagation(t *testing.T) { findByName := func(cmds []*CommandInfo, name string) *CommandInfo { for _, c := range cmds { if c.Name == name { return c } } return nil } t.Run("simple cmd1 & cmd2", func(t *testing.T) { cmds := ExtractCommands(Parse("cmd1 & cmd2")) c1 := findByName(cmds, "cmd1") c2 := findByName(cmds, "cmd2") if c1 == nil || c2 == nil { t.Fatalf("expected both cmd1 and cmd2, got %v", cmds) } if !c1.Background { t.Errorf("cmd1.Background = false, want true") } if c2.Background { t.Errorf("cmd2.Background = true, want false") } }) t.Run("nested cmd1 && cmd2 & cmd3", func(t *testing.T) { cmds := ExtractCommands(Parse("cmd1 && cmd2 & cmd3")) c1 := findByName(cmds, "cmd1") c2 := findByName(cmds, "cmd2") c3 := findByName(cmds, "cmd3") if c1 == nil || c2 == nil || c3 == nil { t.Fatalf("expected cmd1/cmd2/cmd3, got %v", cmds) } if !c1.Background { t.Errorf("cmd1.Background = false, want true (inside backgrounded && group)") } if !c2.Background { t.Errorf("cmd2.Background = false, want true (inside backgrounded && group)") } if c3.Background { t.Errorf("cmd3.Background = true, want false (foreground)") } }) } // Sub-claim (Background-c): extractSimpleCommand 把 ctx.background 写入 // CommandInfo.Background. 和 (b) 区别: 本 test 锁字段暴露存在, 不依赖 // nested 语义, 只确认 CommandInfo.Background 不是总 false. // // Field exposure: asserts extractSimpleCommand actually writes // ctx.background into CommandInfo.Background. Distinct from (b): this // test locks that the field is populated at all, independent of the // propagation algorithm. func TestExtractCommands_BackgroundFieldPopulated(t *testing.T) { cmds := ExtractCommands(Parse("rm -rf / &")) if len(cmds) == 0 { t.Fatalf("no commands extracted") } if !cmds[0].Background { t.Errorf("CommandInfo.Background = false for `rm -rf / &`; parser write + ctx propagation + field write all required on this path") } // Counter-case: no `&` must yield Background=false (not always-true). // 对照: 无 `&` 时必为 false, 防止 "总是 true" 的假阳性 wire. fg := ExtractCommands(Parse("rm -rf /")) if len(fg) == 0 { t.Fatalf("no foreground command extracted") } if fg[0].Background { t.Errorf("CommandInfo.Background = true for foreground `rm -rf /`; want false") } } // Sub-claim (b): ResolveHeredocBody 真实消费 HeredocStripTabs. // <<- 形式返回行首 tab strip 后的 body (匹配运行时), 普通 << // 形式原样返回. 这把 HeredocStripTabs 从形式暴露变成真跨包消费者 // 的产线读 site. // // Sub-claim (b): ResolveHeredocBody really consumes HeredocStripTabs. // <<- form returns body with leading tabs stripped (matches runtime); // plain << form returns body unchanged. Turns HeredocStripTabs from // shape exposure into a real cross-package-consumer production read. func TestResolveHeredocBody_StripTabsConsumer(t *testing.T) { strip := Parse("cat <<-EOF\n\t.ssh/authorized_keys\n\tEOF") stripCmds := ExtractCommands(strip) if len(stripCmds) == 0 || len(stripCmds[0].Redirections) == 0 { t.Fatalf("no heredoc redirection in <<- source") } stripBody := ResolveHeredocBody(stripCmds[0].Redirections[0]) if strings.Contains(stripBody, "\t") { t.Errorf("ResolveHeredocBody for <<- still contains tab: %q", stripBody) } if !strings.Contains(stripBody, ".ssh/authorized_keys") { t.Errorf("ResolveHeredocBody for <<- lost payload: %q", stripBody) } plain := Parse("cat <", Target: "out.txt"} if got := ResolveHeredocBody(nonHeredoc); got != "" { t.Errorf("ResolveHeredocBody on non-heredoc = %q, want empty", got) } } // Sub-claim (f): RedirectionInfo.HeredocBody 是 canonical string, // 包含 body 内全部行 (含尾 \n), 不折叠不剥 tab. // Godoc: "Heredoc 的内容体". func TestExtractHeredocBody_Canonical(t *testing.T) { src := "cat <