From cc46d2558d42e8b60d43c4ec8c8f317489517411 Mon Sep 17 00:00:00 2001 From: GGOBP Date: Thu, 3 Sep 2026 10:12:19 +0900 Subject: [PATCH] Escape TOML strings and reject unparsable widget names Fixes glendix-labs/mendraw#8 --- src/mxpak/config/toml_writer.gleam | 20 +++-------- src/mxpak/lockfile.gleam | 19 +++++----- src/mxpak/toml_syntax.gleam | 57 ++++++++++++++++++++++++++++++ src/mxpak/widget.gleam | 8 +++-- test/config_test.gleam | 27 ++++++++++++++ test/lockfile_test.gleam | 32 +++++++++++++++++ test/mxpak_test.gleam | 2 +- test/toml_syntax_test.gleam | 23 ++++++++++++ 8 files changed, 159 insertions(+), 29 deletions(-) create mode 100644 src/mxpak/toml_syntax.gleam create mode 100644 test/toml_syntax_test.gleam diff --git a/src/mxpak/config/toml_writer.gleam b/src/mxpak/config/toml_writer.gleam index 0ce3d76..6e7c86b 100644 --- a/src/mxpak/config/toml_writer.gleam +++ b/src/mxpak/config/toml_writer.gleam @@ -7,6 +7,7 @@ import gleam/option import gleam/result import gleam/string import mxpak/error +import mxpak/toml_syntax import mxpak/widget import simplifile @@ -25,9 +26,9 @@ pub fn write_widget( ) let section_header = "[tools.mxpak.widgets." <> quote_key(name) <> "]" let entries = - [#("version", quote_string(version))] + [#("version", toml_syntax.quote_basic_string(version))] |> append_opt(content_id, fn(id) { #("id", int.to_string(id)) }) - |> append_opt(s3_id, fn(s) { #("s3_id", quote_string(s)) }) + |> append_opt(s3_id, fn(s) { #("s3_id", toml_syntax.quote_basic_string(s)) }) let new_content = case string.contains(content, section_header) { True -> list.fold(entries, content, fn(acc, entry) { @@ -68,7 +69,7 @@ pub fn write_meta_toml( id id: option.Option(Int), kind kind: widget.Kind, ) -> Result(Nil, error.Error) { - let lines = ["version = " <> quote_string(version)] + let lines = ["version = " <> toml_syntax.quote_basic_string(version)] let lines = case id { option.Some(i) -> list.append(lines, ["id = " <> int.to_string(i)]) option.None -> lines @@ -83,18 +84,7 @@ pub fn write_meta_toml( /// Quotes a TOML key when required. pub fn quote_key(name name: String) -> String { - case - string.contains(name, " ") - || string.contains(name, "-") - || string.contains(name, ".") - { - True -> "\"" <> name <> "\"" - False -> name - } -} - -fn quote_string(value: String) -> String { - "\"" <> value <> "\"" + toml_syntax.quote_key(name) } /// Updates the key in section. diff --git a/src/mxpak/lockfile.gleam b/src/mxpak/lockfile.gleam index c9f42e4..f0b53e7 100644 --- a/src/mxpak/lockfile.gleam +++ b/src/mxpak/lockfile.gleam @@ -8,6 +8,7 @@ import gleam/option import gleam/result import gleam/string import mxpak/error +import mxpak/toml_syntax import mxpak/widget import simplifile import tom @@ -148,8 +149,8 @@ fn serialize_lockfile(entries: dict.Dict(String, LockEntry)) -> String { fn serialize_entry(name: String, entry: LockEntry) -> String { let header = "[widgets." <> quote_key(name) <> "]\n" let lines = [ - "version = \"" <> entry.version <> "\"", - "hash = \"" <> entry.hash <> "\"", + "version = " <> toml_syntax.quote_basic_string(entry.version), + "hash = " <> toml_syntax.quote_basic_string(entry.hash), ] let lines = case entry.content_id { option.Some(id) -> @@ -157,7 +158,10 @@ fn serialize_entry(name: String, entry: LockEntry) -> String { option.None -> lines } let lines = case entry.s3_id { - option.Some(s3) -> list.append(lines, ["s3_id = \"" <> s3 <> "\""]) + option.Some(s3) -> + list.append(lines, [ + "s3_id = " <> toml_syntax.quote_basic_string(s3), + ]) option.None -> lines } let lines = case entry.kind { @@ -168,12 +172,5 @@ fn serialize_entry(name: String, entry: LockEntry) -> String { } fn quote_key(name: String) -> String { - case - string.contains(name, " ") - || string.contains(name, "-") - || string.contains(name, ".") - { - True -> "\"" <> name <> "\"" - False -> name - } + toml_syntax.quote_key(name) } diff --git a/src/mxpak/toml_syntax.gleam b/src/mxpak/toml_syntax.gleam new file mode 100644 index 0000000..bbe533f --- /dev/null +++ b/src/mxpak/toml_syntax.gleam @@ -0,0 +1,57 @@ +//// Provides TOML syntax encoding shared by generated TOML files. + +import gleam/int +import gleam/list +import gleam/string + +/// Encodes a value as a quoted TOML basic string with escapes. +pub fn quote_basic_string(value value: String) -> String { + "\"" <> escape_basic_string(value) <> "\"" +} + +/// Encodes a TOML key as a bare key when legal and a quoted key otherwise. +pub fn quote_key(name name: String) -> String { + case is_bare_key(name) { + True -> name + False -> quote_basic_string(name) + } +} + +fn is_bare_key(name: String) -> Bool { + name != "" + && { + name + |> string.to_utf_codepoints + |> list.all(fn(codepoint) { + let code = string.utf_codepoint_to_int(codepoint) + { code >= 0x30 && code <= 0x39 } + || { code >= 0x41 && code <= 0x5A } + || { code >= 0x61 && code <= 0x7A } + || code == 0x2D + || code == 0x5F + }) + } +} + +fn escape_basic_string(value: String) -> String { + value + |> string.to_utf_codepoints + |> list.map(escape_codepoint) + |> string.concat +} + +fn escape_codepoint(codepoint: UtfCodepoint) -> String { + let code = string.utf_codepoint_to_int(codepoint) + case code { + 0x08 -> "\\b" + 0x09 -> "\\t" + 0x0A -> "\\n" + 0x0C -> "\\f" + 0x0D -> "\\r" + 0x22 -> "\\\"" + 0x5C -> "\\\\" + escaped if escaped < 0x20 || escaped == 0x7F -> + "\\u" <> string.pad_start(int.to_base16(escaped), 4, "0") + _ -> string.from_utf_codepoints([codepoint]) + } +} diff --git a/src/mxpak/widget.gleam b/src/mxpak/widget.gleam index d2b1e95..9186cd3 100644 --- a/src/mxpak/widget.gleam +++ b/src/mxpak/widget.gleam @@ -24,7 +24,9 @@ pub fn is_classic(kind kind: Kind) -> Bool { /// /// Widget names originate in project TOML files, so they are untrusted path /// segments. Dotted names remain valid because they are legal TOML section -/// names and safe single path segments. +/// names and safe single path segments. Quotes are rejected because the +/// configured TOML parser cannot round-trip quoted keys that contain escaped +/// quotes, in addition to the path-safety rules above. pub fn validate_name(name name: String) -> Result(Nil, error.Error) { case name { "" -> @@ -39,11 +41,13 @@ pub fn validate_name(name name: String) -> Result(Nil, error.Error) { case string.contains(name, "/") || string.contains(name, "\\") + || string.contains(name, "\"") || string.contains(name, "\u{0000}") { True -> Error(error.configuration( - "Widget name must not contain path separators or NUL: " <> name, + "Widget name must not contain path separators, quotes, or NUL: " + <> name, )) False -> Ok(Nil) } diff --git a/test/config_test.gleam b/test/config_test.gleam index 93b821d..bec34c0 100644 --- a/test/config_test.gleam +++ b/test/config_test.gleam @@ -421,6 +421,33 @@ pub fn read_config_accepts_dotted_names_test() -> Nil { Nil } +/// Verifies hostile strings round-trip through written widget configuration. +pub fn write_widget_escapes_hostile_strings_test() -> Nil { + let dir = "build/test_tmp/escaping" + let hostile_version = "1.0\"0\\n\u{0009}x" + simplifile.create_directory_all(dir) + |> should.be_ok + simplifile.write(dir <> "/gleam.toml", "name = \"host\"\n") + |> should.be_ok + toml_writer.write_widget( + dir, + "Data Grid", + hostile_version, + option.Some(7), + option.Some("a\\b\"c"), + ) + |> should.be_ok + let config = toml_reader.read_config(dir) |> should.be_ok + let widget = dict.get(config.widgets, "Data Grid") |> should.be_ok + widget.version + |> should.equal(hostile_version) + widget.s3_id + |> should.equal(option.Some("a\\b\"c")) + simplifile.delete(dir) + |> should.be_ok + Nil +} + fn contains(haystack: String, needle: String) -> Bool { string.contains(haystack, needle) } diff --git a/test/lockfile_test.gleam b/test/lockfile_test.gleam index 51c6d9c..85cfb29 100644 --- a/test/lockfile_test.gleam +++ b/test/lockfile_test.gleam @@ -155,6 +155,38 @@ pub fn quoted_key_test() -> Nil { Nil } +/// Verifies hostile names and versions round-trip through the lockfile. +pub fn hostile_values_round_trip_test() -> Nil { + let dir = "build/test_tmp/lock5" + let hostile_name = "Data Grid 1" + let hostile_version = "1.0\"0\n" + simplifile.create_directory_all(dir) + |> should.be_ok + let entries = + dict.new() + |> dict.insert( + hostile_name, + lockfile.LockEntry( + hostile_version, + "hash", + option.None, + option.Some("s3\"id"), + widget.Pluggable, + ), + ) + lockfile.write(dir, entries) + |> should.be_ok + let lock = lockfile.read(dir) |> should.be_ok + let entry = lockfile.get_entry(lock, hostile_name) |> should.be_ok + entry.version + |> should.equal(hostile_version) + entry.s3_id + |> should.equal(option.Some("s3\"id")) + simplifile.delete(dir) + |> should.be_ok + Nil +} + fn string_contains(haystack: String, needle: String) -> Bool { string.contains(haystack, needle) } diff --git a/test/mxpak_test.gleam b/test/mxpak_test.gleam index 5377df2..9c16384 100644 --- a/test/mxpak_test.gleam +++ b/test/mxpak_test.gleam @@ -32,7 +32,7 @@ pub fn widget_validate_name_test() -> Nil { |> list.each(fn(name) { widget.validate_name(name) |> should.be_ok }) [ "", ".", "..", "../sibling", "nested/evil", "nested\\evil", "/absolute/evil", - "a\u{0000}b", + "a\u{0000}b", "quote\"name", ] |> list.each(fn(name) { widget.validate_name(name) |> should.be_error }) Nil diff --git a/test/toml_syntax_test.gleam b/test/toml_syntax_test.gleam new file mode 100644 index 0000000..7335072 --- /dev/null +++ b/test/toml_syntax_test.gleam @@ -0,0 +1,23 @@ +//// Tests TOML syntax encoding for mxpak. +//// + +import gleeunit/should +import mxpak/toml_syntax + +/// Verifies basic string escapes. +pub fn quote_basic_string_test() -> Nil { + toml_syntax.quote_basic_string("a\"b\\c\nd\te") + |> should.equal("\"a\\\"b\\\\c\\nd\\te\"") + Nil +} + +/// Verifies bare keys stay bare and hostile keys become quoted. +pub fn quote_key_test() -> Nil { + toml_syntax.quote_key("Data-Grid_2") + |> should.equal("Data-Grid_2") + toml_syntax.quote_key("Data Grid") + |> should.equal("\"Data Grid\"") + toml_syntax.quote_key("quote\"key") + |> should.equal("\"quote\\\"key\"") + Nil +}