Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 5 additions & 15 deletions src/mxpak/config/toml_writer.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
19 changes: 8 additions & 11 deletions src/mxpak/lockfile.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -148,16 +149,19 @@ 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) ->
list.append(lines, ["content_id = " <> int.to_string(id)])
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 {
Expand All @@ -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)
}
57 changes: 57 additions & 0 deletions src/mxpak/toml_syntax.gleam
Original file line number Diff line number Diff line change
@@ -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])
}
}
8 changes: 6 additions & 2 deletions src/mxpak/widget.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
"" ->
Expand All @@ -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)
}
Expand Down
27 changes: 27 additions & 0 deletions test/config_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
32 changes: 32 additions & 0 deletions test/lockfile_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
2 changes: 1 addition & 1 deletion test/mxpak_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions test/toml_syntax_test.gleam
Original file line number Diff line number Diff line change
@@ -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
}
Loading