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
74 changes: 74 additions & 0 deletions .github/scripts/setup-moonbit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env bash

set -euo pipefail

readonly MOONBIT_VERSION='0.10.5+5e7afb0c0'
readonly MOONBIT_VERSION_URL='0.10.5%2B5e7afb0c0'
readonly MOONBIT_BASE_URL='https://cli.moonbitlang.com'
readonly TOOLCHAIN_SHA256='07da4e4b21d3ea203b00183906e9e6a3104d77b9756a95c4d9e648471cf2e2d8'
readonly CORE_SHA256='d92991190e30d10a1ab6fcd7d0282b8ddd0004d0309bc40fbc056310cf20bfc5'

: "${RUNNER_TEMP:?RUNNER_TEMP must be set}"
: "${GITHUB_ENV:?GITHUB_ENV must be set}"
: "${GITHUB_PATH:?GITHUB_PATH must be set}"

moonbit_setup_root=$(mktemp -d "${RUNNER_TEMP}/moonbit-toolchain.XXXXXX")
readonly moonbit_setup_root
readonly moon_home="${moonbit_setup_root}/home"
readonly download_dir="${moonbit_setup_root}/downloads"
readonly toolchain_archive="${download_dir}/moonbit-linux-x86_64.tar.gz"
readonly core_archive="${download_dir}/core.tar.gz"

cleanup_downloads() {
if [[ "${download_dir}" == "${RUNNER_TEMP}"/moonbit-toolchain.*/downloads ]]; then
rm -rf -- "${download_dir}"
fi
}
trap cleanup_downloads EXIT

mkdir -p "${download_dir}" "${moon_home}/lib"

download() {
local source_url=$1
local destination=$2
curl \
--proto '=https' \
--tlsv1.2 \
--fail \
--location \
--show-error \
--silent \
--output "${destination}" \
"${source_url}"
}

verify_archive() {
local expected_sha256=$1
local archive=$2
printf '%s %s\n' "${expected_sha256}" "${archive}" | sha256sum --check --strict
}

download \
"${MOONBIT_BASE_URL}/binaries/${MOONBIT_VERSION_URL}/moonbit-linux-x86_64.tar.gz" \
"${toolchain_archive}"
download \
"${MOONBIT_BASE_URL}/cores/core-${MOONBIT_VERSION_URL}.tar.gz" \
"${core_archive}"

verify_archive "${TOOLCHAIN_SHA256}" "${toolchain_archive}"
verify_archive "${CORE_SHA256}" "${core_archive}"

