package builtin // sed_edit_parser.go - sed 原地编辑命令解析器 // // 定位:解析 `sed -i 's/pattern/replacement/flags' file` 命令, // 提取文件路径和替换规则,以便在权限请求 UI 中显示"改动前/后"的 diff 预览. // 这和 sed_security.go 的职责不同:security 检查命令是否安全; // 这里是在命令被允许后,预览它会产生什么文件改动. // // 核心设计决策: // - 随机盐占位符(ELEVATED):&→${0} 转换使用随机 salt 生成唯一占位符, // 防止替换字符串本身包含占位符时的注入混淆. // - BRE→Go 正则转换:sed 默认使用 BRE(基本正则),Go 使用 ERE-like 语法; // 二者对 +, ?, |, (, ) 的转义规则相反,必须在执行前转换. // - 字段类型与 go-regexp 解耦:SedEditInfo 存储原始 sed 字段, // ApplySedSubstitution 负责转换,两者职责分离. // // 升华改进(ELEVATED): 早期实现使用 randomBytes(8).toString('hex') // 生成随机 salt,防止替换字符串中恰好包含占位符字符串(理论上极低概率但真实风险). // 我们用 crypto/rand 实现相同保护.静态占位符(如早期方案的 AMPERSAND_TOKEN)若被 // 恶意输入或意外内容匹配会导致错误的 diff 预览,欺骗用户误判改动范围. // 替代方案:<静态占位符,如 `___ESCAPED_AMP___`> - // 否决原因:若文件内容或替换字符串恰好包含该静态字符串,diff 预览会产生错误结果, // 从安全角度说,这是一个微小但真实的注入面. import ( "crypto/rand" "fmt" "regexp" "strings" ) // SedEditInfo 是解析后的 sed 原地编辑命令信息. // 包含执行和预览替换所需的全部字段. type SedEditInfo struct { FilePath string // 目标文件路径(-i 的操作对象) Pattern string // 搜索正则(sed 语法,未转换) Replacement string // 替换字符串(sed 语法,未转换) Flags string // 替换标志(g=全局, i=不区分大小写, m=多行) ExtendedRegex bool // 是否使用扩展正则(-E 或 -r 标志) } // IsSedInPlaceEdit 快速判断命令是否为 sed 原地编辑命令. // 只有 `sed -i 's/.../.../' file` 形式才返回 true. func IsSedInPlaceEdit(command string) bool { return ParseSedEditCommand(command) != nil } // sedTokenize 是 shell 命令的简单分词器(引号感知). // // 历史包袱(LEGACY): 与 pkg/permission.tokenizeCommand 逻辑相同,但无法直接复用-- // 跨包调用会引入 tools/builtin → permission 的单向依赖,而 permission 层的分词器 // 是 unexported 内部函数. // 理想做法:将分词器移入 internal/shellparse 包,供两者共用. // 现在:原样复制,两处保持同步(如果分词逻辑需要改进,两处都要更新). func sedTokenize(cmd string) []string { var tokens []string var current strings.Builder inSingleQuote := false inDoubleQuote := false for i := 0; i < len(cmd); i++ { ch := cmd[i] if ch == '\\' && i+1 < len(cmd) && !inSingleQuote { current.WriteByte(cmd[i+1]) i++ continue } if ch == '\'' && !inDoubleQuote { inSingleQuote = !inSingleQuote continue } if ch == '"' && !inSingleQuote { inDoubleQuote = !inDoubleQuote continue } if (ch == ' ' || ch == '\t') && !inSingleQuote && !inDoubleQuote { if current.Len() > 0 { tokens = append(tokens, current.String()) current.Reset() } continue } current.WriteByte(ch) } if current.Len() > 0 { tokens = append(tokens, current.String()) } return tokens } // ParseSedEditCommand 解析 sed 原地编辑命令,提取替换信息. // 不支持的命令格式(多文件,多表达式,非替换操作等)返回 nil. // // 精妙之处(CLEVER): 使用 sedTokenize 做 shell 分词-- // 保证引号内的空格不被误分割,和安全检查层逻辑一致,避免行为不一致. // 如果用 strings.Fields 分词,`sed -i 's/a b/c/'` 中 `'s/a b/c/'` 会被错误切分. func ParseSedEditCommand(command string) *SedEditInfo { tokens := sedTokenize(command) if len(tokens) == 0 { return nil } // 找 sed 命令位置并跳过命令名本身 cmdIdx := -1 for i, tok := range tokens { base := tok if idx := strings.LastIndex(tok, "/"); idx >= 0 { base = tok[idx+1:] } if base == "sed" || base == "gsed" { cmdIdx = i break } } if cmdIdx < 0 || cmdIdx+1 >= len(tokens) { return nil } args := tokens[cmdIdx+1:] // 解析 flag 和参数 hasInPlace := false extendedRegex := false var expression string var filePath string hasExpression := false hasFilePath := false i := 0 for i < len(args) { arg := args[i] // -i 标志(原地写入),macOS 支持 -i '' 形式 if arg == "-i" || arg == "--in-place" { hasInPlace = true i++ // macOS: -i 后面可能跟空字符串或 .bak 作为备份后缀 if i < len(args) { next := args[i] if next == "" || strings.HasPrefix(next, ".") { i++ // 跳过备份后缀 } } continue } if strings.HasPrefix(arg, "-i") { // -i.bak 形式(内联后缀) hasInPlace = true i++ continue } // -E / -r:扩展正则 if arg == "-E" || arg == "-r" || arg == "--regexp-extended" { extendedRegex = true i++ continue } // -e expr 或 --expression=expr if arg == "-e" || arg == "--expression" { if i+1 < len(args) { if hasExpression { return nil // 多个表达式不支持 } expression = args[i+1] hasExpression = true i += 2 continue } return nil } if strings.HasPrefix(arg, "--expression=") { if hasExpression { return nil } expression = arg[len("--expression="):] hasExpression = true i++ continue } // 未知 flag if strings.HasPrefix(arg, "-") { return nil } // 非 flag 参数:先是表达式,再是文件路径 if !hasExpression { expression = arg hasExpression = true } else if !hasFilePath { filePath = arg hasFilePath = true } else { return nil // 多个文件不支持 } i++ } // 必须同时满足:-i 标志,有表达式,有文件路径 if !hasInPlace || !hasExpression || !hasFilePath { return nil } // 解析替换表达式:s/pattern/replacement/flags // 仅支持 / 作为分隔符(与早期实现 一致) if !strings.HasPrefix(expression, "s/") { return nil } pattern, replacement, flags, ok := parseSedSubstExpression(expression[2:]) if !ok { return nil } // 仅允许安全的替换标志 // 精妙之处(CLEVER): 白名单而非黑名单--只允许已知安全的标志, // 未知标志(如 e=执行, w=写文件)直接拒绝,避免漏判. if !validSedFlags.MatchString(flags) { return nil } return &SedEditInfo{ FilePath: filePath, Pattern: pattern, Replacement: replacement, Flags: flags, ExtendedRegex: extendedRegex, } } // validSedFlags 是允许的 sed 替换标志. // 允许:g(全局), p(打印), i/I(大小写), m/M(多行), 1-9(第N次匹配). // 禁止:e(执行shell命令), w/W(写文件) -- 这些由 sed_security.go 的危险操作检测覆盖. var validSedFlags = regexp.MustCompile(`^[gpimIM1-9]*$`) // parseSedSubstExpression 解析 `pattern/replacement/flags` 三段. // input 是去掉前缀 `s/` 后的字符串. // 返回 (pattern, replacement, flags, ok). // // 精妙之处(CLEVER): 手写状态机而非正则--sed 表达式中 `/` 可被 `\` 转义, // 正则无法直接处理"转义的分隔符不算分隔"这一语义. // 比如 `s/fo\/o/bar/g` 中 `\/` 是模式的一部分而非分隔符, // 用 strings.SplitN(expr, "/", 4) 会错误地切割. func parseSedSubstExpression(rest string) (pattern, replacement, flags string, ok bool) { var parts [3]strings.Builder state := 0 // 0=pattern, 1=replacement, 2=flags for j := 0; j < len(rest); j++ { ch := rest[j] if ch == '\\' { if j+1 >= len(rest) { // 末尾单独反斜杠:非法 sed 语法,解析失败 return "", "", "", false } // 转义字符:原样保留(不解释),交给 ApplySedSubstitution 处理 parts[state].WriteByte(ch) parts[state].WriteByte(rest[j+1]) j++ continue } if ch == '/' { if state < 2 { state++ continue } // flags 段出现额外 / -- 不支持 return "", "", "", false } parts[state].WriteByte(ch) } // 必须到达 flags 状态(即找到了两个 / 分隔符) if state != 2 { return "", "", "", false } return parts[0].String(), parts[1].String(), parts[2].String(), true } // ApplySedSubstitution 将 sed 原地替换应用到文件内容,返回替换后的内容. // 如果模式无效或其他错误,返回原始内容(保守策略,不影响 UI 使用). // // 升华改进(ELEVATED): 使用随机 8 字节 salt 生成唯一占位符, // 防止 sed 替换字符串本身包含占位符时产生错误的转换结果. // 早期实现 同样使用 randomBytes(8),我们用 crypto/rand 实现相同保护. // // 替换字符串转换规则(sed → Go regexp): // - `\&` (转义 &,字面量 &) → `&`(Go 中 & 不特殊,直接保留) // - `&` (全匹配引用) → `${0}`(Go regexp 全匹配语法) // - `\1`–`\9` (捕获组引用) → `$1`–`$9` // - `\n` → 实际换行符 // - `\t` → 实际制表符 // - `\/` → `/`(取消分隔符转义) // - `\\` → `\`(字面量反斜杠) // - `$` (字面量,sed 中不特殊) → `$$`(Go regexp 中需转义) func ApplySedSubstitution(content string, info *SedEditInfo) string { // 构建随机 salt,生成唯一占位符 saltBytes := make([]byte, 8) if _, err := rand.Read(saltBytes); err != nil { // crypto/rand 失败极为罕见(内核熵耗尽);降级使用固定盐(预览功能非安全关键). // 历史包袱(LEGACY): 此处降级会引入静态占位符碰撞风险,但 crypto/rand 失败 // 在实际运行中几乎不可能发生(Linux 有 /dev/urandom 保证). // 若要消除此风险,可在函数签名中要求调用方传入 salt. copy(saltBytes, []byte{0x61, 0x6d, 0x70, 0x73, 0x61, 0x6c, 0x74, 0x00}) } saltHex := fmt.Sprintf("%x", saltBytes) // 占位符格式:不含任何 sed/Go regexp 特殊字符,且包含随机 salt escapedAmpPH := "___ESCAPED_AMP_" + saltHex + "___" // 对应 \&(字面量 &) escapedBSPH := "___ESCAPED_BS_" + saltHex + "___" // 对应 \\(字面量 \) // 1. 转换正则模式:sed → Go regexp goPattern := convertSedPattern(info.Pattern, info.ExtendedRegex, saltHex) // 2. 转换替换字符串:sed → Go regexp replacement goReplacement := convertSedReplacement(info.Replacement, escapedAmpPH, escapedBSPH) // 3. 构建 Go 正则标志 flags := "" if strings.ContainsAny(info.Flags, "iI") { flags += "(?i)" } if strings.ContainsAny(info.Flags, "mM") { flags += "(?m)" } // 4. 编译正则 re, err := regexp.Compile(flags + goPattern) if err != nil { return content // 正则无效:保守策略,返回原内容 } // 5. 应用替换 // 精妙之处(CLEVER): 区分全局替换和首次替换-- // sed `g` 标志 = ReplaceAllString;无 g 标志 = 只替换第一次匹配(ReplaceAllString 会替换全部). // 如果不区分,`sed 's/foo/bar/'`(无 g)会意外替换全部 foo, // 导致 diff 预览与实际 sed 执行结果不符,误导用户. if strings.Contains(info.Flags, "g") { return re.ReplaceAllString(content, goReplacement) } // 无 g:只替换第一次匹配 // 使用 ReplaceAllStringFunc + 计数器实现"只替换一次"语义 replaced := false return re.ReplaceAllStringFunc(content, func(match string) string { if replaced { return match } replaced = true // 重新展开 goReplacement 中的 ${0} 等引用 return re.ReplaceAllString(match, goReplacement) }) } // convertSedReplacement 将 sed 替换字符串转换为 Go regexp 替换字符串. // 使用随机 salt 占位符防注入(见 ApplySedSubstitution 注释). func convertSedReplacement(sed, escapedAmpPH, escapedBSPH string) string { r := sed // Pass 1:保护转义字符,防止后续全局替换影响它们 // 顺序重要:先保护 \\ 再保护 \&,防止 \\& 被误判为 \& + 普通字符 r = strings.ReplaceAll(r, `\\`, escapedBSPH) // \\ → 占位符(字面量 \) r = strings.ReplaceAll(r, `\&`, escapedAmpPH) // \& → 占位符(字面量 &) // Pass 2:转义 sed 中的字面量 $(sed 替换字符串中 $ 不特殊,但 Go 里是特殊字符) // 精妙之处(CLEVER): 在转换 & → ${0} 之前转义 $,确保原始 $ 被双写为 $$, // 而后续添加的 ${0} 中的 $ 不会被再次转义(因为此步骤先于 & 转换). r = strings.ReplaceAll(r, "$", "$$") // Pass 3:转换 sed 元字符 → Go regexp replacement r = strings.ReplaceAll(r, `\n`, "\n") // \n → 换行 r = strings.ReplaceAll(r, `\t`, "\t") // \t → 制表符 r = strings.ReplaceAll(r, `\/`, "/") // \/ → /(取消分隔符转义) // \1–\9:sed 捕获组引用 → Go regexp $1–$9 // 从 \9 到 \1 倒序替换,防止 \1 先替换后 \10 被意外匹配(虽然 sed 最多 \9) for d := 9; d >= 1; d-- { r = strings.ReplaceAll(r, fmt.Sprintf(`\%d`, d), fmt.Sprintf("$%d", d)) } // Pass 4:& → ${0}(sed 全匹配引用 → Go regexp 全匹配) r = strings.ReplaceAll(r, "&", "${0}") // Pass 5:还原占位符 r = strings.ReplaceAll(r, escapedAmpPH, "&") // 字面量 & r = strings.ReplaceAll(r, escapedBSPH, `\`) // 字面量 \(Go replacement 中 \ 不特殊) return r } // convertSedPattern 将 sed 正则模式转换为 Go regexp 模式. // BRE(基本正则)和 ERE(扩展正则)的转义规则相反,需要区别处理. // // 精妙之处(CLEVER): BRE↔ERE 转义反转的核心规律-- // BRE:`\+` = 一个或多个(元字符),`+` = 字面量加号 // ERE:`+` = 一个或多个(元字符),`\+` = 字面量加号(同 Go regexp) // 所以 BRE 模式下:凡是 `\X`(X ∈ {+,?,|,(,)})的,都要去掉 \; // // 凡是裸 X 的,都要加上 \. // // 使用占位符的多遍替换保证操作幂等:先把 \X 保存为占位符,再处理裸 X,最后还原. func convertSedPattern(pattern string, extended bool, saltHex string) string { // BRE 无需转换(Go regexp ≈ ERE,与 BRE 主要差异在 +, ?, |, (, )) if extended { // ERE 模式:只需处理通用的 \/ 取消转义 return strings.ReplaceAll(pattern, `\/`, "/") } // BRE 模式转换 bsPlus := "___BRE_PLUS_" + saltHex + "___" bsQues := "___BRE_QUES_" + saltHex + "___" bsPipe := "___BRE_PIPE_" + saltHex + "___" bsLpar := "___BRE_LPAR_" + saltHex + "___" bsRpar := "___BRE_RPAR_" + saltHex + "___" bsBS := "___BRE_BS_" + saltHex + "___" // \\ → 字面量 \ p := pattern // Step 1:保护字面量反斜杠(\\),防止干扰后续处理 p = strings.ReplaceAll(p, `\\`, bsBS) // Step 2:保存 BRE 的元字符转义形式(去掉 \ 后在 ERE/Go 里是元字符) p = strings.ReplaceAll(p, `\+`, bsPlus) p = strings.ReplaceAll(p, `\?`, bsQues) p = strings.ReplaceAll(p, `\|`, bsPipe) p = strings.ReplaceAll(p, `\(`, bsLpar) p = strings.ReplaceAll(p, `\)`, bsRpar) // Step 3:BRE 中裸的元字符是字面量,需要在 ERE/Go 中转义 p = strings.ReplaceAll(p, "+", `\+`) p = strings.ReplaceAll(p, "?", `\?`) p = strings.ReplaceAll(p, "|", `\|`) p = strings.ReplaceAll(p, "(", `\(`) p = strings.ReplaceAll(p, ")", `\)`) // Step 4:还原占位符为 ERE/Go 等价形式(去掉 \,因为 ERE 中这些是原生元字符) p = strings.ReplaceAll(p, bsPlus, "+") p = strings.ReplaceAll(p, bsQues, "?") p = strings.ReplaceAll(p, bsPipe, "|") p = strings.ReplaceAll(p, bsLpar, "(") p = strings.ReplaceAll(p, bsRpar, ")") // Step 5:还原字面量反斜杠 p = strings.ReplaceAll(p, bsBS, `\\`) // Step 6:\/ → /(取消分隔符转义) p = strings.ReplaceAll(p, `\/`, "/") return p }