fluidattacks-agent 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,137 @@
1
+ """Watch what a workload imports, without taking part in importing it."""
2
+
3
+ import sys
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass, field
6
+ from types import ModuleType
7
+ from typing import Protocol
8
+
9
+ from fluidattacks_agent.distributions import Dist, distribution_of
10
+ from fluidattacks_agent.report import Ecosystem, Evidence, Granularity, Record, keyed
11
+
12
+ # the probe rides inside a workload we do not own, so its bookkeeping is
13
+ # bounded the same way the reader's is
14
+ MAX_MODULES = 4096
15
+
16
+ # our own package would otherwise land in the workload's inventory
17
+ OURS = "fluidattacks_agent"
18
+
19
+
20
+ @dataclass
21
+ class ImportObserver:
22
+ """
23
+ A meta path finder that claims nothing and only takes note.
24
+
25
+ The import system asks for nothing but ``find_spec``, so there is no base
26
+ class to derive from, and returning ``None`` from every lookup leaves the
27
+ finders behind it to decide the outcome.
28
+ """
29
+
30
+ # how many times each module was looked up, by its whole dotted name, so a
31
+ # record can name the module that was seen and not only the package holding
32
+ # it. The name is what bounds the ceiling below
33
+ seen: dict[str, int] = field(default_factory=dict)
34
+ moved: set[str] = field(default_factory=set)
35
+ # modules seen past the ceiling, so the loss is never silent
36
+ suppressed: int = 0
37
+
38
+ def note(self, fullname: str) -> str | None:
39
+ """Take note of a module looked up, naming it whenever there is room."""
40
+ if fullname.split(".", maxsplit=1)[0] == OURS:
41
+ return None
42
+ if fullname in self.seen:
43
+ self.seen[fullname] += 1
44
+ self.moved.add(fullname)
45
+ return fullname
46
+ # the ceiling bounds how many distinct names are held, not how often a
47
+ # name already held is counted
48
+ if len(self.seen) >= MAX_MODULES:
49
+ self.suppressed += 1
50
+ return None
51
+ self.seen[fullname] = 1
52
+ self.moved.add(fullname)
53
+ return fullname
54
+
55
+ def find_spec(
56
+ self,
57
+ fullname: str,
58
+ _path: object = None,
59
+ _target: ModuleType | None = None,
60
+ ) -> None:
61
+ """Note the module being looked up, then defer to the finders behind."""
62
+ self.note(fullname)
63
+
64
+ def records(
65
+ self,
66
+ modules: dict[str, ModuleType],
67
+ installed: dict[str, Dist],
68
+ carried: Mapping[tuple[str, str], int],
69
+ ) -> list[Record]:
70
+ """
71
+ Name the distribution behind every module that finished importing.
72
+
73
+ A lookup is only an attempt: an import that raised never reaches
74
+ ``modules``, and reporting it would claim a package loaded when it did
75
+ not. Intersecting the two is what makes this evidence rather than
76
+ intent.
77
+
78
+ Both maps are taken by the caller so this stays a pure fold, and so the
79
+ distribution scan happens once per report instead of once per module.
80
+ """
81
+ found: list[Record] = []
82
+ scanned = bool(installed)
83
+ for name in sorted(self.moved):
84
+ times = self.seen[name]
85
+ if times <= carried.get(keyed(Evidence.IMPORTED, name), 0):
86
+ self.moved.discard(name)
87
+ continue
88
+ module = modules.get(name)
89
+ if module is None:
90
+ # kept, because a lookup mid-import finishes after this flush
91
+ continue
92
+ dist = distribution_of(name, installed)
93
+ if dist is None:
94
+ if scanned:
95
+ self.moved.discard(name)
96
+ continue
97
+ found.append(
98
+ Record(
99
+ ecosystem=Ecosystem.PYPI,
100
+ name=dist.name,
101
+ version=dist.version,
102
+ confidence=dist.confidence,
103
+ symbol=name,
104
+ granularity=granularity_of(module),
105
+ evidence=Evidence.IMPORTED,
106
+ frequency=times,
107
+ ),
108
+ )
109
+ return found
110
+
111
+
112
+ def granularity_of(module: ModuleType) -> Granularity:
113
+ """Say whether a module that imported is a package or a module inside one."""
114
+ # a package is the one that carries the path it searches for its own
115
+ # submodules, so this is read off the module rather than guessed at the name
116
+ return Granularity.PACKAGE if hasattr(module, "__path__") else Granularity.MODULE
117
+
118
+
119
+ class Finder(Protocol):
120
+ def find_spec(
121
+ self,
122
+ fullname: str,
123
+ path: object = None,
124
+ target: ModuleType | None = None,
125
+ /,
126
+ ) -> None: ...
127
+
128
+
129
+ def install(finder: Finder) -> None:
130
+ """Put the finder ahead of the ones that do the real work."""
131
+ sys.meta_path.insert(0, finder)
132
+
133
+
134
+ def uninstall(finder: Finder) -> None:
135
+ """Take the finder back out, leaving the chain as it was found."""
136
+ while finder in sys.meta_path:
137
+ sys.meta_path.remove(finder)
@@ -0,0 +1,64 @@
1
+ """Hold what has not travelled yet, bounded by what holding it costs."""
2
+
3
+ import threading
4
+ from collections import deque
5
+ from dataclasses import dataclass, field
6
+ from typing import Final
7
+
8
+ from fluidattacks_agent.batch import Batch
9
+
10
+ # what evidence waiting to travel may cost the process it waits inside. Bodies
11
+ # arrive packed, so the same ceiling holds about three and a half times the
12
+ # report text it would have held unpacked, and a report is capped at a mebibyte
13
+ # before it is packed at all
14
+ MAX_QUEUED: Final = 32
15
+ MAX_QUEUED_BYTES: Final = 2 << 20
16
+
17
+
18
+ @dataclass
19
+ class Outbox:
20
+ """Batches waiting to travel, bounded by how many and by how much."""
21
+
22
+ limit: int = MAX_QUEUED
23
+ ceiling: int = MAX_QUEUED_BYTES
24
+ # a repr of this would spell every byte of every report still held, and a
25
+ # host that walks its objects for a log line is the reason there is a probe
26
+ held: deque[Batch] = field(default_factory=deque, repr=False)
27
+ weight: int = 0
28
+ # the workload's threads put and delivery takes, so the queue and what it
29
+ # weighs have to move as one step or the ceiling is not a ceiling
30
+ guard: threading.Lock = field(default_factory=threading.Lock, repr=False)
31
+
32
+ def put(self, batch: Batch) -> bool:
33
+ """Take a batch if it fits, and say plainly whether it did."""
34
+ with self.guard:
35
+ if len(self.held) >= self.limit:
36
+ return False
37
+ if self.weight + len(batch.body) > self.ceiling:
38
+ return False
39
+ self.held.append(batch)
40
+ self.weight += len(batch.body)
41
+ return True
42
+
43
+ def take(self) -> Batch | None:
44
+ """Give up the batch that has waited longest, or nothing if none has."""
45
+ with self.guard:
46
+ if not self.held:
47
+ return None
48
+ batch = self.held.popleft()
49
+ self.weight -= len(batch.body)
50
+ return batch
51
+
52
+ def forked(self) -> None:
53
+ """Leave a child able to put, holding none of its parent's batches."""
54
+ # a lock held by the thread draining this at the moment of the fork is
55
+ # held in the child forever: measured, a child inheriting one blocked on
56
+ # its first put, and a put here happens inside a host callback. A fresh
57
+ # one is unheld by construction, and the child has no second thread for
58
+ # it to be held against
59
+ self.guard = threading.Lock()
60
+ # not a loss to count: these are keyed to the parent's instance, the
61
+ # parent still holds the same bytes under the same key, and saying
62
+ # evidence was lost here would be saying so of evidence in flight
63
+ self.held.clear()
64
+ self.weight = 0
@@ -0,0 +1,38 @@
1
+ """Say how long to wait before asking a far end again, and how long at first."""
2
+
3
+ import os
4
+ from typing import Final
5
+
6
+ # the shortest and the longest a refused attempt is waited out
7
+ FLOOR: Final = 1.0
8
+ CEILING: Final = 300.0
9
+
10
+ # 1.0 << 9 is already past the ceiling, so nothing above this is computed
11
+ DOUBLINGS: Final = 9
12
+
13
+ # where a refusal that is about nothing of ours starts, rather than climbing
14
+ TOPMOST: Final = DOUBLINGS + 1
15
+
16
+ HALF: Final = 0.5
17
+
18
+ DRAWN: Final = 2
19
+
20
+
21
+ def fraction() -> float:
22
+ """Draw a share of a window out of the kernel's own entropy."""
23
+ # os is already imported; random would cost 1.3 ms of every start
24
+ return int.from_bytes(os.urandom(DRAWN), "big") / (1 << (8 * DRAWN))
25
+
26
+
27
+ def standoff(tries: int, share: float, ceiling: float = CEILING) -> float:
28
+ """Give the wait a refused attempt earns, out of a window that doubles."""
29
+ if tries <= 0:
30
+ return 0.0
31
+ widest = min(ceiling, FLOOR * (1 << min(tries - 1, DOUBLINGS)))
32
+ # half the window is drawn, which is what keeps replicas apart
33
+ return widest * (HALF + share * HALF)
34
+
35
+
36
+ def splay(interval: float, share: float) -> float:
37
+ """Move a first contact off the second every other replica picked."""
38
+ return interval * share
@@ -0,0 +1,175 @@
1
+ """Say what travels beside a batch, hand it over, and read what came back."""
2
+
3
+ import contextlib
4
+ import enum
5
+ from typing import TYPE_CHECKING, Final, Protocol
6
+
7
+ if TYPE_CHECKING:
8
+ import ssl
9
+ from collections.abc import Callable
10
+
11
+ from fluidattacks_agent.batch import BATCH_TAG, Batch
12
+ from fluidattacks_agent.settings import Delivery
13
+
14
+ # the body is signed as the bytes it is, so nothing may re-encode it on the
15
+ # way. Content-Encoding invites a proxy to normalise exactly that, and one that
16
+ # did would break every signature without touching a byte of the evidence
17
+ CONTENT_TYPE: Final = "application/gzip"
18
+
19
+ # so that a customer's proxy has something to allow which is not a bare
20
+ # address, and a constant, so nothing of the workload's is spelt to reach it
21
+ AGENT: Final = "fluidattacks-agent/1"
22
+
23
+ # a socket that never answers must not become a probe that never delivers
24
+ TIMEOUT: Final = 10.0
25
+
26
+ # read only far enough to let the socket close
27
+ MAX_ANSWER: Final = 4096
28
+
29
+ PORT: Final = 443
30
+
31
+ FORMAT: Final = "X-Watches-Format"
32
+ GROUP: Final = "X-Watches-Group"
33
+ WORKLOAD: Final = "X-Watches-Workload"
34
+ INSTANCE: Final = "X-Watches-Instance"
35
+ SEQ: Final = "X-Watches-Seq"
36
+ STREAM: Final = "X-Watches-Stream"
37
+ SIGNATURE: Final = "X-Watches-Signature"
38
+
39
+ # asked to wait, or asked too early: the answers that are about the moment
40
+ AGAIN: Final = frozenset({408, 425, 429})
41
+
42
+ LOWEST_GOOD: Final = 200
43
+ FIRST_BAD: Final = 300
44
+ FIRST_ERROR: Final = 500
45
+
46
+
47
+ class Answer(Protocol):
48
+ """What a far end sent back, of which nothing but the status is read."""
49
+
50
+ status: int
51
+
52
+ def read(self, amount: int, /) -> bytes: ...
53
+
54
+
55
+ class Connection(Protocol):
56
+ """A connection to the endpoint, open and ours to close."""
57
+
58
+ def request(
59
+ self,
60
+ method: str,
61
+ path: str,
62
+ /,
63
+ *,
64
+ body: bytes,
65
+ headers: dict[str, str],
66
+ ) -> None: ...
67
+
68
+ def getresponse(self) -> Answer: ...
69
+
70
+ def close(self) -> None: ...
71
+
72
+
73
+ class Verdict(enum.Enum):
74
+ """What became of a batch handed over, and nothing of what it held."""
75
+
76
+ DELIVERED = "delivered"
77
+ # this same batch again, later
78
+ RETRY = "retry"
79
+ # nothing of ours is being taken at all, so stop offering
80
+ STOP = "stop"
81
+
82
+
83
+ def judged(status: int) -> Verdict:
84
+ """Say what an answer means for the batch that earned it."""
85
+ if LOWEST_GOOD <= status < FIRST_BAD:
86
+ return Verdict.DELIVERED
87
+ if status in AGAIN or status >= FIRST_ERROR:
88
+ return Verdict.RETRY
89
+ # everything else is the endpoint saying no to us rather than to this
90
+ # batch: a redirect it wants followed, a credential it will not take, a
91
+ # path that is not there. No answer makes this destroy a report, because a
92
+ # blanket refusal cannot be told from one meant for a single batch, and
93
+ # stopping costs nothing: a full outbox refuses a flush before that flush
94
+ # charges the records it rendered, so they are offered again
95
+ return Verdict.STOP
96
+
97
+
98
+ def headers(batch: Batch) -> dict[str, str]:
99
+ """Name everything the far end needs to rebuild what was signed."""
100
+ # the credential is in none of these. What proves the sender is the proof
101
+ # over the batch, and a bearer would be readable by everything in between
102
+ return {
103
+ "Content-Type": CONTENT_TYPE,
104
+ "User-Agent": AGENT,
105
+ FORMAT: BATCH_TAG,
106
+ GROUP: batch.group,
107
+ WORKLOAD: batch.workload,
108
+ INSTANCE: batch.instance,
109
+ SEQ: str(batch.seq),
110
+ STREAM: batch.stream,
111
+ SIGNATURE: batch.signature,
112
+ }
113
+
114
+
115
+ def targeted(path: str, query: str) -> str:
116
+ """Name the resource to ask for, keeping what the endpoint routes by."""
117
+ # a query is the endpoint's and a fragment is the client's own
118
+ return f"{path or '/'}?{query}" if query else path or "/"
119
+
120
+
121
+ def proven() -> "ssl.SSLContext":
122
+ """Give a context the far end has to prove itself on, hostname included."""
123
+ import ssl # noqa: PLC0415
124
+
125
+ return ssl.create_default_context()
126
+
127
+
128
+ def opened(host: str, port: int, bound: float) -> Connection:
129
+ """Open a bounded connection to a far end that proved who it is."""
130
+ # fourteen milliseconds of imports, paid on the thread that delivers
131
+ import http.client # noqa: PLC0415
132
+
133
+ return http.client.HTTPSConnection(host, port, timeout=bound, context=proven())
134
+
135
+
136
+ def posted(
137
+ delivery: Delivery,
138
+ batch: Batch,
139
+ bound: float = TIMEOUT,
140
+ connect: "Callable[[str, int, float], Connection]" = opened,
141
+ ) -> Verdict:
142
+ """Offer one batch to the endpoint, and never raise at the thread."""
143
+ # bounded by whoever offers, since an exit has less of a workload's time
144
+ # to spend than a carrier at rest does
145
+ from http.client import HTTPException # noqa: PLC0415
146
+ from urllib.parse import urlsplit # noqa: PLC0415
147
+
148
+ try:
149
+ split = urlsplit(delivery.endpoint)
150
+ host = split.hostname
151
+ if split.scheme != "https" or not host:
152
+ return Verdict.STOP
153
+ held = connect(host, split.port or PORT, bound)
154
+ except (ValueError, OSError):
155
+ # a setting a later attempt resolves no differently
156
+ return Verdict.STOP
157
+ try:
158
+ # nothing follows a redirect: where a batch goes is not the far end's
159
+ held.request(
160
+ "POST",
161
+ targeted(split.path, split.query),
162
+ body=batch.body,
163
+ headers=headers(batch),
164
+ )
165
+ answer = held.getresponse()
166
+ answer.read(MAX_ANSWER)
167
+ return judged(answer.status)
168
+ except (OSError, HTTPException):
169
+ return Verdict.RETRY
170
+ except ValueError:
171
+ # a header the client will not encode: not transient either way
172
+ return Verdict.STOP
173
+ finally:
174
+ with contextlib.suppress(Exception):
175
+ held.close()
@@ -0,0 +1,286 @@
1
+ """
2
+ Serialise frequency into the watches-exec report the agent reads.
3
+
4
+ The bounds mirror ``watches/crates/domain/src/exec.rs``. A record the reader
5
+ would refuse is dropped here and counted, so the reader's refusal counter stays
6
+ a signal about the wire rather than about us.
7
+ """
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from enum import Enum
12
+ from typing import Final
13
+
14
+ REPORT_TAG: Final = "watches-exec/1"
15
+ ABSENT: Final = "-"
16
+
17
+ MAX_LINE: Final = 4096
18
+ MAX_REPORT: Final = 1 << 20
19
+ ROOM: Final = 256
20
+ MAX_NAME: Final = 214
21
+ MAX_VERSION: Final = 64
22
+ MAX_SYMBOL: Final = 1024
23
+ MAX_ATTRIBUTE: Final = 64
24
+
25
+ # PEP 503: a run of - _ . is one -, and case never distinguishes two projects
26
+ SEPARATORS: Final = re.compile(r"[-_.]+")
27
+
28
+ # the tail of a line is tagged rather than positional, so stating one more thing
29
+ # about a record never changes what a line that stated nothing already meant
30
+ CONFIDENCE: Final = "c"
31
+ GRANULARITY: Final = "g"
32
+ WHEN: Final = "w"
33
+ USED: Final = "u"
34
+
35
+ MAX_WHEN: Final = 3_153_600_000_000
36
+ MAX_USED: Final = 4_102_444_800_000
37
+
38
+ # a kind outside the three rungs: the six fixed columns describe a symbol, and
39
+ # this describes the window the symbols were seen in
40
+ WINDOW: Final = "s"
41
+ DROPPED: Final = "d"
42
+ UNSTAMPED: Final = "n"
43
+ BEYOND_READ: Final = "sl"
44
+ BEYOND_IMPORTED: Final = "si"
45
+ BEYOND_EXECUTED: Final = "sx"
46
+ MONITORED: Final = "m"
47
+ ALONE: Final = "a"
48
+ BACKLOG: Final = "b"
49
+
50
+
51
+ class Evidence(Enum):
52
+ """What the interpreter did with the symbol."""
53
+
54
+ # weaker than IMPORTED: the files were read and no module resulted. Not
55
+ # named "loaded", which reads as a synonym for imported. The tag stays "l"
56
+ # because changing it would mean a new REPORT_TAG for no gain in meaning
57
+ READ = "l"
58
+ IMPORTED = "i"
59
+ EXECUTED = "x"
60
+
61
+
62
+ class Confidence(Enum):
63
+ """How well the version on a record is known, in the reader's words."""
64
+
65
+ EXACT = "exact"
66
+ # the module is shipped by more than one installed distribution, so the
67
+ # number is real and the distribution it is attributed to is a guess
68
+ INFERRED = "inferred"
69
+ UNKNOWN = "unknown"
70
+
71
+
72
+ class Granularity(Enum):
73
+ """What the symbol on a record stands for, in the reader's words."""
74
+
75
+ PACKAGE = "package"
76
+ MODULE = "module"
77
+ FUNCTION = "function"
78
+
79
+
80
+ class Ecosystem(Enum):
81
+ """
82
+ The tags the reader accepts, mirroring its ``Ecosystem::as_str``.
83
+
84
+ A closed set rather than a string: the reader refuses the whole line for a
85
+ tag it does not know, so a free-text column could emit records it would
86
+ reject. Falling behind a new tag on its side only costs us the ability to
87
+ name that ecosystem, which is the harmless direction.
88
+ """
89
+
90
+ PYPI = "pypi"
91
+ NPM = "npm"
92
+ MAVEN = "maven"
93
+ RUBYGEMS = "rubygems"
94
+ HEX = "hex"
95
+ NATIVE = "native"
96
+ GO = "go"
97
+ PACKAGIST = "packagist"
98
+ NUGET = "nuget"
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class Name:
103
+ """
104
+ A package name in the one spelling the reader keys its inventory by.
105
+
106
+ Constructing one is the only way to hold one and construction normalizes,
107
+ so no path reaches the wire with a name the reader could not join. The rule
108
+ is PEP 503, which is the rule for every record this probe emits.
109
+ """
110
+
111
+ value: str
112
+
113
+ def __post_init__(self) -> None:
114
+ object.__setattr__(self, "value", SEPARATORS.sub("-", self.value).lower())
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class Record:
119
+ """One observation: a package, and the symbol it imported or ran."""
120
+
121
+ ecosystem: Ecosystem
122
+ name: Name
123
+ version: str | None
124
+ confidence: Confidence
125
+ symbol: str
126
+ granularity: Granularity
127
+ evidence: Evidence
128
+ frequency: int
129
+ when: int | None = None
130
+ used: int | None = None
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class Window:
135
+ """What the probe held back, in the words the reader folds them by."""
136
+
137
+ # sightings that found no room once an observer stopped taking new symbols,
138
+ # per rung. Sightings and not symbols: a name never admitted is counted
139
+ # again every time it comes back, so this bounds the blind spot from above
140
+ beyond_read: int = 0
141
+ beyond_imported: int = 0
142
+ beyond_executed: int = 0
143
+ # whether the executed rung exists in this process at all, and whether the
144
+ # probe is the only tool watching. Without the second, a count on an
145
+ # executed record cannot be read: it means windows the code ran in only
146
+ # while the probe is alone, and means seen at least once beside a coverage
147
+ # or profiling tool, which re-arms the events for everyone
148
+ monitored: bool = False
149
+ alone: bool = False
150
+ # flushes it would not write because its own undrained reports had piled up
151
+ backlog: int = 0
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class Report:
156
+ """A serialised report, and what building it could not put on the wire."""
157
+
158
+ text: str
159
+ dropped: int
160
+ unstamped: int
161
+ # records that did not fit, for the caller to put on the report after this
162
+ held: tuple[Record, ...] = ()
163
+
164
+
165
+ def keyed(evidence: Evidence, symbol: str) -> tuple[str, str]:
166
+ """Name a record the way a probe holds what it has already carried."""
167
+ return (evidence.value, symbol)
168
+
169
+
170
+ def _writable(value: str, limit: int) -> bool:
171
+ # ``isprintable`` is stricter than the reader's control-character check and
172
+ # never looser, so anything we emit is something it accepts. It is asked
173
+ # first because it is what rules out the characters encoding would raise on
174
+ return bool(value) and value.isprintable() and len(value.encode()) <= limit
175
+
176
+
177
+ def readable(version: str) -> bool:
178
+ """Say whether a version is one the reader would take as written."""
179
+ return _writable(version, MAX_VERSION)
180
+
181
+
182
+ def _known(record: Record) -> bool:
183
+ # a version nobody could read, and a version claimed to be read that is not
184
+ # there, both say more than was witnessed, and the reader refuses either
185
+ return (record.version is None) == (record.confidence is Confidence.UNKNOWN)
186
+
187
+
188
+ def _stamp(tag: str, value: int | None, ceiling: int) -> str | None:
189
+ """Render a stamp, taking only a whole number a clock could have produced."""
190
+ if not isinstance(value, int) or isinstance(value, bool):
191
+ return None
192
+ return f"{tag}={value}" if 0 <= value <= ceiling else None
193
+
194
+
195
+ def _line(record: Record) -> str | None:
196
+ version = ABSENT if record.version is None else record.version
197
+ bounded = (
198
+ (record.name.value, MAX_NAME),
199
+ (record.symbol, MAX_SYMBOL),
200
+ (version, MAX_VERSION),
201
+ (record.confidence.value, MAX_ATTRIBUTE),
202
+ (record.granularity.value, MAX_ATTRIBUTE),
203
+ )
204
+ if record.frequency < 1 or not _known(record):
205
+ return None
206
+ if any(not _writable(value, limit) for value, limit in bounded):
207
+ return None
208
+ stamps = (
209
+ _stamp(WHEN, record.when, MAX_WHEN),
210
+ _stamp(USED, record.used, MAX_USED),
211
+ )
212
+ line = "\t".join(
213
+ (
214
+ record.evidence.value,
215
+ record.ecosystem.value,
216
+ record.name.value,
217
+ version,
218
+ record.symbol,
219
+ str(record.frequency),
220
+ f"{CONFIDENCE}={record.confidence.value}",
221
+ f"{GRANULARITY}={record.granularity.value}",
222
+ *(stamp for stamp in stamps if stamp is not None),
223
+ ),
224
+ )
225
+ return line if len(line.encode()) <= MAX_LINE else None
226
+
227
+
228
+ def _stated(tag: str, value: int) -> str | None:
229
+ # never clamped, unlike a record's own fields: the reader pins a number
230
+ # past its ceiling rather than refusing it, and the pin is what says the
231
+ # true count is larger. Clamping here would hand it an honest looking value
232
+ # and charge the difference away. A zero it reads the same as unsaid is
233
+ # left unsaid, which keeps a quiet window to one short line
234
+ if not isinstance(value, int) or isinstance(value, bool) or value < 1:
235
+ return None
236
+ return f"{tag}={value}"
237
+
238
+
239
+ def _window(window: Window, dropped: int, unstamped: int) -> str:
240
+ stated = (
241
+ _stated(DROPPED, dropped),
242
+ _stated(UNSTAMPED, unstamped),
243
+ _stated(BEYOND_READ, window.beyond_read),
244
+ _stated(BEYOND_IMPORTED, window.beyond_imported),
245
+ _stated(BEYOND_EXECUTED, window.beyond_executed),
246
+ f"{MONITORED}=1" if window.monitored else None,
247
+ f"{ALONE}=1" if window.alone else None,
248
+ _stated(BACKLOG, window.backlog),
249
+ )
250
+ return "\t".join((WINDOW, *(pair for pair in stated if pair is not None)))
251
+
252
+
253
+ def render(
254
+ records: list[Record],
255
+ window: Window | None = None,
256
+ dropped: int = 0,
257
+ unstamped: int = 0,
258
+ ) -> Report:
259
+ """Render records, dropping and counting any the reader would refuse."""
260
+ lines = [REPORT_TAG]
261
+ size = len(REPORT_TAG) + 1
262
+ held: tuple[Record, ...] = ()
263
+ for index, record in enumerate(records):
264
+ line = _line(record)
265
+ if line is None:
266
+ dropped += 1
267
+ continue
268
+ room = len(line.encode()) + 1
269
+ if size + room > MAX_REPORT - ROOM:
270
+ held = tuple(records[index:])
271
+ break
272
+ size += room
273
+ # asked of the record and not of the rendered line: a symbol may spell
274
+ # the tag itself, and searching the line would read that as a date
275
+ if _stamp(WHEN, record.when, MAX_WHEN) is None:
276
+ unstamped += 1
277
+ lines.append(line)
278
+ # on the last report of a flush, once every refusal has been counted
279
+ if window is not None and not held:
280
+ lines.append(_window(window, dropped, unstamped))
281
+ return Report(
282
+ text="\n".join([*lines, ""]),
283
+ dropped=dropped,
284
+ unstamped=unstamped,
285
+ held=held,
286
+ )