feat: make the blocklist and manual allowlist opt-in; cut the ban subsystem's on-loop cost (#2577)

Two features ran on every shard out of the box, each polling on its own 60s timer for files most shards never generate, neither ever asked for. Fixing that turned into untangling why they shared a config file — and then into the on-loop cost of the three lists behind them.

## Before / after

Measured on the shipped defaults. On-loop numbers are what freezes the world; the tick budget is 8 ms.

| | before | after |
|---|---:|---:|
| Blocklist poll on a shard with no list | every 60s, forever | **none** (opt-in) |
| Manual allowlist poll on a shard with no carve-outs | every 60s, forever | **none** (opt-in) |
| Promote-guard sweep timer | leaked on `Stop()` | stopped, and only started when hits are reported |
| Login allowlist flush, on-loop | O(n) walk + 2 arrays **every 60s**, LOH past ~5,300 entries | reused buffers, **hourly**, zero steady-state allocation |
| Auto-denylist, accept path | 9.1 ns/call | **6.1 ns/call** |
| Auto-denylist, sustained flood at cap (60k rejected) | 26.7 ms | **9.3 ms** |
| Auto-denylist, flood end — **worst single call** | 9.49 ms | **0.05 ms** |
| Auto-denylist cap | 65,536 (stranding 9,895 slots) | **324,449** (exact `HashSet` capacity, ~19 MB) |

The auto-denylist row that matters is the third: the on-loop stall at flood end drops **190×**, because retiring lapsed holds is now the number expiring rather than the number held.

## Why this design

It is built for the shape of attack these shards actually see: **hundreds to a few thousand connections per second**, occasionally tens of thousands, sustained over minutes rather than delivered instantly. Against that shape the cap now covers the whole observed range (50k–250k distinct sources) in memory, and the work of expiring them spreads across the accept calls that were already happening.

There is one case this design is *worse* at than the old one: if every held entry lapses within the same millisecond, retiring them costs ~10.7 ms against the old ~8.9 ms, because the ring's random-access set removals lose to a sequential dictionary scan. Reaching it requires an entire flood to arrive inside one millisecond. **A shard absorbing 324,449 connections in a millisecond is finished at the accept path no matter what this list does** — that is the point where the answer is upstream security and scrubbing (an L4 proxy, edge filtering, a bouncer at the kernel), not a data structure in the game loop. We chose the design that fits the attacks we see and degrades honestly past them, rather than over-engineering for one we do not.

## Blocklist — now opt-in

`BlocklistFilter.Start` only bailed when `_path == null`, which needs `file` to be empty. The default is `"Configuration/ip-blocklist.txt"`, so on any default install both `Task.Run(PollLoop)` and a recurring `SweepGuard` timer started unconditionally, logging *"Blocklist inert: no list at …; polling every 60s"* and then doing exactly that forever.

Adds `"enabled"`, default `false`, using the `_enabled = s.Enabled && <preconditions>` idiom already in `LoginAllowlist` and `AutoDenylist`. **Upgrade is deliberately loud**: a missing key binds to the default, so `LogWhyDisabled()` splits three cases and a shard with a list on disk but no `enabled` key gets a **Warning**, not silence.

## `FileAllowlist` → `ManualAllowlist`, with its own config

Moves to `Configuration/ip-allowlist.json` (`enabled` default `false`, `files`, `reloadInterval`) and into `Network/ManualAllowlist/`, mirroring `Network/LoginAllowlist/`.

It was never a sub-feature of the blocklist. `ManualAllowlist.Contains` has two callers:

| Caller | Could anything else do it? |
|---|---|
| `BlocklistFilter.Evaluate` | **Yes** — the generator already subtracts these files at generation time |
| `BanExemptions.IsExempt` | **No** — sole mechanism for suppressing behavioural ban contributions |

The second reaches `BanChannel.IsExempt` with no blocklist in the path. A shard running **no blocklist** still needs this so the admin's own IP isn't auto-banned by rate-limit detection, so a shared flag couldn't express it — the implication is asymmetric. They still work together via a startup warning when the blocklist is on and the allowlist is not.

