From 77c2b3d824b28f3787b6093ed3ebb0b3cbc66411 Mon Sep 17 00:00:00 2001 From: Patrick Ferris Date: Sat, 27 Jun 2026 21:50:06 +0100 Subject: [PATCH] Handle unescaping characters in double quotes See POSIX specifcation XCU 2.2 Quoting --- src/lib/ast.ml | 43 ++++++++++++++++++++++++++++++++----------- src/lib/ast.mli | 1 + src/lib/eval.ml | 3 ++- src/lib/sast.ml | 1 + test/regressions.t | 16 ++++++++++++++++ test/wordexp.ml | 22 +++++++++++++++++----- 6 files changed, 69 insertions(+), 17 deletions(-) diff --git a/src/lib/ast.ml b/src/lib/ast.ml index 80bbc99..70f5815 100644 --- a/src/lib/ast.ml +++ b/src/lib/ast.ml @@ -892,22 +892,34 @@ let has_glob ast = module Fragment = struct let make ?(escaping = false) ?(splittable = false) ?(globbable = false) - ?(tilde_expansion = false) ?(join = `No) txt = - { txt; escaping; splittable; join; globbable; tilde_expansion } + ?(tilde_expansion = false) ?(was_quoted = false) ?(join = `No) txt = + { txt; escaping; splittable; join; globbable; tilde_expansion; was_quoted } let empty = make "" - let to_string { txt; escaping; _ } = - if not escaping then txt - else - let s_len = String.length txt in + let to_string t = + if t.escaping then ( + let s_len = String.length t.txt in let buf = Buffer.create s_len in for i = 0 to s_len - 1 do - match String.get ~label:"frag-to-string" txt i with + match String.get ~label:"frag-to-string" t.txt i with | '\\' -> () | c -> Buffer.add_char buf c done; - Buffer.contents buf + Buffer.contents buf) + else if t.was_quoted then ( + let s_len = String.length t.txt in + let buf = Buffer.create s_len in + for i = 0 to s_len - 1 do + match String.get ~label:"frag-to-string" t.txt i with + | '\\' -> ( + match String.get ~label:"frag-to-string2" t.txt (i + 1) with + | '$' | '"' | '\\' | '`' | '\n' -> () + | _ -> Buffer.add_char buf '\\') + | c -> Buffer.add_char buf c + done; + Buffer.contents buf) + else t.txt let join ~sep f1 f2 = { @@ -929,11 +941,20 @@ module Fragment = struct | `With_next -> Fmt.pf ppf "with-next" | `Yes -> Fmt.pf ppf "yes" - let pp ppf { txt; join; escaping; splittable; globbable; tilde_expansion } = + let pp ppf + { + txt; + join; + escaping; + splittable; + globbable; + tilde_expansion; + was_quoted; + } = Fmt.pf ppf "{ txt = %S; join = %a; splittable = %b; globbable = %b; tilde_expansion \ - = %b; escaping = %b }" - txt pp_join join splittable globbable tilde_expansion escaping + = %b; escaping = %b; was_quoted = %b }" + txt pp_join join splittable globbable tilde_expansion escaping was_quoted let handle_joins cst = let rec loop = function diff --git a/src/lib/ast.mli b/src/lib/ast.mli index d9b4cb4..7cb3f80 100644 --- a/src/lib/ast.mli +++ b/src/lib/ast.mli @@ -40,6 +40,7 @@ module Fragment : sig ?splittable:bool -> ?globbable:bool -> ?tilde_expansion:bool -> + ?was_quoted:bool -> ?join:[ `No | `With_next | `With_previous | `Yes ] -> string -> fragment diff --git a/src/lib/eval.ml b/src/lib/eval.ml index 090a9e6..e28118f 100644 --- a/src/lib/eval.ml +++ b/src/lib/eval.ml @@ -1160,9 +1160,10 @@ module Make (S : Types.State) (E : Types.Exec) = struct let make ?(join = if in_double_quotes ctx then `With_previous else `Yes) ?globbable ?splittable ?tilde_expansion + ?(was_quoted = in_quotes ctx) ?(escaping = if in_quotes ctx then false else true) v = Ast.Fragment.make ~join ?splittable ?tilde_expansion ?globbable - ~escaping v + ~was_quoted ~escaping v end in match v with | Ast.WordVariable v -> ( diff --git a/src/lib/sast.ml b/src/lib/sast.ml index 1238695..eb527e7 100644 --- a/src/lib/sast.ml +++ b/src/lib/sast.ml @@ -138,6 +138,7 @@ and arith_expr = and fragment = { txt : string; escaping : bool; + was_quoted : bool; splittable : bool; globbable : bool; tilde_expansion : bool; diff --git a/test/regressions.t b/test/regressions.t index 58982b1..ee569c5 100644 --- a/test/regressions.t +++ b/test/regressions.t @@ -83,3 +83,19 @@ Even partially fulfilled while-loop clauses should update the context. C should be 3: 3 $ msh test.sh C should be 3: 3 + +The rules for "unescaping" characters are a little complicated, particularly +when double quotes are involved. + + $ cat > test.sh << EOF + > cat \$1 | cut -d "\"" -f 2 + > EOF + + $ cat > input.txt << EOF + > print "hello world" + > EOF + + $ sh test.sh input.txt + hello world + $ msh test.sh input.txt + hello world diff --git a/test/wordexp.ml b/test/wordexp.ml index 6c69f1c..d142d18 100644 --- a/test/wordexp.ml +++ b/test/wordexp.ml @@ -8,7 +8,9 @@ let expand ctx cst = let fragment = Alcotest.of_pp Merry.Ast.Fragment.pp let fragments = Alcotest.list fragment -let frags ?(escaping = true) v = List.map (Ast.Fragment.make ~escaping) v + +let frags ?(escaping = true) ?(was_quoted = false) v = + List.map (Ast.Fragment.make ~was_quoted ~escaping) v let with_default_ctx ?(args = []) ?(params = []) ?(home = "/home/merry/") env fn = @@ -71,7 +73,7 @@ let test_dquote env () = Ast. [ Fragment.make ~escaping:true "echo"; - Fragment.make ~escaping:false ~join:`No "hello there"; + Fragment.make ~was_quoted:true ~escaping:false ~join:`No "hello there"; ] in let actual = expand ctx cargs in @@ -85,7 +87,10 @@ let test_dquote_expansion env () = with_default_ctx ~args ~params:[ ("FOO", "there") ] env @@ fun ctx -> let expected = Ast.Fragment. - [ make ~escaping:true "echo"; make ~escaping:false "hello there..." ] + [ + make ~escaping:true "echo"; + make ~was_quoted:true ~escaping:false "hello there..."; + ] in let actual = expand ctx cargs in Alcotest.check fragments "same fragments" expected actual @@ -121,7 +126,12 @@ let test_argv_in_quotes_expansion env () = with_default_ctx ~args:[ "echo"; "a"; "b"; "c" ] env @@ fun ctx -> let expected = Ast.Fragment. - [ make ~escaping:true "echo"; make "got [a"; make "b"; make "c]" ] + [ + make ~escaping:true "echo"; + make ~was_quoted:true "got [a"; + make ~was_quoted:true "b"; + make ~was_quoted:true "c]"; + ] in let actual = expand ctx cargs in Alcotest.check fragments "same fragments" expected actual @@ -141,7 +151,9 @@ let test_arg_in_quotes_expansion env () = ] in with_default_ctx ~args:[ "/some/file/test.sh" ] env @@ fun ctx -> - let expected = Ast.Fragment.[ make "use `test.sh --help'" ] in + let expected = + Ast.Fragment.[ make ~was_quoted:true "use `test.sh --help'" ] + in let actual = expand ctx cargs in Alcotest.check fragments "same fragments" expected actual -- 2.51.2