Configure variables, options, and limits
Some layout choices are fixed when you construct CStruct. Others change for each piece of data. CStructSharp keeps
those two groups separate:
- constructor and compilation options describe the format and bound layout preparation;
- operation variables provide integer values such as an externally known array count; and
- read, write, or update options limit work performed on one input.
Supply a runtime variable
This layout cannot know the payload count until the caller supplies COUNT:
struct packet {
uint8 kind;
uint8 payload[COUNT];
};
Pass a read-only integer dictionary to every operation that needs the count:
private static void RuntimePayload()
{
var layout = new CStruct("struct packet { uint8 kind; uint8 payload[COUNT]; };");
var variables = new Dictionary<string, int> { ["COUNT"] = 3 };
byte[] bytes = [0x7F, 0x10, 0x20, 0x30];
StructValue packet = layout.Parse(bytes, "packet", variables);
Equal((byte)0x7F, packet.Get<byte>("kind"));
Equal(3, packet.Get<byte[]>("payload").Length);
object? secondPayload = layout.ReadValue(bytes, "packet.payload[1]", variables);
Equal((byte)0x20, (byte)secondPayload!);
using var stream = new MemoryStream(bytes);
stream.Position = 1;
Equal(3, layout.GetArrayLength(stream, "packet.payload", variables));
Equal(1L, stream.Position);
}
A generated reader takes the dictionary as its variables argument, which comes before the options on the unnamed root
Parse as on Parse<Name>: Wire.Parse(bytes, variables), Wire.Parse(bytes, variables, options), and with limits as a value
Wire.Parse(bytes, variables, in limits), which is the order of Parse(bytes, variables, options); a read with limits and no variables is
Wire.Parse(bytes, in limits). The named readers and the TryParse forms follow the same order. Write Wire.Parse(bytes, null, options) (or options: options) to pass only
the options. Wire.Parse(bytes, default) does not compile: default could be the variables or the limits, so name the argument
(default(ReadLimits) or (IReadOnlyDictionary<string, int>?)null).
The operation copies the entries before it evaluates the layout. A caller value overrides a layout #define with
the same name for that operation, but CStructSharp does not change the dictionary.
Use the same variables for related read, address, length, write, and update calls. Omitting or changing COUNT can
make a later path refer to a different byte position than the initial parse.
Choose the right option type
| Option type | When it applies | Examples of work it limits |
|---|---|---|
CStructCompilationOptions |
Constructing CStruct |
Source length, layout/expression depth, expression work |
ReadOptions |
Parse, read, debug, address, length, pointer traversal | Arrays, strings, total bytes, nesting, pointers |
WriteOptions |
Serialize and direct stream writes | Arrays, strings, total output, nesting, pointer encoding |
UpdateOptions |
Locating and replacing existing storage | Separate traversal-read limits plus inherited write limits |
Properties are init-only, so configure a complete object with an initializer:
var options = new ReadOptions
{
MaxArrayElements = 10_000,
MaxStringBytes = 1_024 * 1_024,
DereferencePointers = false,
};
The public operation reads the supplied values at its outer entry. Reuse an initialized options object when several calls use the same policy; create another object for a different policy.
The four option types are C# records: a with expression makes a copy that changes only the members you name,
and two option objects with the same members are equal. That is how a shared policy gets one variation without
repeating every setting:
private static void OptionsWith()
{
var layout = new CStruct("struct sample { uint8 count; uint16 values[count]; char name[4]; };");
byte[] bytes = [1, 0x34, 0x12, (byte)'a', (byte)'b', 0, 0];
// One shared policy, and a variation that changes a single member.
var strict = new ReadOptions { MaxArrayElements = 8, MaxStringBytes = 64, };
ReadOptions trimmed = strict with { TrimFixedText = true, };
Equal(8, trimmed.MaxArrayElements);
Equal("ab\0\0", layout.Parse(bytes, "sample", options: strict).Get<string>("name"));
Equal("ab", layout.Parse(bytes, "sample", options: trimmed).Get<string>("name"));
// Records compare by their members, so an equal policy is the same policy.
True(strict == new ReadOptions { MaxArrayElements = 8, MaxStringBytes = 64, }, "equal members, equal options");
True(strict != trimmed, "one member differs");
}
The Codecs and Defined members of CStructCompilationOptions hold collections, which compare by reference: two
compilation options that carry different list instances are not equal even when the lists have the same contents.
Understand the defaults
Default limits are intentionally finite:
- layout source: 128 KiB;
- layout or expression/dependency depth: 256;
- expression work: 100,000 steps/nodes;
- one read or write array: 1,000,000 elements;
- one encoded read or write string: 16 MiB;
- total bytes read or written: 64 MiB;
- read pointer depth: 64; and
- read or write nesting depth: 256.
The memory-analysis API budgets its operations with MemoryAccessContext instead of these option records. It uses
the same defaults for total bytes (64 MiB), pointer depth (64), and nesting depth (256); see
memory budgets.
MaxTotalBytesRead counts the bytes an operation consumes, each byte once per read of it. It is not a limit on the
input's file size. Two kinds of field look at bytes before they know how long they are, and neither look is charged:
- A terminated array (
uint8 a[]) is scanned for its all-zero terminator element before its elements are read. The elements and the terminator count once, when they are consumed. The scan stops where the budget ends, so an array that does not fit the budget fails withCStructReadLimitExceptionbefore any element is read. - A terminated string (
cstring,char name[],unicode_string_zero) reads ahead in chunks of up to 256 bytes while it searches for its terminator. Only the bytes through the terminator count; a stream source may still read the rest of the chunk physically, and the reader moves back over it.
For example, struct one { uint8 a[]; } over 01 02 00 09 consumes three bytes (two elements and the terminator),
so a parse succeeds with a budget of 3 and fails at 2, on every input form and in the generated reader too. Debug
parsing spends the same budget as a plain parse: a packed header with a uint16 and a uint32 needs a budget of 6
either way, because debug records carry byte ranges rather than copies. Layouts with unions, pointers, or selected
reads may reread bytes for traversal or overlapping fields, and each reread counts, so the input size is a lower bound,
not the exact cost.
UpdateOptions has separate MaxTraversal* values for bytes read while finding the destination. After the target is
found, its inherited write limits apply to the replacement.
These are safety ceilings, not a promise that every value below them is appropriate for your application. For a network message expected to contain at most 100 items, set a limit near 100 rather than relying on one million.
Pass the limits as a value
ReadOptions is an object, so a program that builds one for every record it parses allocates 88 bytes each time. When the
only thing that changes is a limit, the generated Parse has an overload that takes the limits as a value:
var limits = new ReadLimits { MaxArrayElements = 4096, MaxTotalBytesRead = 512 };
Header header = Wire.Parse(bytes, limits); // no ReadOptions object is created
Entry entry = Wire.ParseEntry(bytes, in limits); // the named reader of any declaration
ReadLimits holds the array, string, total-byte and nesting limits, TrimFixedText, InvalidText, OffsetBase, the constraint
Constraints policy and the cancellation token, with the meaning and the defaults they have on ReadOptions; a default(ReadLimits) has the default limits.
It does not hold the pointer policy, which keeps its defaults, so a read that follows pointers with another policy uses
ReadOptions. ReadLimits.From(options) and limits.ToOptions() convert between the two. The overload exists on every
generated surface that has a reader; views and the stream and sequence forms take ReadOptions.
Report offsets in the larger input
A failure reports Offset, the position where the read stopped. For a span that is a position in the span, but a program
that reads a header out of an image reads it into a buffer first, and the position it wants in a report is the one in the
image. ReadOptions.OffsetBase and ReadLimits.OffsetBase give the position of the buffer's first byte in the larger
input, and it is added to the Offset of every failure of a span, array or memory read and to the offset in its message:
byte[] sector = ReadSector(image, 0x1BE0);
var limits = new ReadLimits { OffsetBase = 0x1BE0 };
if (!Wire.TryParse(sector, out Header? header, out CStructException? failure, in limits))
{
Console.WriteLine(failure!.Message); // "... (field 'magic', in 'header', offset 7136)": 0x1BE0 + 2
}
It changes nothing else: positions, pointer addresses and Origin still count from the buffer's first byte. It is at most
2^48 - 1 (ReadLimits.MaxOffsetBase), so that ReadLimits stays as small as it is; a negative or larger value is an
ArgumentOutOfRangeException when it is set. The runtime layout's stream readers and the record sequences over a stream
(ParseMany, Records) report absolute stream positions already and ignore it; the generated Parse of a stream counts
from the stream's position at the call, so it adds it.
Choose a policy for the quiet cases
Four options change behavior rather than a limit. The first two default to the permissive choice, and the third to the strict one:
ReadOptions.TrimFixedText(defaultfalse): fixed-capacity text such aschar name[8],wchar[N], or a boundedutf8 name[N]buffer keeps its NUL padding when read, so61 62 00 00is"ab\0\0". Set the option to read"ab"; only trailing NULs are removed and writing still zero-pads to the declared capacity.ReadOptions.InvalidText(defaultFail): text that is not valid in its encoding - malformed UTF-8, an unpaired UTF-16 surrogate, a non-ASCII byte in acstring- fails the read at its field.InvalidTextHandling.Replacereads each invalid sequence as U+FFFD and continues; see invalid text.ReadOptions.ConstraintsandReadLimits.Constraints(defaultConstraintPolicy.Enforce): the read evaluates the@expectand@supportsconditions of its layout.ConstraintPolicy.Skipevaluates none and collects none, so a layout that states its header rules can still be read where those rules are not wanted; see read without the constraints.WriteOptions.UnknownMembers(defaultIgnore): a supplied value may carry members the struct does not declare and they are skipped.UnknownMemberPolicy.Rejectfails the write before any byte is written -'bogus' is not a member of 'root' (WriteOptions.UnknownMembers is Reject). The layout declares: kind, tail.- for dictionaries,StructValues, and .NET objects alike, nested structs included. The members of an anonymous promoted struct or union are declared members of the struct that contains it, so they are accepted on that parent value, as a parse returns them. It also applies toUpdateOptions.
The what is not an error list explains the other quiet cases.
Handle limit failures
Exceeding a read limit throws CStructReadLimitException with code ReadLimitExceeded. Exceeding a write limit
throws CStructWriteLimitException with code WriteLimitExceeded. Invalid non-positive option values are argument
errors and are rejected before work begins.
Do not respond to a limit failure by raising every limit globally. Confirm the real format maximum, distinguish trusted from untrusted data, and change only the relevant policy. A count that is unexpectedly huge may indicate wrong byte order, a wrong starting position, or an incorrect runtime variable rather than a legitimate large value.
Cancel a long operation
Limits bound how much work an operation may do; they do not bound how long a caller is willing to wait. The
three option records carry a CancellationToken for that. A token is a small handle that another part of the
program - a timeout, a request that was abandoned, a user pressing Stop - can switch to "cancelled"; code that
holds the token checks it at sensible points and stops. The library checks it where a read or write reaches a
boundary: when it enters a struct or union, when it follows a pointer, between the 64 KiB blocks of a numeric
array, between the elements of an array of structs, and between the 256-byte chunks of a terminated string. It
never checks per primitive, so a small read costs nothing for it.
private static void Cancellation()
{
var layout = new CStruct("struct point { uint16 x; uint16 y; }; struct path { uint8 count; point points[count]; };");
byte[] bytes = [2, 1, 0, 2, 0, 3, 0, 4, 0];
// A token that is already cancelled ends the read at its first boundary, before any byte is decoded.
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
var options = new ReadOptions { CancellationToken = cancelled.Token, };
Throws<OperationCanceledException>(() => layout.Parse(bytes, "path", options: options));
// The same options with a live token read normally; a timeout is the usual source of one.
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
StructValue path = layout.Parse(bytes, "path", options: options with { CancellationToken = timeout.Token, });
Equal(2, path.Get<StructValue[]>("points").Length);
}
Cancellation is not a read failure: the operation ends with the runtime's OperationCanceledException, which
TryReadValue<T> lets through (it restores the stream position first) rather than turning into false. An update
stages its bytes before it commits, so a cancelled update leaves the destination unchanged; a direct stream write
may have written a prefix, as any late failure may. Generated classes observe the same token through their
ReadOptions/WriteOptions arguments.
Continue with Handle errors and recovery, or use the exact language limits when defining an input policy.