On the name: "File" described the storage. The distinction from `LoginAllowlist` is **provenance** — declared by an operator versus earned by authenticating — and "Manual" matches `BanReasons.Manual`. `allowlistFiles` is removed from `BlocklistSettings` outright; blocklists have not shipped long enough for anyone to have set it.

## Login allowlist flush

`Flush()` allocated two arrays sized to the live entry count and copied the whole dictionary into them **on the game loop**, every 60s. `UInt128` is 16 bytes, so past ~5,300 entries that first array was an LOH allocation once a minute, forever. The file write was already off-loop; the walk was not.

Static buffers grown geometrically; the writer owns them until it posts completion back through `Core.LoopContext`, so `_writing`/`_dirty` stay loop state (rule #10). Interval → 1 hour against a 90-day TTL. Clean shutdown writes synchronously via `EventSink.Shutdown`; `HandleClosed` skips `InvokeShutdown` when crashed, so the crash path subscribes separately and only writes when it is actually on the loop thread. Also fixes a pre-existing hole where `_dirty` was cleared *before* the write, so a failed write dropped entries despite the comment promising a retry.

## Auto-denylist: expiry ring

Reclaiming lapsed holds was O(entries held) — every cap-triggered reclaim during a flood walked the whole dictionary to find the few that expired, and `_warnedFull` suppressed the log, not the work.

A hold is **never refreshed** now: the first detection sets the expiry, later ones leave it. That makes insertion order equal to expiry order, so a ring of the same keys is sorted by construction and retiring stops at the first live record. Nothing is lost — the rate limiter runs *ahead* of the connection filters (`NetState.Network.cs`) and reports to the ban channel, so a flooder whose hold lapses is re-held on its next attempt.

Because the ring carries the expiry, the membership side only answers "present?", so it is a `HashSet` — measured at **36 B/slot against the dictionary's 52**. `HashSet` and `Dictionary` share `HashHelpers`, so the from-empty capacity progression is identical (36,353 → 75,431 → 156,437 → 324,449 → 672,827) and the cap still lands on one exactly. The ring is parallel `UInt128[]`/`long[]` rather than an array of structs — `UInt128` forces 16-byte alignment, so a packed pair costs 32 bytes where these cost 24, and the drain reads only the `long[]`.

Rejected after measuring: splitting the drain into a scan loop plus a removal loop (inside noise — both issue N hash removes, and the pointer math was never the bottleneck), and `Dictionary<UInt128,bool>` with tombstoning instead of removal (10% slower *and* unbounded, which breaks the cap).

## Testing

Build clean, 0 warnings. **1,530 tests pass** — 708 UOContent, 822 Server.

Tests were reworked rather than patched: the refresh test inverts to `Repeat_detection_does_not_extend_the_hold`, the obsolete sweep-throttle test is deleted along with the throttle, and four were added for the ring — set/ring parity, release-then-re-hold not being retired by the stale record, exact fill of a non-power-of-two cap, and the moved allowlist config's casing contract. The throttle test added mid-PR was verified to fail without its fix before being deleted.

One commit is comments only (verified: a diff filtered of `//` lines is empty), removing development narration — a `"(Task 2)"` plan reference, `"matching the per-feature JSON config pattern used by X"` across four loaders, a duplicated threading note — and repointing `Firewall` at `dev-docs/ip-bans-and-allowlists.md` instead of a "ban-channel design doc" that does not exist.

Note `Distribution/Configuration/blocklist.json` is gitignored (`.gitignore:14`) and generated from the record defaults on first boot, so the record default *is* the shipped default.
This commit is contained in:
Kamron Batman 2026-08-13 23:22:35 -07:00 • committed by GitHub
parent 240118340e
commit 2dbaa87377
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 696 additions and 188 deletions

View file

@ -21,9 +21,8 @@ using Server.Json;
namespace Server.Network.Bans;
/// <summary>
/// Loads the <see cref="BlocklistSettings"/> from <c>Configuration/blocklist.json</c> (matching the
/// per-feature JSON config pattern used by <c>AssistantConfiguration</c>). Loaded once; a missing file
/// writes a template so operators have something to edit.
/// Loads the <see cref="BlocklistSettings"/> from <c>Configuration/blocklist.json</c>. Loaded once; a
/// missing file writes a template so operators have something to edit.
/// </summary>
public static class BlocklistConfiguration
{
@ -53,12 +52,19 @@ public static class BlocklistConfiguration
}
/// <summary>
/// Bound configuration for <see cref="BlocklistFilter"/>. The filter is inert unless <see cref="File"/>
/// points at a list that actually exists, so the shipped defaults are safe on a shard that never runs
/// the generator.
/// Bound configuration for <see cref="BlocklistFilter"/>. The filter is inert unless <see cref="Enabled"/>
/// is set and <see cref="File"/> points at a list that exists, so a shard that never runs the generator
/// pays nothing for the defaults.
/// </summary>
public record BlocklistSettings
{
/// <summary>
/// Whether the accept-path gate runs at all. Off by default: the reload poll runs for the whole
/// uptime, which no shard should pay before an operator has chosen to run a blocklist.
/// </summary>
[JsonPropertyName("enabled")]
public bool Enabled { get; set; }
/// <summary>
/// Path to the blocklist. A relative path resolves against <see cref="Core.BaseDirectory"/>; an
/// absolute path is used as-is (handy when several shards share one generated list). Set to
@ -67,19 +73,6 @@ public record BlocklistSettings
[JsonPropertyName("file")]
public string File { get; set; } = "Configuration/ip-blocklist.txt";
/// <summary>
/// Addresses that must never be blocked and never escalated, in the blocklist's own format. The same
/// files <c>tools/Export-IpBlocklist.ps1</c> subtracts at generation time; the shard reads them so an
/// entry also suppresses ban contributions, which the generator alone cannot do. See
/// <see cref="FileAllowlist"/>.
/// </summary>
/// <remarks>
/// The filename may contain wildcards, which is how the default picks up a carve-out an admin adds
/// without anyone editing this file.
/// </remarks>
[JsonPropertyName("allowlistFiles")]
public string[] AllowlistFiles { get; set; } = ["Configuration/ip-allowlist*.txt"];
/// <summary>How often the file is checked for changes. Reloads only happen when it actually changed.</summary>
[JsonPropertyName("reloadInterval")]
public TimeSpan ReloadInterval { get; set; } = TimeSpan.FromSeconds(60);

View file

@ -25,8 +25,8 @@ namespace Server.Network.Bans;
/// <summary>
/// Accept-path gate for a large, file-sourced IP blocklist, hydrated from the file a generator
/// (<c>tools/Export-IpBlocklist.ps1</c>) writes on a schedule. Holds an immutable snapshot swapped
/// atomically by an off-loop reload poll, so accept-path reads are lock-free. Inert when no file is
/// configured or present.
/// atomically by an off-loop reload poll, so accept-path reads are lock-free. Opt-in via
/// <c>blocklist.json</c>'s <c>enabled</c>; inert when off, or when no file is configured or present.
/// </summary>
/// <remarks>
/// This is the demand-paging half of the design: an OS firewall cannot hold millions of entries on
@ -38,12 +38,13 @@ public sealed class BlocklistFilter : IConnectionFilter
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(BlocklistFilter));
// Written by the reload poll (off-loop), read by the accept path (game loop): a single volatile
// reference swap is the whole synchronization story — readers see the old or the new snapshot, whole.
// Written by the reload poll (off-loop), read by the accept path (game loop). One volatile reference
// swap is the whole synchronization story: readers see the old or the new snapshot, whole.
private volatile BlocklistSnapshot _snapshot = BlocklistSnapshot.Empty;
private readonly PromotedGuard _guard = new();
private bool _enabled;
private string _path;
private TimeSpan _interval;
private bool _reportHits;
@ -52,6 +53,7 @@ public sealed class BlocklistFilter : IConnectionFilter
private string _lastGenerated;
private DateTime _lastWriteUtc;
private CancellationTokenSource _cts;
private Timer _sweepTimer;
public string Name => "blocklist";
@ -68,6 +70,7 @@ public sealed class BlocklistFilter : IConnectionFilter
var s = BlocklistConfiguration.Settings;
_path = ResolvePath(s.File);
_enabled = s.Enabled && _path != null;
_interval = s.ReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : s.ReloadInterval;
_reportHits = s.ReportHits;
_banDuration = s.BanDuration;
@ -91,16 +94,26 @@ public sealed class BlocklistFilter : IConnectionFilter
public void Start(CancellationToken token)
{
if (_path == null)
if (!_enabled)
{
logger.Information("Blocklist disabled (\"file\" empty in blocklist.json)");
LogWhyDisabled();
return;
}
// The operator's override on this gate, opted into separately. Without it only the generator's
// subtraction covers carve-outs, and that does not cover ban contributions.
if (!ManualAllowlist.Enabled)
{
logger.Warning(
"Blocklist is on but the manual allowlist is not; set \"enabled\" in ip-allowlist.json so a " +
"carve-out also suppresses ban contributions"
);
}
_cts = CancellationTokenSource.CreateLinkedTokenSource(token);
// A missing file is the shipped default, not an error: the gate stays inert until the poll picks
// up whatever the generator first writes. No restart needed.
// A missing file is not an error: the gate stays inert until the poll picks up whatever the
// generator first writes. No restart needed.
if (File.Exists(_path))
{
Reload(); // synchronous prime; empty on failure (fail-open)
@ -110,17 +123,47 @@ public sealed class BlocklistFilter : IConnectionFilter
logger.Information("Blocklist inert: no list at \"{Path}\"; polling every {Interval}", _path, _interval);
}
// Sweep the promote-guard so a distinct-IP flood cannot grow it unbounded.
Timer.DelayCall(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), SweepGuard);
// Sweep the promote-guard so a distinct-IP flood cannot grow it unbounded. Only marked when hits
// are reported, so there is nothing to sweep otherwise.
if (_reportHits)
{
_sweepTimer = Timer.DelayCall(TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), SweepGuard);
}
_ = Task.Run(() => PollLoop(_cts.Token), _cts.Token);
}
private void LogWhyDisabled()
{
if (_path == null)
{
logger.Information("Blocklist disabled (\"file\" empty in blocklist.json)");
}
else if (File.Exists(_path))
{
// An upgraded shard has a list on disk but no "enabled" key, so say so rather than silently
// dropping a gate it was relying on.
logger.Warning(
"Blocklist is off (\"enabled\" false in blocklist.json) but a list is present at \"{Path}\"; " +
"no addresses will be denied",
_path
);
}
else
{
logger.Information("Blocklist disabled (\"enabled\" false in blocklist.json)");
}
}
public void Stop()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
// Recurring, so an uncancelled sweep survives Stop and the next Start adds a second one.
_sweepTimer?.Stop();
_sweepTimer = null;
}
public bool ShouldDeny(IPAddress address)
@ -153,9 +196,9 @@ public sealed class BlocklistFilter : IConnectionFilter
}
// Both are asked only once the list has matched, so they cost the common accept nothing. The file
// list is usually redundant because the generator subtracts it — except right after an operator adds
// an entry without regenerating, which is exactly when someone is waiting to get back in.
if (FileAllowlist.Contains(address))
// list is usually redundant because the generator subtracts it — except right after an operator
// adds an entry without regenerating, which is when someone is waiting to get back in.
if (ManualAllowlist.Contains(address))
{
return false;
}
@ -248,9 +291,8 @@ public sealed class BlocklistFilter : IConnectionFilter
private void Reload()
{
// Capture the mtime/header BEFORE Load() so the markers describe the version being parsed, not
// one the producer swapped in mid-parse. Stale markers only cost an extra reload next poll;
// capturing after could skip a version entirely.
// Capture the mtime/header BEFORE Load() so they describe the version being parsed. Capturing
// after could skip a version the producer swapped in mid-parse; stale markers only cost a reload.
var writeUtc = default(DateTime);
try
{

View file

@ -46,8 +46,7 @@ public sealed class BlocklistSnapshot
/// Parses a blocklist directly from its UTF-8/ASCII file bytes — one line at a time, splitting on
/// <c>'\n'</c> with no per-line string allocation. IPv4 singles and CIDRs are parsed straight from the
/// byte span; IPv6 (the rare path) decodes the single address token and defers to the framework parser.
/// Malformed lines increment <paramref name="skipped"/> and never throw. Build-time intermediates use
/// the multithreaded pool because this runs off the game loop on the reload/bootstrap thread.
/// Malformed lines increment <paramref name="skipped"/> and never throw.
/// </summary>
public static BlocklistSnapshot Build(ReadOnlySpan<byte> data, out int parsed, out int skipped)
{
@ -183,7 +182,7 @@ public sealed class BlocklistSnapshot
}
/// <summary>
/// Plain set membership, for callers whose set is an ALLOWlist (see <see cref="FileAllowlist"/>) and for
/// Plain set membership, for callers whose set is an ALLOWlist (see <see cref="ManualAllowlist"/>) and for
/// whom <see cref="IsBanned"/> would read backwards. The interval machinery is direction-agnostic.
/// </summary>
public bool Contains(IPAddress ip) => IsBanned(ip);

View file

@ -1,313 +0,0 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: FileAllowlist.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Server.Logging;
namespace Server.Network.Bans;
/// <summary>
/// The operator's own "leave this address alone" list, read from the same files
/// <c>tools/Export-IpBlocklist.ps1</c> subtracts at generation time.
/// </summary>
/// <remarks>
/// The generator already subtracts these from the blocklist, but that only covers being BLOCKED.
/// Behavioural detections never consult the blocklist, so without reading the files here a carve-out is
/// quietly routed around: one scanner behind a shared CGNAT address is enough to get the whole address
/// contributed and firewalled. Reading them also means an entry applies on the next reload rather than the
/// next regeneration. Unconditional, unlike <see cref="LoginAllowlist"/>, but still no shield against a
/// manual ban — see <see cref="BanExemptions"/>.
/// </remarks>
public static class FileAllowlist
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(FileAllowlist));
// Written by the reload poll (off-loop), read by the accept path (game loop): a single volatile
// reference swap is the whole synchronization story — readers see the old or the new snapshot, whole.
private static volatile BlocklistSnapshot _snapshot = BlocklistSnapshot.Empty;
private static string[] _patterns = [];
private static TimeSpan _interval = TimeSpan.FromSeconds(60);
private static long _lastStamp;
private static CancellationTokenSource _cts;
public static int Count => _snapshot.Count;
/// <summary>True when an operator listed this address. Safe before <see cref="Initialize"/>.</summary>
public static bool Contains(IPAddress address) => address != null && _snapshot.Contains(address);
public static void Initialize()
{
// BlocklistFilter.Register ran during the Configure sweep, so the settings are populated.
var settings = BlocklistConfiguration.Settings;
if (settings == null)
{
return;
}
_patterns = ResolvePaths(settings.AllowlistFiles);
_interval = settings.ReloadInterval <= TimeSpan.Zero ? TimeSpan.FromSeconds(60) : settings.ReloadInterval;
if (_patterns.Length == 0)
{
logger.Information("File allowlist disabled (\"allowlistFiles\" empty in blocklist.json)");
return;
}
Reload();
_cts = CancellationTokenSource.CreateLinkedTokenSource(Core.ClosingTokenSource.Token);
_ = Task.Run(() => PollLoop(_cts.Token), _cts.Token);
}
public static void Stop()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
}
private static string[] ResolvePaths(string[] configured)
{
if (configured == null)
{
return [];
}
var resolved = new string[configured.Length];
var count = 0;
for (var i = 0; i < configured.Length; i++)
{
var path = configured[i];
if (string.IsNullOrWhiteSpace(path))
{
continue;
}
// Relative resolves against BaseDirectory, never the working directory, which differs when the
// shard is launched from elsewhere. Absolute is as-is, so shards can share a list.
resolved[count++] = Path.IsPathRooted(path) ? path : Path.Join(Core.BaseDirectory, path);
}
Array.Resize(ref resolved, count);
return resolved;
}
/// <summary>
/// Expands the configured patterns to actual files. Done per poll rather than once, so a carve-out an
/// admin adds is picked up without a restart.
/// </summary>
private static string[] ExpandPaths()
{
var files = new List<string>();
for (var i = 0; i < _patterns.Length; i++)
{
var pattern = _patterns[i];
var name = Path.GetFileName(pattern);
if (name.IndexOf('*') < 0 && name.IndexOf('?') < 0)
{
if (File.Exists(pattern))
{
files.Add(pattern);
}
continue;
}
try
{
var dir = Path.GetDirectoryName(pattern);
if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir))
{
continue;
}
var matches = Directory.GetFiles(dir, name);
Array.Sort(matches, StringComparer.Ordinal);
for (var j = 0; j < matches.Length; j++)
{
// Windows wildcard matching still honours legacy short names, so ".txt" can pull in the
// generator's ".txt.tmp" mid-swap. Check the real extension.
if (matches[j].EndsWith(".txt", StringComparison.OrdinalIgnoreCase))
{
files.Add(matches[j]);
}
}
}
catch
{
// Unreadable directory; the next poll retries.
}
}
return files.ToArray();
}
private static async ValueTask PollLoop(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
await Task.Delay(_interval, token);
}
catch (OperationCanceledException)
{
return;
}
try
{
if (Stamp() != _lastStamp)
{
// A save owns the disk and nothing here is urgent.
// See the threading policy in CLAUDE.md (rules #3 and #10).
while (World.Saving || World.WorldState == WorldState.PendingSave)
{
await Task.Delay(TimeSpan.FromSeconds(1), token);
}
Reload();
}
}
catch (OperationCanceledException)
{
return;
}
catch (Exception e)
{
logger.Warning(e, "File allowlist reload check failed; keeping last snapshot ({Count})", Count);
}
}
}
/// <summary>
/// Change fingerprint across every configured file. A missing file contributes nothing, so creating or
/// deleting one also registers as a change.
/// </summary>
private static long Stamp()
{
var stamp = 0L;
var paths = ExpandPaths();
for (var i = 0; i < paths.Length; i++)
{
try
{
var info = new FileInfo(paths[i]);
if (info.Exists)
{
stamp = stamp * 31 + info.LastWriteTimeUtc.Ticks + info.Length;
}
}
catch
{
// Mid-swap by the generator; the next poll picks it up.
}
}
return stamp;
}
private static void Reload()
{
// Fingerprint BEFORE parsing, so it describes the version being read. Capturing after could skip a
// version; a stale fingerprint only costs an extra reload.
var stamp = Stamp();
var combined = ReadAll(out var files);
// Reuses the blocklist parser and interval index: an address set is direction-agnostic.
var next = combined.Length == 0
? BlocklistSnapshot.Empty
: BlocklistSnapshot.Build(combined, out _, out _);
_snapshot = next; // single volatile swap; readers see old or new whole
_lastStamp = stamp;
logger.Information(
"File allowlist loaded {Count} range(s) from {Files} file(s)",
next.Count,
files
);
}
/// <summary>
/// Concatenates every configured file into one buffer. The parser is line-based, so a newline join is
/// enough, and membership stays a single lookup.
/// </summary>
private static byte[] ReadAll(out int files)
{
files = 0;
var paths = ExpandPaths();
var chunks = new byte[paths.Length][];
var total = 0;
for (var i = 0; i < paths.Length; i++)
{
try
{
if (!File.Exists(paths[i]))
{
continue;
}
var bytes = File.ReadAllBytes(paths[i]);
chunks[i] = bytes;
total += bytes.Length + 1; // + newline separator
files++;
}
catch (Exception e)
{
// Fail open per file: losing one entry beats refusing to load the rest.
logger.Warning(e, "Could not read allowlist \"{Path}\"", paths[i]);
}
}
if (total == 0)
{
return [];
}
var combined = new byte[total];
var offset = 0;
for (var i = 0; i < chunks.Length; i++)
{
var chunk = chunks[i];
if (chunk == null)
{
continue;
}
Buffer.BlockCopy(chunk, 0, combined, offset, chunk.Length);
offset += chunk.Length;
combined[offset++] = (byte)'\n';
}
return combined;
}
internal static void LoadForTesting(BlocklistSnapshot snapshot) => _snapshot = snapshot ?? BlocklistSnapshot.Empty;
}