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.
- fluidattacks_agent/__init__.py +0 -0
- fluidattacks_agent/batch.py +161 -0
- fluidattacks_agent/deliver.py +178 -0
- fluidattacks_agent/distributions.py +80 -0
- fluidattacks_agent/executions.py +124 -0
- fluidattacks_agent/gate.py +10 -0
- fluidattacks_agent/loads.py +109 -0
- fluidattacks_agent/observer.py +137 -0
- fluidattacks_agent/outbox.py +64 -0
- fluidattacks_agent/patience.py +38 -0
- fluidattacks_agent/post.py +175 -0
- fluidattacks_agent/report.py +286 -0
- fluidattacks_agent/settings.py +119 -0
- fluidattacks_agent/sink.py +163 -0
- fluidattacks_agent/startup.py +439 -0
- fluidattacks_agent/switch.py +22 -0
- fluidattacks_agent-0.1.2.dist-info/METADATA +95 -0
- fluidattacks_agent-0.1.2.dist-info/RECORD +20 -0
- fluidattacks_agent-0.1.2.dist-info/WHEEL +4 -0
- fluidattacks_agent.pth +1 -0
|
@@ -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
|
+
)
|