tar -xzf "${toolchain_archive}" -C "${moon_home}"
tar -xzf "${core_archive}" -C "${moon_home}/lib"
ln -s moon "${moon_home}/bin/moonx"
chmod +x "${moon_home}"/bin/*
chmod +x "${moon_home}/bin/internal/tcc"

PATH="${moon_home}/bin:${PATH}" \
"${moon_home}/bin/moon" -C "${moon_home}/lib/core" bundle --warn-list -a --all
PATH="${moon_home}/bin:${PATH}" \
"${moon_home}/bin/moon" -C "${moon_home}/lib/core" bundle --warn-list -a --target wasm-gc --quiet

printf 'MOON_HOME=%s\n' "${moon_home}" >> "${GITHUB_ENV}"
printf '%s\n' "${moon_home}/bin" >> "${GITHUB_PATH}"
printf 'Installed MoonBit %s in %s\n' "${MOONBIT_VERSION}" "${moon_home}"
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
pull_request:
branches: [main]

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
Expand All @@ -14,9 +17,7 @@ jobs:
uses: actions/checkout@v5

- name: Set up MoonBit
run: |
curl -fsSL https://cli.moonbitlang.com/install/unix.sh | bash
echo "$HOME/.moon/bin" >> $GITHUB_PATH
run: .github/scripts/setup-moonbit.sh

- name: Report MoonBit toolchain
run: moon version --all --json
Expand Down
12 changes: 5 additions & 7 deletions .github/workflows/copilot-setup-steps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,10 @@ jobs:
uses: actions/checkout@v5

- name: Set up MoonBit
run: |
curl -fsSL https://cli.moonbitlang.com/install/unix.sh | bash
echo "$HOME/.moon/bin" >> $GITHUB_PATH
run: .github/scripts/setup-moonbit.sh

- name: Report MoonBit toolchain
run: moon version --all --json

- name: Update MoonBit dependencies
run: |
moon version --all
moon update

run: moon update
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security

- Human-readable event messages, field keys, and trace-context diagnostics now render C0/C1 control codes as visible escapes, preventing injected line breaks and terminal control sequences while leaving structured JSON unchanged.
- W3C `traceparent` and `tracestate` parsing now rejects inputs over 512 characters before eager allocation; `tracestate` also rejects duplicate wire keys instead of applying last-write-wins behavior.
- Trace and span identifier generation now seeds ChaCha8 from 32 bytes of platform entropy when available, with the existing clock-derived seed retained only as a documented fallback.
- Newly created native file-subscriber logs and gzip rotations request owner-only `0600` permissions on Unix. Existing file modes are not changed.
- The default OTLP HTTP client now streams response bodies with a 64 KiB ceiling. Oversized responses fail as non-retryable request errors.
- GitHub workflows now install a pinned MoonBit toolchain and core from versioned archives whose SHA-256 digests are verified before extraction, replacing the mutable remote installer pipeline.

## [0.13.0] - 2026-06-01

### Added
Expand Down
21 changes: 21 additions & 0 deletions README.mbt.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,12 @@ match @moontrace.parse_tracestate("rojo=00f067aa0ba902b7,congo=t61rcWkgMzE") {

`SpanContext` keeps the remote trace ID, span ID, sampled flag, tracestate, and remote/local marker. `span_from_remote_context` creates a local child span while preserving the incoming sampled flag for downstream export.

Incoming `traceparent` and `tracestate` values are limited to 512 characters.
Serialized `tracestate` values may contain at most 32 non-empty members and must
not repeat a key. `parse_span_context` preserves a valid `traceparent` while
dropping malformed, oversized, or duplicate-key `tracestate`, matching its
partial-recovery behavior.

### Span Links

Parent/child spans model ownership. Links model causal edges that should not change the parent relationship, such as retries, queued work, or fan-in.
Expand Down Expand Up @@ -336,6 +342,12 @@ Output:
14:31:44.500 | ERROR | handler — delivery failed target="leaf-1" exit_code=2
```

Human-readable formatting keeps each event on one physical record. C0/C1
control codes in event messages, field keys, and trace-context diagnostics are
rendered visibly (`\\n`, `\\r`, `\\t`, or `\\u{001b}`-style escapes). Printable
Unicode is preserved. The JSON subscriber remains governed by JSON string
escaping and is unchanged.

### JSON Subscriber

Machine-readable JSON output:
Expand Down Expand Up @@ -400,6 +412,12 @@ pub async fn export_batch(
}
```

The default HTTP client streams response bodies and retains at most 64 KiB.
Larger responses return `Request("HTTP response body exceeds 65536 bytes")` and
are not retried. Successful and error response bodies within the limit remain
available through `HttpResponse.body`; injected clients keep the same public
interface.

### Subscriber Composition

Route events to multiple subscribers:
Expand Down Expand Up @@ -492,6 +510,9 @@ See [docs/flame.md](docs/flame.md) for rendering with `inferno-flamegraph` or `f

`brickfrog/moontrace/file` provides a native-only buffered JSONL file subscriber. The synchronous subscriber enqueues without blocking the logging call site; an async worker drains, rotates, optionally gzips rotated files, and tracks written/dropped counts.

On Unix, files newly created by the subscriber request owner-only `0600`
permissions. Existing file modes are left unchanged.

```mbt
pub async fn install_file_subscriber() -> Unit {
let files = @file.file_subscriber(
Expand Down
11 changes: 11 additions & 0 deletions docs/file.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ renamed to `path.1`, older rotated files are shifted up, and files beyond
`max_files` are removed. With `gzip=true`, rotated files are written as
`path.N.gz` and the temporary uncompressed rotation is removed.

On Unix, every active or gzip file newly created by the subscriber requests
mode `0600` (owner read/write only). The process umask may make that mode more
restrictive, but cannot grant group or other access. A raw rotated file is a
rename of the active file and retains its mode.

The subscriber does not change the permissions of an existing active file. If
the configured path already exists, its mode and ownership remain the caller's
responsibility. The caller is also responsible for the ownership and
permissions of the parent directory. The underlying `moonbitlang/async/fs`
permission argument is ignored on Windows.

The file subscriber uses `moonbitlang/async/fs` and is only supported on the
native target. Constructing it on other targets aborts immediately with a clear
unsupported-target error instead of returning a subscriber that drops events.
25 changes: 25 additions & 0 deletions src/escape.mbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
///|
/// Render control code points visibly in human-readable output while preserving
/// printable text verbatim.
fn escape_control_chars(value : String) -> String {
let buf = StringBuilder()
for ch in value.iter() {
match ch {
'\b' => buf.write_string("\\b")
'\u{000c}' => buf.write_string("\\f")
'\n' => buf.write_string("\\n")
'\r' => buf.write_string("\\r")
'\t' => buf.write_string("\\t")
_ =>
if ch.is_control() {
let hex = ch.to_int().to_string(radix=16).pad_start(4, '0')
buf.write_string("\\u{")
buf.write_string(hex)
buf.write_string("}")
} else {
buf.write_char(ch)
}
}
}
buf.to_string()
}
4 changes: 2 additions & 2 deletions src/event_format.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ pub fn format_event(
buf.write_string(source)
}
buf.write_string(" — ")
buf.write_string(event.message)
buf.write_string(escape_control_chars(event.message))
if !event.fields.is_empty() {
buf.write_string(" ")
let mut first = true
Expand All @@ -64,7 +64,7 @@ pub fn format_event(
} else {
buf.write_string(" ")
}
buf.write_string(f.key)
buf.write_string(escape_control_chars(f.key))
buf.write_string("=")
buf.write_string(f.value.stringify())
})
Expand Down
13 changes: 13 additions & 0 deletions src/event_format_test.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,19 @@ test "event format two spaces before fields one between" {
)
}

///|
test "event format escapes control characters in messages and field keys" {
let event = sample_event(
@moontrace.Info,
"line1\nline2\r\t\u001b\u007f\u009f",
fields=@moontrace.fields([("key\n\u001b", "printable 汉字 \\n".to_json())]),
)
assert_eq(
event.format(),
"14:31:43.903 | INFO | choir/server — line1\\nline2\\r\\t\\u{001b}\\u{007f}\\u{009f} key\\n\\u{001b}=\"printable 汉字 \\\\n\"",
)
}

///|
test "format timestamp utc at zero" {
assert_eq(@moontrace.format_timestamp_utc((0 : UInt64)), "00:00:00.000")
Expand Down
4 changes: 3 additions & 1 deletion src/field_show.mbt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
///|
pub impl Show for Field with fn output(self, logger) {
logger.write_string("\{self.key}=\{self.value.stringify()}")
logger.write_string(escape_control_chars(self.key))
logger.write_string("=")
logger.write_string(self.value.stringify())
}

///|
Expand Down
12 changes: 12 additions & 0 deletions src/field_show_test.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ test "field show via interpolation" {
assert_eq(shown, "count=42")
}

///|
test "field show escapes control characters in keys" {
let f = @moontrace.Field::{
key: "na\b\u{000c}\n\r\t\u001b\u007f\u009fme",
value: "printable 汉字 \\n".to_json(),
}
assert_eq(
f.to_string(),
"na\\b\\f\\n\\r\\t\\u{001b}\\u{007f}\\u{009f}me=\"printable 汉字 \\\\n\"",
)
}

///|
test "field to_json wraps as object" {
let f = @moontrace.Field::{ key: "key", value: "val".to_json() }
Expand Down
8 changes: 7 additions & 1 deletion src/file/file_subscriber.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ const DEFAULT_MAX_BATCH : Int = 128
///|
const DEFAULT_QUEUE_CAPACITY : Int = 1024

///|
#cfg(target="native")
const FILE_PERMISSION : Int = 0o600

///|
pub(all) struct FileSubscriberConfig {
path : String
Expand Down Expand Up @@ -351,6 +355,7 @@ async fn FileSubscriber::write_batch(
self.config.path,
payload,
create_mode=@fs.CreateMode::OpenOrCreate,
permission=FILE_PERMISSION,
append=true,
)
self.current_size += payload_bytes
Expand Down Expand Up @@ -396,6 +401,7 @@ async fn FileSubscriber::ensure_active_file(self : FileSubscriber) -> Unit {
self.config.path,
"",
create_mode=@fs.CreateMode::OpenOrCreate,
permission=FILE_PERMISSION,
append=true,
)
}
Expand Down Expand Up @@ -496,7 +502,7 @@ async fn FileSubscriber::gzip_file(
destination,
mode=@fs.Mode::WriteOnly,
create_mode=@fs.CreateMode::CreateOrTruncate,
permission=0o600,
permission=FILE_PERMISSION,
)
defer writer.close()
let encoder = @gzip.Encoder(writer)
Expand Down
22 changes: 13 additions & 9 deletions src/id.mbt
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
///|
// Stretch an 8-byte wall-clock timestamp to 32 bytes using splitmix64-style
// mixing constants. Not cryptographically strong, but sufficient to prevent
// accidental trace-ID collisions between independently-launched processes.
// Two processes starting within the same nanosecond would still collide —
// for adversarial collision resistance, OS entropy would be needed, which
// moonbitlang/core/random does not currently expose.
fn make_seed() -> Bytes {
let t = @env.now()
// Prefer a 32-byte platform entropy source. If the target cannot provide one,
// stretch the millisecond wall clock into the required seed size. The fallback
// is deterministic and non-cryptographic, but keeps unsupported environments
// functional.
fn make_seed(random_bytes : (Int) -> Bytes?, now : () -> UInt64) -> Bytes {
match random_bytes(32) {
Some(seed) if seed.length() == 32 => return seed
_ => ()
}
let t = now()
let constants : Array[UInt64] = [
0UL, 0x9e3779b97f4a7c15UL, 0x6c62272e07bb0142UL, 0x94d049bb133111ebUL,
]
Expand All @@ -21,7 +23,9 @@ fn make_seed() -> Bytes {
}

///|
let rng : @random.Rand = @random.Rand::chacha8(seed=make_seed())
let rng : @random.Rand = @random.Rand::chacha8(
seed=make_seed(@env.rand, @env.now),
)

///|
fn to_hex_16(val : UInt64) -> String {
Expand Down
31 changes: 31 additions & 0 deletions src/id_wbtest.mbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
///|
test "make seed prefers exact platform entropy" {
let expected = Bytes::makei(32, i => i.to_byte())
let clock_read : Ref[Bool] = Ref(false)
let actual = make_seed(
size => if size == 32 { Some(expected) } else { None },
() => {
clock_read.val = true
0UL
},
)
assert_eq(actual, expected)
assert_true(!clock_read.val)
}

///|
test "make seed falls back deterministically without entropy" {
let make = fn() { make_seed(fn(_) { None }, fn() { 42UL }) }
let first = make()
let second = make()
assert_eq(first.length(), 32)
assert_eq(first, second)
assert_true(first != Bytes::make(32, b'\x00'))
}

///|
test "make seed rejects platform entropy with the wrong length" {
let seed = make_seed(fn(_) { Some(b"too short") }, fn() { 7UL })
assert_eq(seed.length(), 32)
assert_true(seed != b"too short")
}
Loading