Skip to content

Commit c3720ac

Browse files
ioquatixsamuel-williams-shopify
authored andcommitted
Add depth and size limits to Console::Format::Safe
Fold maximum-size enforcement into Safe rather than a separate wrapper: - Rename `limit` to `depth_limit` (with a deprecated `limit:` alias) to clarify its purpose alongside the new `size_limit`. - Add `size_limit` (default 16 KiB, `nil` disables). When the fast serialization exceeds it, the record is rebuilt field-by-field, keeping as many top-level fields as fit. - Unify the failure and size diagnostics into a single `truncated` object mapping each degraded field to why: `true` (dropped for size) or its error (value recovered, could not serialize directly). Falls back to `truncated: true` when detail does not fit. - Serialize hash-like records via `to_hash`. Removes the separate Console::Format::Truncated class.
1 parent 2459ec1 commit c3720ac

5 files changed

Lines changed: 352 additions & 34 deletions

File tree

lib/console/format.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
require_relative "format/safe"
77

88
module Console
9+
# @namespace
910
module Format
1011
# A safe format for converting objects to strings.
1112
#

lib/console/format/safe.rb

Lines changed: 147 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,31 +10,166 @@ module Console
1010
module Format
1111
# A safe format for converting objects to strings.
1212
#
13-
# Handles issues like circular references and encoding errors.
13+
# Handles issues like circular references, encoding errors, excessive nesting depth, and excessive output size.
1414
class Safe
15+
# The JSON fragment used as the truncation marker when dropped fields cannot be named.
16+
TRUNCATED = "\"truncated\":true"
17+
1518
# Create a new safe format.
1619
#
1720
# @parameter format [JSON] The format to use for serialization.
18-
# @parameter limit [Integer] The maximum depth to recurse into objects.
21+
# @parameter depth_limit [Integer] The maximum depth to recurse into objects (the JSON `max_nesting`).
22+
# @parameter size_limit [Integer | Nil] The maximum byte size of the serialized output, or `nil` to disable size limiting. Limits below {TRUNCATED} (the minimal marker) cannot be honoured.
1923
# @parameter encoding [Encoding] The encoding to use for strings.
20-
def initialize(format: ::JSON, limit: 12, encoding: ::Encoding::UTF_8)
24+
# @parameter limit [Integer | Nil] Deprecated alias for `depth_limit`.
25+
def initialize(format: ::JSON, depth_limit: 12, size_limit: 16 * 1024, encoding: ::Encoding::UTF_8, limit: nil)
26+
if limit
27+
warn "Console::Format::Safe `limit:` is deprecated, use `depth_limit:` instead.", uplevel: 1, category: :deprecated
28+
depth_limit = limit
29+
end
30+
2131
@format = format
22-
@limit = limit
32+
@depth_limit = depth_limit
33+
@size_limit = size_limit
2334
@encoding = encoding
2435
end
2536

37+
# @attribute [Integer] The maximum depth to recurse into objects.
38+
attr :depth_limit
39+
40+
# @attribute [Integer | Nil] The maximum byte size of the serialized output.
41+
attr :size_limit
42+
2643
# Dump the given object to a string.
2744
#
45+
# The common case is a single fast serialization. If that fails (e.g. circular
46+
# references, excessive nesting, or encoding errors) or its output exceeds
47+
# {size_limit}, it falls back to {safe_dump}, which rebuilds the record
48+
# field-by-field within the limit.
49+
#
2850
# @parameter object [Object] The object to dump.
2951
# @returns [String] The dumped object.
3052
def dump(object)
31-
@format.dump(object, @limit)
32-
rescue SystemStackError, StandardError => error
33-
@format.dump(safe_dump(object, error))
53+
buffer = @format.dump(object, @depth_limit)
54+
55+
if @size_limit and buffer.bytesize > @size_limit
56+
return safe_dump(object)
57+
end
58+
59+
return buffer
60+
rescue SystemStackError, StandardError
61+
return safe_dump(object)
3462
end
3563

3664
private
3765

