Files
bun.sh/src/sourcemap/Mapping.zig
robobun 98c04e37ec Fix source index bounds check in sourcemap decoder (#24145)
## Summary

Fix the source index bounds check in `src/sourcemap/Mapping.zig` to
correctly validate indices against the range `[0, sources_count)`.

## Changes

- Changed the bounds check condition from `source_index > sources_count`
to `source_index >= sources_count` on line 452
- This prevents accepting `source_index == sources_count`, which would
be out of bounds when indexing into the sources array

## Test plan

- [x] Built successfully with `bun bd`
- The existing test suite should continue to pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-28 12:32:53 -07:00

606 lines
20 KiB
Zig

const Mapping = @This();
const debug = bun.Output.scoped(.SourceMap, .visible);
generated: LineColumnOffset,
original: LineColumnOffset,
source_index: i32,
name_index: i32 = -1,
/// Optimization: if we don't care about the "names" column, then don't store the names.
pub const MappingWithoutName = struct {
generated: LineColumnOffset,
original: LineColumnOffset,
source_index: i32,
pub fn toNamed(this: *const MappingWithoutName) Mapping {
return .{
.generated = this.generated,
.original = this.original,
.source_index = this.source_index,
.name_index = -1,
};
}
};
pub const List = struct {
impl: Value = .{ .without_names = .{} },
names: []const bun.Semver.String = &[_]bun.Semver.String{},
names_buffer: bun.ByteList = .{},
pub const Value = union(enum) {
without_names: bun.MultiArrayList(MappingWithoutName),
with_names: bun.MultiArrayList(Mapping),
pub fn memoryCost(this: *const Value) usize {
return switch (this.*) {
.without_names => |*list| list.memoryCost(),
.with_names => |*list| list.memoryCost(),
};
}
pub fn ensureTotalCapacity(this: *Value, allocator: std.mem.Allocator, count: usize) !void {
switch (this.*) {
inline else => |*list| try list.ensureTotalCapacity(allocator, count),
}
}
};
fn ensureWithNames(this: *List, allocator: std.mem.Allocator) !void {
if (this.impl == .with_names) return;
var without_names = this.impl.without_names;
var with_names = bun.MultiArrayList(Mapping){};
try with_names.ensureTotalCapacity(allocator, without_names.len);
defer without_names.deinit(allocator);
with_names.len = without_names.len;
var old_slices = without_names.slice();
var new_slices = with_names.slice();
@memcpy(new_slices.items(.generated), old_slices.items(.generated));
@memcpy(new_slices.items(.original), old_slices.items(.original));
@memcpy(new_slices.items(.source_index), old_slices.items(.source_index));
@memset(new_slices.items(.name_index), -1);
this.impl = .{ .with_names = with_names };
}
fn findIndexFromGenerated(line_column_offsets: []const LineColumnOffset, line: bun.Ordinal, column: bun.Ordinal) ?usize {
var count = line_column_offsets.len;
var index: usize = 0;
while (count > 0) {
const step = count / 2;
const i: usize = index + step;
const mapping = line_column_offsets[i];
if (mapping.lines.zeroBased() < line.zeroBased() or (mapping.lines.zeroBased() == line.zeroBased() and mapping.columns.zeroBased() <= column.zeroBased())) {
index = i + 1;
count -|= step + 1;
} else {
count = step;
}
}
if (index > 0) {
if (line_column_offsets[index - 1].lines.zeroBased() == line.zeroBased()) {
return index - 1;
}
}
return null;
}
pub fn findIndex(this: *const List, line: bun.Ordinal, column: bun.Ordinal) ?usize {
switch (this.impl) {
inline else => |*list| {
if (findIndexFromGenerated(list.items(.generated), line, column)) |i| {
return i;
}
},
}
return null;
}
const SortContext = struct {
generated: []const LineColumnOffset,
pub fn lessThan(ctx: SortContext, a_index: usize, b_index: usize) bool {
const a = ctx.generated[a_index];
const b = ctx.generated[b_index];
if (a.lines.zeroBased() != b.lines.zeroBased()) {
return a.lines.zeroBased() < b.lines.zeroBased();
}
if (a.columns.zeroBased() != b.columns.zeroBased()) {
return a.columns.zeroBased() < b.columns.zeroBased();
}
return a_index < b_index;
}
};
pub fn sort(this: *List) void {
switch (this.impl) {
.without_names => |*list| list.sort(SortContext{ .generated = list.items(.generated) }),
.with_names => |*list| list.sort(SortContext{ .generated = list.items(.generated) }),
}
}
pub fn append(this: *List, allocator: std.mem.Allocator, mapping: *const Mapping) !void {
switch (this.impl) {
.without_names => |*list| {
try list.append(allocator, .{
.generated = mapping.generated,
.original = mapping.original,
.source_index = mapping.source_index,
});
},
.with_names => |*list| {
try list.append(allocator, mapping.*);
},
}
}
pub fn find(this: *const List, line: bun.Ordinal, column: bun.Ordinal) ?Mapping {
switch (this.impl) {
inline else => |*list, tag| {
if (findIndexFromGenerated(list.items(.generated), line, column)) |i| {
if (tag == .without_names) {
return list.get(i).toNamed();
} else {
return list.get(i);
}
}
},
}
return null;
}
pub fn generated(self: *const List) []const LineColumnOffset {
return switch (self.impl) {
inline else => |*list| list.items(.generated),
};
}
pub fn original(self: *const List) []const LineColumnOffset {
return switch (self.impl) {
inline else => |*list| list.items(.original),
};
}
pub fn sourceIndex(self: *const List) []const i32 {
return switch (self.impl) {
inline else => |*list| list.items(.source_index),
};
}
pub fn nameIndex(self: *const List) []const i32 {
return switch (self.impl) {
inline else => |*list| list.items(.name_index),
};
}
pub fn deinit(self: *List, allocator: std.mem.Allocator) void {
switch (self.impl) {
inline else => |*list| list.deinit(allocator),
}
self.names_buffer.deinit(allocator);
allocator.free(self.names);
}
pub fn getName(this: *List, index: i32) ?[]const u8 {
if (index < 0) return null;
const i: usize = @intCast(index);
if (i >= this.names.len) return null;
if (this.impl == .with_names) {
const str: *const bun.Semver.String = &this.names[i];
return str.slice(this.names_buffer.slice());
}
return null;
}
pub fn memoryCost(this: *const List) usize {
return this.impl.memoryCost() + this.names_buffer.memoryCost() +
(this.names.len * @sizeOf(bun.Semver.String));
}
pub fn ensureTotalCapacity(this: *List, allocator: std.mem.Allocator, count: usize) !void {
try this.impl.ensureTotalCapacity(allocator, count);
}
};
pub const Lookup = struct {
mapping: Mapping,
source_map: ?*ParsedSourceMap = null,
/// Owned by default_allocator always
/// use `getSourceCode` to access this as a Slice
prefetched_source_code: ?[]const u8,
name: ?[]const u8 = null,
/// This creates a bun.String if the source remap *changes* the source url,
/// which is only possible if the executed file differs from the source file:
///
/// - `bun build --sourcemap`, it is another file on disk
/// - `bun build --compile --sourcemap`, it is an embedded file.
pub fn displaySourceURLIfNeeded(lookup: Lookup, base_filename: []const u8) ?bun.String {
const source_map = lookup.source_map orelse return null;
// See doc comment on `external_source_names`
if (source_map.external_source_names.len == 0)
return null;
if (lookup.mapping.source_index >= source_map.external_source_names.len)
return null;
const name = source_map.external_source_names[@intCast(lookup.mapping.source_index)];
if (source_map.is_standalone_module_graph) {
return bun.String.cloneUTF8(name);
}
if (std.fs.path.isAbsolute(base_filename)) {
const dir = bun.path.dirname(base_filename, .auto);
return bun.String.cloneUTF8(bun.path.joinAbs(dir, .auto, name));
}
return bun.String.init(name);
}
/// Only valid if `lookup.source_map.isExternal()`
/// This has the possibility of invoking a call to the filesystem.
///
/// This data is freed after printed on the assumption that printing
/// errors to the console are rare (this isnt used for error.stack)
pub fn getSourceCode(lookup: Lookup, base_filename: []const u8) ?bun.jsc.ZigString.Slice {
const bytes = bytes: {
if (lookup.prefetched_source_code) |code| {
break :bytes code;
}
const source_map = lookup.source_map orelse return null;
assert(source_map.isExternal());
const provider = source_map.underlying_provider.provider() orelse
return null;
const index = lookup.mapping.source_index;
// Standalone module graph source maps are stored (in memory) compressed.
// They are decompressed on demand.
if (source_map.is_standalone_module_graph) {
const serialized = source_map.standaloneModuleGraphData();
if (index >= source_map.external_source_names.len)
return null;
const code = serialized.sourceFileContents(@intCast(index));
return bun.jsc.ZigString.Slice.fromUTF8NeverFree(code orelse return null);
}
if (provider.getSourceMap(
base_filename,
source_map.underlying_provider.load_hint,
.{ .source_only = @intCast(index) },
)) |parsed|
if (parsed.source_contents) |contents|
break :bytes contents;
if (index >= source_map.external_source_names.len)
return null;
const name = source_map.external_source_names[@intCast(index)];
var buf: bun.PathBuffer = undefined;
const normalized = bun.path.joinAbsStringBufZ(
bun.path.dirname(base_filename, .auto),
&buf,
&.{name},
.loose,
);
switch (bun.sys.File.readFrom(
std.fs.cwd(),
normalized,
bun.default_allocator,
)) {
.result => |r| break :bytes r,
.err => return null,
}
};
return bun.jsc.ZigString.Slice.init(bun.default_allocator, bytes);
}
};
pub inline fn generatedLine(mapping: *const Mapping) i32 {
return mapping.generated.lines.zeroBased();
}
pub inline fn generatedColumn(mapping: *const Mapping) i32 {
return mapping.generated.columns.zeroBased();
}
pub inline fn sourceIndex(mapping: *const Mapping) i32 {
return mapping.source_index;
}
pub inline fn originalLine(mapping: *const Mapping) i32 {
return mapping.original.lines.zeroBased();
}
pub inline fn originalColumn(mapping: *const Mapping) i32 {
return mapping.original.columns.zeroBased();
}
pub inline fn nameIndex(mapping: *const Mapping) i32 {
return mapping.name_index;
}
pub fn parse(
allocator: std.mem.Allocator,
bytes: []const u8,
estimated_mapping_count: ?usize,
sources_count: i32,
input_line_count: usize,
options: struct {
allow_names: bool = false,
sort: bool = false,
},
) ParseResult {
debug("parse mappings ({d} bytes)", .{bytes.len});
var mapping = Mapping.List{};
errdefer mapping.deinit(allocator);
if (estimated_mapping_count) |count| {
mapping.ensureTotalCapacity(allocator, count) catch {
return .{
.fail = .{
.msg = "Out of memory",
.err = error.OutOfMemory,
.loc = .{},
},
};
};
}
var generated = LineColumnOffset{ .lines = bun.Ordinal.start, .columns = bun.Ordinal.start };
var original = LineColumnOffset{ .lines = bun.Ordinal.start, .columns = bun.Ordinal.start };
var name_index: i32 = 0;
var source_index: i32 = 0;
var needs_sort = false;
var remain = bytes;
var has_names = false;
while (remain.len > 0) {
if (remain[0] == ';') {
generated.columns = bun.Ordinal.start;
while (strings.hasPrefixComptime(
remain,
comptime [_]u8{';'} ** (@sizeOf(usize) / 2),
)) {
generated.lines = generated.lines.addScalar(@sizeOf(usize) / 2);
remain = remain[@sizeOf(usize) / 2 ..];
}
while (remain.len > 0 and remain[0] == ';') {
generated.lines = generated.lines.addScalar(1);
remain = remain[1..];
}
if (remain.len == 0) {
break;
}
}
// Read the generated column
const generated_column_delta = decodeVLQ(remain, 0);
if (generated_column_delta.start == 0) {
return .{
.fail = .{
.msg = "Missing generated column value",
.err = error.MissingGeneratedColumnValue,
.value = generated.columns.zeroBased(),
.loc = .{ .start = @as(i32, @intCast(bytes.len - remain.len)) },
},
};
}
needs_sort = needs_sort or generated_column_delta.value < 0;
generated.columns = generated.columns.addScalar(generated_column_delta.value);
if (generated.columns.zeroBased() < 0) {
return .{
.fail = .{
.msg = "Invalid generated column value",
.err = error.InvalidGeneratedColumnValue,
.value = generated.columns.zeroBased(),
.loc = .{ .start = @as(i32, @intCast(bytes.len - remain.len)) },
},
};
}
remain = remain[generated_column_delta.start..];
// According to the specification, it's valid for a mapping to have 1,
// 4, or 5 variable-length fields. Having one field means there's no
// original location information, which is pretty useless. Just ignore
// those entries.
if (remain.len == 0)
break;
switch (remain[0]) {
',' => {
remain = remain[1..];
continue;
},
';' => {
continue;
},
else => {},
}
// Read the original source
const source_index_delta = decodeVLQ(remain, 0);
if (source_index_delta.start == 0) {
return .{
.fail = .{
.msg = "Invalid source index delta",
.err = error.InvalidSourceIndexDelta,
.loc = .{ .start = @as(i32, @intCast(bytes.len - remain.len)) },
},
};
}
source_index += source_index_delta.value;
if (source_index < 0 or source_index >= sources_count) {
return .{
.fail = .{
.msg = "Invalid source index value",
.err = error.InvalidSourceIndexValue,
.value = source_index,
.loc = .{ .start = @as(i32, @intCast(bytes.len - remain.len)) },
},
};
}
remain = remain[source_index_delta.start..];
// Read the original line
const original_line_delta = decodeVLQ(remain, 0);
if (original_line_delta.start == 0) {
return .{
.fail = .{
.msg = "Missing original line",
.err = error.MissingOriginalLine,
.loc = .{ .start = @as(i32, @intCast(bytes.len - remain.len)) },
},
};
}
original.lines = original.lines.addScalar(original_line_delta.value);
if (original.lines.zeroBased() < 0) {
return .{
.fail = .{
.msg = "Invalid original line value",
.err = error.InvalidOriginalLineValue,
.value = original.lines.zeroBased(),
.loc = .{ .start = @as(i32, @intCast(bytes.len - remain.len)) },
},
};
}
remain = remain[original_line_delta.start..];
// Read the original column
const original_column_delta = decodeVLQ(remain, 0);
if (original_column_delta.start == 0) {
return .{
.fail = .{
.msg = "Missing original column value",
.err = error.MissingOriginalColumnValue,
.value = original.columns.zeroBased(),
.loc = .{ .start = @as(i32, @intCast(bytes.len - remain.len)) },
},
};
}
original.columns = original.columns.addScalar(original_column_delta.value);
if (original.columns.zeroBased() < 0) {
return .{
.fail = .{
.msg = "Invalid original column value",
.err = error.InvalidOriginalColumnValue,
.value = original.columns.zeroBased(),
.loc = .{ .start = @as(i32, @intCast(bytes.len - remain.len)) },
},
};
}
remain = remain[original_column_delta.start..];
if (remain.len > 0) {
switch (remain[0]) {
',' => {
// 4 column, but there's more on this line.
remain = remain[1..];
},
// 4 column, and there's no more on this line.
';' => {},
// 5th column: the name
else => |c| {
// Read the name index
const name_index_delta = decodeVLQ(remain, 0);
if (name_index_delta.start == 0) {
return .{
.fail = .{
.msg = "Invalid name index delta",
.err = error.InvalidNameIndexDelta,
.value = @intCast(c),
.loc = .{ .start = @as(i32, @intCast(bytes.len - remain.len)) },
},
};
}
remain = remain[name_index_delta.start..];
if (options.allow_names) {
name_index += name_index_delta.value;
if (!has_names) {
mapping.ensureWithNames(allocator) catch {
return .{
.fail = .{
.msg = "Out of memory",
.err = error.OutOfMemory,
.loc = .{ .start = @as(i32, @intCast(bytes.len - remain.len)) },
},
};
};
}
has_names = true;
}
if (remain.len > 0) {
switch (remain[0]) {
// There's more on this line.
',' => {
remain = remain[1..];
},
// That's the end of the line.
';' => {},
else => {},
}
}
},
}
}
mapping.append(allocator, &.{
.generated = generated,
.original = original,
.source_index = source_index,
.name_index = name_index,
}) catch |err| bun.handleOom(err);
}
if (needs_sort and options.sort) {
mapping.sort();
}
return .{ .success = .{
.ref_count = .init(),
.mappings = mapping,
.input_line_count = input_line_count,
} };
}
const std = @import("std");
const SourceMap = @import("./sourcemap.zig");
const LineColumnOffset = SourceMap.LineColumnOffset;
const ParseResult = SourceMap.ParseResult;
const ParsedSourceMap = SourceMap.ParsedSourceMap;
const decodeVLQ = SourceMap.VLQ.decode;
const bun = @import("bun");
const assert = bun.assert;
const strings = bun.strings;