66+
# Produce a safe, size-limited serialization of the given object. This is the
67+
# fallback path, used both when direct serialization fails (an exception) and
68+
# when its output exceeds {size_limit}.
69+
#
70+
# Each top-level value is serialized independently and defensively, so a single
71+
# un-serializable or oversized value cannot break or bloat the whole record.
72+
# Whenever a field is degraded, the reason is recorded in a trailing `"truncated"`
73+
# object that maps the field name to why it was truncated:
74+
#
75+
# - `"key": true` — the value was dropped because it did not fit the size limit.
76+
# - `"key": {error}` — the value could not be serialized directly; a safe
77+
# representation was kept in its place and the triggering error is recorded.
78+
#
79+
# Fields are kept while they fit, always reserving room for at least a minimal
80+
# `"truncated":true` marker. The detailed reason map is then emitted only if it
81+
# fits in the remaining space; otherwise it degrades to `"truncated":true`. This
82+
# is best-effort — in the worst case the per-field detail is lost — but it keeps
83+
# the bookkeeping simple and the size guarantee hard.
84+
#
85+
# @parameter object [Object] The object to serialize.
86+
# @returns [String] The safe, size-limited serialized record.
87+
def safe_dump(object)
88+
# Serialize hash-like objects field-by-field; anything else falls through to the
89+
# error handler below, which emits a minimal truncated marker.
90+
object = object.to_hash
91+
92+
# Serialize each field once, capturing the error for any value that could not be
93+
# serialized directly. Our own "truncated" key is skipped so it is never duplicated.
94+
errors = {}
95+
fragments = []
96+
object.each do |key, value|
97+
name = key.to_s
98+
next if name == "truncated"
99+
100+
fragment, error = dump_pair(key, value)
101+
errors[name] = error_info(error) if error
102+
fragments << [name, fragment]
103+
end
104+
105+
# Assemble the body, keeping each field while it fits — always reserving room for
106+
# at least a minimal `"truncated":true` marker. Each truncated field's reason is
107+
# collected: its error (value recovered) or `true` (dropped for size).
108+
buffer = +"{"
109+
first = true
110+
reasons = {}
111+
112+
fragments.each do |name, fragment|
113+
if buffer.bytesize + (first ? 0 : 1) + fragment.bytesize + TRUNCATED.bytesize + 2 <= @size_limit
114+
buffer << "," unless first
115+
buffer << fragment
116+
first = false
117+
118+
# The value was kept; if it had to be recovered, note why.
119+
reasons[name] = errors[name] if errors[name]
120+
else
121+
# The value did not fit and was dropped entirely.
122+
reasons[name] = true
123+
end
124+
end
125+
126+
unless reasons.empty?
127+
# Include the detailed reasons if they fit, otherwise fall back to the minimal
128+
# marker so the truncation is still signalled.
129+
detailed = "\"truncated\":#{@format.dump(reasons)}"
130+
fits = buffer.bytesize + (first ? 0 : 1) + detailed.bytesize + 1 <= @size_limit
131+
132+
buffer << "," unless first
133+
buffer << (fits ? detailed : TRUNCATED)
134+
end
135+
136+
buffer << "}"
137+
138+
return buffer
139+
rescue SystemStackError, StandardError
140+
return "{#{TRUNCATED}}"
141+
end
142+
143+
# Serialize a single top-level `"key":value` pair, safely handling values that
144+
# cannot be serialized directly.
145+
#
146+
# @parameter key [Object] The field key.
147+
# @parameter value [Object] The field value.
148+
# @returns [Array(String, Exception | Nil)] The `"key":value` fragment and the error, if recovery was needed.
149+
def dump_pair(key, value)
150+
value_json, error = dump_value(value)
151+
152+
return ["#{dump_string(String(key))}:#{value_json}", error]
153+
end
154+
155+
# Serialize a single value, falling back to a safe representation on failure.
156+
#
157+
# @parameter value [Object] The value to serialize.
158+
# @returns [Array(String, Exception | Nil)] The serialized value and the error, if recovery was needed.
159+
def dump_value(value)
160+
[@format.dump(value, @depth_limit), nil]
161+
rescue SystemStackError, StandardError => error
162+
[@format.dump(safe_dump_recurse(value)), error]
163+
end
164+
165+
# Serialize a string as a JSON string, encoding it safely first.
166+
#
167+
# @parameter value [String] The string to serialize.
168+
# @returns [String] The serialized (quoted) string.
169+
def dump_string(value)
170+
@format.dump(value.encode(@encoding, invalid: :replace, undef: :replace))
171+
end
172+
38173
# Filter the backtrace to remove duplicate frames and reduce verbosity.
39174
#
40175
# @parameter error [Exception] The exception to filter.
@@ -76,24 +211,16 @@ def filter_backtrace(error)
76211
return frames
77212
end
78213

79-
# Dump the given object to a string, replacing it with a safe representation if there is an error.
80-
#
81-
# This is a slow path so we try to avoid it.
214+
# Build a safe, primitive representation of an error for inclusion as an `"error"` field.
82215
#
83-
# @parameter object [Object] The object to dump.
84216
# @parameter error [Exception] The error that occurred while dumping the object.
85-
# @returns [Hash] The dumped (truncated) object including error details.
86-
def safe_dump(object, error)
87-
object = safe_dump_recurse(object)
88-
89-
object[:truncated] = true
90-
object[:error] = {
217+
# @returns [Hash] The error details (class, message, filtered backtrace).
218+
def error_info(error)
219+
{
91220
class: safe_dump_recurse(error.class.name),
92221
message: safe_dump_recurse(error.message),
93222
backtrace: safe_dump_recurse(filter_backtrace(error)),
94223
}
95-
96-
return object
97224
end
98225

99226
# Create a new hash with identity comparison.
@@ -107,7 +234,7 @@ def default_objects
107234
# @parameter limit [Integer] The maximum depth to recurse into objects.
108235
# @parameter objects [Hash] The objects that have already been visited.
109236
# @returns [Object] The dumped object as a primitive representation.
110-
def safe_dump_recurse(object, limit = @limit, objects = default_objects)
237+
def safe_dump_recurse(object, limit = @depth_limit, objects = default_objects)
111238
case object
112239
when Hash
113240
if limit <= 0 || objects[object]

releases.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Releases
22

3+
## Unreleased
4+
5+
- Add a `size_limit` to `Console::Format::Safe` (default 16KiB) which rebuilds oversized records field-by-field, keeping as many top-level fields as fit within the limit.
6+
- Degraded fields are recorded in a `truncated` object that maps each field name to why it was truncated: `true` (dropped for size) or the error (the value could not be serialized directly and a safe representation was kept in its place).
7+
- Rename `Console::Format::Safe`'s `limit:` to `depth_limit:` (with a deprecated `limit:` alias) to clarify its purpose alongside the new `size_limit:`.
8+
39
## v1.35.0
410

511
- Fix handling of `Errno::ENODEV` errors when calculating the width of a terminal that was been re-opened to `File::NULL`.

0 commit comments

Comments
 (0)