paytrace 2.0.2__tar.gz → 2.0.4__tar.gz

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.
Files changed (88) hide show
  1. {paytrace-2.0.2 → paytrace-2.0.4}/CHANGELOG.md +20 -0
  2. {paytrace-2.0.2 → paytrace-2.0.4}/CITATION.cff +1 -1
  3. {paytrace-2.0.2 → paytrace-2.0.4}/PKG-INFO +1 -1
  4. {paytrace-2.0.2 → paytrace-2.0.4}/pyproject.toml +1 -1
  5. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/_version.py +1 -1
  6. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/analytics.py +6 -4
  7. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/artifacts.py +7 -3
  8. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/net.py +59 -13
  9. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/sellersjson.py +101 -5
  10. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_artifacts.py +39 -0
  11. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_sellersjson.py +86 -0
  12. {paytrace-2.0.2 → paytrace-2.0.4}/.github/ISSUE_TEMPLATE/new_collector.md +0 -0
  13. {paytrace-2.0.2 → paytrace-2.0.4}/.gitignore +0 -0
  14. {paytrace-2.0.2 → paytrace-2.0.4}/CODE_OF_CONDUCT.md +0 -0
  15. {paytrace-2.0.2 → paytrace-2.0.4}/CONTRIBUTING.md +0 -0
  16. {paytrace-2.0.2 → paytrace-2.0.4}/LICENSE +0 -0
  17. {paytrace-2.0.2 → paytrace-2.0.4}/NOTICE +0 -0
  18. {paytrace-2.0.2 → paytrace-2.0.4}/README.md +0 -0
  19. {paytrace-2.0.2 → paytrace-2.0.4}/SECURITY.md +0 -0
  20. {paytrace-2.0.2 → paytrace-2.0.4}/VERIFYING.md +0 -0
  21. {paytrace-2.0.2 → paytrace-2.0.4}/adversarial/README.md +0 -0
  22. {paytrace-2.0.2 → paytrace-2.0.4}/adversarial/TRACE_clean.md +0 -0
  23. {paytrace-2.0.2 → paytrace-2.0.4}/adversarial/TRACE_defended.md +0 -0
  24. {paytrace-2.0.2 → paytrace-2.0.4}/adversarial/TRACE_diff.md +0 -0
  25. {paytrace-2.0.2 → paytrace-2.0.4}/adversarial/TRACE_naive.md +0 -0
  26. {paytrace-2.0.2 → paytrace-2.0.4}/demo/run_demo.py +0 -0
  27. {paytrace-2.0.2 → paytrace-2.0.4}/docs/GUIDE.md +0 -0
  28. {paytrace-2.0.2 → paytrace-2.0.4}/examples/end_to_end_domain.py +0 -0
  29. {paytrace-2.0.2 → paytrace-2.0.4}/examples/reference_collector.py +0 -0
  30. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/__init__.py +0 -0
  31. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/adstxt.py +0 -0
  32. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/adversarial.py +0 -0
  33. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/agent/__init__.py +0 -0
  34. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/agent/agent.py +0 -0
  35. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/agent/fixture_data/__init__.py +0 -0
  36. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/agent/fixture_data/ads.clean.txt +0 -0
  37. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/agent/fixture_data/ads.txt +0 -0
  38. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/agent/fixture_data/sellers.clean.json +0 -0
  39. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/agent/fixture_data/sellers.poisoned.json +0 -0
  40. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/agent/fixtures.py +0 -0
  41. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/agent/guards.py +0 -0
  42. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/agent/tools.py +0 -0
  43. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/catalog.py +0 -0
  44. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/cli.py +0 -0
  45. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/__init__.py +0 -0
  46. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/base.py +0 -0
  47. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/business.py +0 -0
  48. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/disclosure.py +0 -0
  49. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/infra.py +0 -0
  50. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/lookups.py +0 -0
  51. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/persona.py +0 -0
  52. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/records.py +0 -0
  53. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/registries.py +0 -0
  54. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/collectors/surface.py +0 -0
  55. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/data/registries.yaml +0 -0
  56. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/egress.py +0 -0
  57. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/enrich.py +0 -0
  58. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/extract.py +0 -0
  59. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/fingerprint.py +0 -0
  60. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/index.py +0 -0
  61. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/ingest.py +0 -0
  62. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/netsec.py +0 -0
  63. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/pinned.py +0 -0
  64. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/pivot.py +0 -0
  65. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/portfolio.py +0 -0
  66. {paytrace-2.0.2 → paytrace-2.0.4}/src/paytrace/robin_ingest.py +0 -0
  67. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_adstxt.py +0 -0
  68. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_adversarial.py +0 -0
  69. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_adversarial_example.py +0 -0
  70. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_agent.py +0 -0
  71. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_catalog.py +0 -0
  72. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_cli.py +0 -0
  73. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_collectors.py +0 -0
  74. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_disclosure.py +0 -0
  75. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_egress.py +0 -0
  76. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_enrich.py +0 -0
  77. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_extract.py +0 -0
  78. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_fingerprint.py +0 -0
  79. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_index.py +0 -0
  80. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_ingest.py +0 -0
  81. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_lookups.py +0 -0
  82. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_obfuscation_collectors.py +0 -0
  83. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_persona_gate.py +0 -0
  84. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_records.py +0 -0
  85. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_robin_ingest.py +0 -0
  86. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_security.py +0 -0
  87. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_surface.py +0 -0
  88. {paytrace-2.0.2 → paytrace-2.0.4}/tests/test_transport.py +0 -0
@@ -6,6 +6,26 @@ versioning follows [SemVer](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.0.4] - 2026-09-23
10
+
11
+ - sellers.json documents larger than the body cap are now streamed and scanned
12
+ instead of truncated. Google's is 104 MB uncompressed, so the payee behind any
13
+ Google-monetised site could never be named — the ad system serving most
14
+ ad-funded sites. Only the matched record is kept; the retrieval stays
15
+ evidenced by a byte count and a SHA-256 taken over the stream.
16
+ - Scan-mode retrievals bypass the cache, which would otherwise serve the
17
+ truncated copy left by an earlier ordinary fetch.
18
+
19
+ ## [2.0.3] - 2026-09-23
20
+
21
+ - One AdSense payee is now one node. `ca-pub-N` in page source and `pub-N` in
22
+ ads.txt were captured in different shapes by different collectors, so a single
23
+ account became two graph nodes with its evidence divided between them — on a
24
+ real domain the same ID resolved STRONG_EVIDENCE under one spelling and
25
+ UNSUPPORTED under the other. The `adsense_pub` scheme is gone with it.
26
+ - UA identifiers now key on the account (`UA-1234`), not the property
27
+ (`UA-1234-2`), in every collector.
28
+
9
29
  ## [2.0.2] - 2026-09-22
10
30
 
11
31
  - `paytrace run` no longer crashes on a real network. It closed its HTTP
@@ -25,4 +25,4 @@ keywords:
25
25
  - attribution
26
26
  - corporate registries
27
27
  license: Apache-2.0
28
- version: 2.0.2
28
+ version: 2.0.4
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: paytrace
3
- Version: 2.0.2
3
+ Version: 2.0.4
4
4
  Summary: Trace a website to the legal entity that gets paid for it
5
5
  Project-URL: Homepage, https://github.com/tusharkarumudi/paytrace
6
6
  Project-URL: Issues, https://github.com/tusharkarumudi/paytrace/issues
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "paytrace"
7
- version = "2.0.2"
7
+ version = "2.0.4"
8
8
  description = "Trace a website to the legal entity that gets paid for it"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -5,4 +5,4 @@ circular import: the package __init__ imports those submodules before it has
5
5
  finished defining __version__.
6
6
  """
7
7
 
8
- __version__ = "2.0.2"
8
+ __version__ = "2.0.4"
@@ -37,10 +37,12 @@ from .base import Collector, register
37
37
  # above infrastructure.
38
38
 
39
39
  ID_PATTERNS: dict[str, tuple[re.Pattern, Reliability]] = {
40
- # ca-pub-XXXXXXXXXXXXXXXX -- one AdSense payee account
41
- "adsense": (re.compile(r"\bca-pub-(\d{16})\b"), Reliability.AUTHORITATIVE),
42
- # pub-XXXXXXXXXXXXXXXX in ads.txt context
43
- "adsense_pub": (re.compile(r"\bpub-(\d{16})\b"), Reliability.AUTHORITATIVE),
40
+ # One AdSense payee account, written `ca-pub-N` in page source and `pub-N`
41
+ # in ads.txt. ONE pattern, capturing the digits: two patterns under two
42
+ # scheme names split one payee into separate graph nodes, so its evidence
43
+ # was divided and the same ID could come out STRONG_EVIDENCE under one
44
+ # spelling and UNSUPPORTED under the other.
45
+ "adsense": (re.compile(r"\b(?:ca-)?pub-(\d{16})\b"), Reliability.AUTHORITATIVE),
44
46
  # G-XXXXXXXXXX GA4 measurement
45
47
  "ga4": (re.compile(r"\b(G-[A-Z0-9]{8,12})\b"), Reliability.STRONG),
46
48
  # UA-XXXXXXXX-N legacy Universal Analytics; the account part is UA-XXXXXXXX
@@ -76,10 +76,14 @@ _STAGING = re.compile(
76
76
 
77
77
  #: Service identifier schemes worth extracting from source. Each is a pivot.
78
78
  _SERVICE_IDS = {
79
- "adsense": re.compile(r"\b(pub-\d{16})\b"),
79
+ # Digits only, matching analytics.py: the `pub-` prefix is spelling, not
80
+ # identity. Keeping it here made one payee two nodes.
81
+ "adsense": re.compile(r"\b(?:ca-)?pub-(\d{16})\b"),
80
82
  "ga4": re.compile(r"\b(G-[A-Z0-9]{8,12})\b"),
81
- "ua": re.compile(r"\b(UA-\d{4,10}-\d{1,4})\b"),
82
- "gtm": re.compile(r"\b(GTM-[A-Z0-9]{6,8})\b"),
83
+ # The ACCOUNT part only, matching analytics.py. UA-1234-1 and UA-1234-2 are
84
+ # two properties of one account; keeping the suffix split them.
85
+ "ua": re.compile(r"\b(UA-\d{4,10})-\d{1,4}\b"),
86
+ "gtm": re.compile(r"\b(GTM-[A-Z0-9]{5,9})\b"),
83
87
  "fbpixel": re.compile(r"fbq\(['\"]init['\"],\s*['\"](\d{15,16})['\"]"),
84
88
  "hotjar": re.compile(r"hjid[:=]\s*(\d{6,8})"),
85
89
  "intercom": re.compile(r"app_id[\"']?\s*[:=]\s*[\"']([a-z0-9]{8})[\"']"),
@@ -174,6 +174,8 @@ class Fetcher:
174
174
  # of the investigation wrote.
175
175
  self.url_policy = url_policy or UrlPolicy()
176
176
  self.blocked: list[tuple[str, str]] = []
177
+ #: byte count and digest of the last scan-mode retrieval
178
+ self.last_scan: dict | None = None
177
179
  # Content varies by the requester's apparent location, so the exit used
178
180
  # is part of the evidence rather than a transport detail. A capture
179
181
  # that does not record its egress is not reproducible even in
@@ -281,7 +283,19 @@ class Fetcher:
281
283
  headers: dict | None = None,
282
284
  allow_html: bool = False,
283
285
  egress: str = "",
286
+ scanner=None,
287
+ scan_max_bytes: int | None = None,
284
288
  ) -> Response | None:
289
+ """Fetch a URL under the full policy, SSRF and pinning guards.
290
+
291
+ ``scanner`` switches to SCAN MODE: each decoded chunk is handed to the
292
+ callable and the body is NOT retained. That is what makes a resource
293
+ too large to hold usable -- Google's sellers.json is 104 MB, and
294
+ buffering it would put that in memory, in the cache, and in every
295
+ evidence package. In scan mode the evidence records the byte count and
296
+ a SHA-256 computed over the stream, so the retrieval stays verifiable
297
+ without storing it.
298
+ """
285
299
  # Policy is evaluated BEFORE the cache is consulted. Serving a cached
286
300
  # body skipped the check entirely, so `robots_policy: respect` stopped
287
301
  # applying the moment a URL had been fetched once -- including by an
@@ -313,7 +327,12 @@ class Fetcher:
313
327
  return None
314
328
 
315
329
  cp = self._cache_path(url, headers, egress)
316
- if cp.exists():
330
+ # Scan mode must not be served from the cache. The cache holds whatever
331
+ # the capped read produced -- for an oversized body that is a TRUNCATED
332
+ # copy -- and a scan needs the live stream. Serving the cache here made
333
+ # the streaming lookup silently find nothing whenever the URL had been
334
+ # fetched once already.
335
+ if cp.exists() and scanner is None:
317
336
  d = json.loads(cp.read_text())
318
337
  # A cache hit still has to appear in this run's evidence package.
319
338
  # Otherwise a second run consumed retrieved content and produced a
@@ -375,7 +394,9 @@ class Fetcher:
375
394
  outcome="refused", note=note)
376
395
  return None
377
396
 
378
- result = await self._get_following_redirects(url, headers, egress=egress)
397
+ result = await self._get_following_redirects(
398
+ url, headers, egress=egress,
399
+ scanner=scanner, scan_max_bytes=scan_max_bytes)
379
400
  if result is None:
380
401
  self._record_evidence(url, None, b"", egress, outcome="error",
381
402
  note="no response")
@@ -472,7 +493,8 @@ class Fetcher:
472
493
  "the evidence package misleading."
473
494
  ) from e
474
495
 
475
- async def _read_capped(self, response) -> tuple[bytes, bool]:
496
+ async def _read_capped(self, response, scanner=None,
497
+ cap: int | None = None) -> tuple[bytes, bool]:
476
498
  """Read a response body, stopping at ``max_bytes``.
477
499
 
478
500
  ``response.content`` materialises the entire body first, so a hostile
@@ -483,21 +505,44 @@ class Fetcher:
483
505
  The cap applies to bytes *after* transfer decoding, so a decompression
484
506
  bomb is bounded by the same limit.
485
507
  """
486
- buf = bytearray()
508
+ cap = cap or self.max_bytes
509
+ if scanner is None:
510
+ buf = bytearray()
511
+ truncated = False
512
+ async for chunk in response.aiter_bytes():
513
+ remaining = self.max_bytes - len(buf)
514
+ if remaining <= 0:
515
+ truncated = True
516
+ break
517
+ buf.extend(chunk[:remaining])
518
+ if len(chunk) > remaining:
519
+ truncated = True
520
+ break
521
+ return bytes(buf), truncated
522
+
523
+ # Scan mode: hand each chunk to the scanner and retain nothing. Memory
524
+ # stays flat regardless of body size, which is the whole point.
525
+ import hashlib
526
+
527
+ digest = hashlib.sha256()
528
+ seen = 0
487
529
  truncated = False
488
530
  async for chunk in response.aiter_bytes():
489
- remaining = self.max_bytes - len(buf)
490
- if remaining <= 0:
491
- truncated = True
492
- break
493
- buf.extend(chunk[:remaining])
494
- if len(chunk) > remaining:
531
+ if seen + len(chunk) > cap:
532
+ chunk = chunk[: cap - seen]
495
533
  truncated = True
534
+ digest.update(chunk)
535
+ seen += len(chunk)
536
+ scanner(chunk)
537
+ if truncated:
496
538
  break
497
- return bytes(buf), truncated
539
+ self.last_scan = {"bytes": seen, "sha256": digest.hexdigest(),
540
+ "truncated": truncated}
541
+ return b"", truncated
498
542
 
499
543
  async def _get_following_redirects(self, url: str, headers: dict | None,
500
- max_hops: int = 5, egress: str = ""):
544
+ max_hops: int = 5, egress: str = "",
545
+ scanner=None, scan_max_bytes: int | None = None):
501
546
  """Follow redirects manually, validating every hop.
502
547
 
503
548
  Returns ``(status, body, encoding, truncated)``.
@@ -526,7 +571,8 @@ class Fetcher:
526
571
  try:
527
572
  async with client.stream("GET", url, headers=headers) as r:
528
573
  if r.status_code not in (301, 302, 303, 307, 308):
529
- body, truncated = await self._read_capped(r)
574
+ body, truncated = await self._read_capped(
575
+ r, scanner=scanner, cap=scan_max_bytes)
530
576
  return (r.status_code, body, r.encoding, truncated, url,
531
577
  safe_headers(r.request.headers),
532
578
  safe_headers(r.headers))
@@ -248,12 +248,108 @@ def find_seller_in_text(body: str, adsystem: str, seller_id: str,
248
248
 
249
249
 
250
250
  async def resolve_seller(fetcher, adsystem: str, seller_id: str) -> SellerRecord | None:
251
- """Fetch and locate a seller, trying every known location for the ad system."""
251
+ """Fetch and locate a seller, trying every known location for the ad system.
252
+
253
+ Falls back to a streaming scan when the document is too large to hold.
254
+ Google's sellers.json is 104 MB, so the ordinary body cap truncated it and
255
+ the payee could never be named -- for the ad system that serves the
256
+ majority of ad-funded sites.
257
+ """
252
258
  for url in candidate_urls(adsystem):
259
+ seen = len(getattr(fetcher, "blocked", ()))
253
260
  r = await fetcher.get(url)
254
- if not r or r.status != 200 or not r.text:
261
+ truncated = any("truncated" in why
262
+ for _u, why in list(getattr(fetcher, "blocked", ()))[seen:])
263
+ if r and r.status == 200 and r.text:
264
+ rec = find_seller_in_text(r.text, adsystem, seller_id, url)
265
+ if rec:
266
+ return rec
267
+ if truncated:
268
+ rec = await _stream_at(fetcher, url, adsystem, seller_id)
269
+ if rec:
270
+ return rec
271
+ return None
272
+
273
+
274
+ # --------------------------------------------------------------------------- #
275
+ # Streaming lookup, for sellers.json documents too large to hold
276
+ # --------------------------------------------------------------------------- #
277
+
278
+ #: Ceiling for a streamed sellers.json. Google's is 104 MB uncompressed, so the
279
+ #: ordinary 10 MB body cap truncated it and the payee could never be named.
280
+ #: Nothing is retained in scan mode, so this bounds time, not memory.
281
+ STREAM_MAX_BYTES = 512 * 1024 * 1024
282
+
283
+ #: Bytes kept between chunks so a record split across a boundary is still found.
284
+ #: sellers.json records are a few hundred bytes; this is generous.
285
+ _WINDOW = 64 * 1024
286
+
287
+
288
+ class _RecordScanner:
289
+ """Find one seller record in a stream, without holding the stream."""
290
+
291
+ def __init__(self, seller_id: str) -> None:
292
+ self._needle = f'"{seller_id}"'.encode()
293
+ self._buf = b""
294
+ self.record: str | None = None
295
+
296
+ def feed(self, chunk: bytes) -> None:
297
+ if self.record is not None:
298
+ return
299
+ self._buf += chunk
300
+ i = self._buf.find(self._needle)
301
+ if i != -1:
302
+ found = _enclosing_object(self._buf, i)
303
+ if found is not None:
304
+ self.record = found
305
+ self._buf = b""
306
+ return
307
+ if len(self._buf) > _WINDOW:
308
+ self._buf = self._buf[-_WINDOW:]
309
+
310
+
311
+ def _enclosing_object(buf: bytes, pos: int) -> str | None:
312
+ """The JSON object containing ``pos``, or None if it is not complete yet."""
313
+ start = buf.rfind(b"{", 0, pos)
314
+ if start == -1:
315
+ return None
316
+ depth, in_str, esc = 0, False, False
317
+ for j in range(start, len(buf)):
318
+ c = buf[j : j + 1]
319
+ if esc:
320
+ esc = False
255
321
  continue
256
- rec = find_seller_in_text(r.text, adsystem, seller_id, url)
257
- if rec:
258
- return rec
322
+ if c == b"\\":
323
+ esc = True
324
+ continue
325
+ if c == b'"':
326
+ in_str = not in_str
327
+ continue
328
+ if in_str:
329
+ continue
330
+ if c == b"{":
331
+ depth += 1
332
+ elif c == b"}":
333
+ depth -= 1
334
+ if depth == 0:
335
+ return buf[start : j + 1].decode("utf-8", errors="replace")
259
336
  return None
337
+
338
+
339
+ async def _stream_at(fetcher, url: str, adsystem: str, seller_id: str):
340
+ scanner = _RecordScanner(seller_id)
341
+ await fetcher.get(url, scanner=scanner.feed, scan_max_bytes=STREAM_MAX_BYTES)
342
+ if scanner.record is None:
343
+ return None
344
+ return find_seller_in_text('{"sellers":[' + scanner.record + "]}",
345
+ adsystem, seller_id, url)
346
+
347
+
348
+ async def stream_find_seller(fetcher, adsystem: str, seller_id: str):
349
+ """Look up one seller in a sellers.json of any size.
350
+
351
+ Streams the document, matches the record as it passes, and keeps only that
352
+ record. The retrieval stays evidenced: the fetcher records the byte count
353
+ and a SHA-256 taken over the stream.
354
+ """
355
+ return await _stream_at(fetcher, sellers_json_url(adsystem), adsystem, seller_id)
@@ -200,3 +200,42 @@ async def test_staging_subdomains_become_pivots():
200
200
  claims = list(await c.collect(Identifier(IdKind.DOMAIN, "x.example")))
201
201
  subs = [x.object.value for x in claims if x.object.kind is IdKind.DOMAIN]
202
202
  assert "staging.realsite.example" in subs
203
+
204
+
205
+ # ---- one identifier, one node --------------------------------------------- #
206
+
207
+ def test_collectors_agree_on_the_canonical_identifier():
208
+ """`ca-pub-N` in page source and `pub-N` in ads.txt are one payee account.
209
+
210
+ Two collectors captured it in different shapes — digits in one, `pub-`
211
+ included in the other — so a single account became two graph nodes. Its
212
+ evidence was split between them, and on a real domain the same ID came out
213
+ STRONG_EVIDENCE under one spelling and UNSUPPORTED under the other.
214
+ """
215
+ from paytrace.collectors.analytics import ID_PATTERNS
216
+ from paytrace.collectors.artifacts import _SERVICE_IDS
217
+
218
+ digits = "5446113378742009"
219
+ for text in (f'src="//x/adsbygoogle.js?client=ca-pub-{digits}"',
220
+ f"google.com, pub-{digits}, DIRECT"):
221
+ a = ID_PATTERNS["adsense"][0].search(text)
222
+ b = _SERVICE_IDS["adsense"].search(text)
223
+ assert a and b and a.group(1) == b.group(1) == digits, text
224
+
225
+
226
+ def test_ua_identifiers_use_the_account_not_the_property():
227
+ """UA-1234-1 and UA-1234-2 are two properties of one account. Keeping the
228
+ property suffix in one collector and not the other split the account."""
229
+ from paytrace.collectors.analytics import ID_PATTERNS
230
+ from paytrace.collectors.artifacts import _SERVICE_IDS
231
+
232
+ for pattern in (ID_PATTERNS["ua"][0], _SERVICE_IDS["ua"]):
233
+ assert pattern.search("UA-12345678-3").group(1) == "UA-12345678"
234
+
235
+
236
+ def test_no_second_scheme_name_for_one_service():
237
+ """`adsense_pub` was a separate scheme for the same accounts, which splits
238
+ a payee by scheme name rather than by value."""
239
+ from paytrace.collectors.analytics import ID_PATTERNS
240
+
241
+ assert "adsense_pub" not in ID_PATTERNS
@@ -165,3 +165,89 @@ def test_ext_field_survives_as_untrusted_extra():
165
165
  rec = find_seller_in_text(body, "x.example", "1")
166
166
  assert "ext" in rec.extra
167
167
  assert "terminate" in rec.extra["ext"]
168
+
169
+
170
+ # ---- sellers.json documents too large to hold ------------------------------ #
171
+
172
+ def test_scanner_finds_a_record_split_across_chunks():
173
+ """Google's sellers.json is 104 MB; it arrives in chunks and a record can
174
+ straddle any boundary."""
175
+ import json as _json
176
+
177
+ from paytrace.sellersjson import _RecordScanner
178
+
179
+ target = _json.dumps({"seller_id": "156423", "name": "Example Media Holdings Ltd"})
180
+ doc = ('{"sellers":['
181
+ + ",".join(_json.dumps({"seller_id": str(i), "name": f"Other {i}"})
182
+ for i in range(200))
183
+ + "," + target + "]}").encode()
184
+ for size in (1, 7, 512, 65536):
185
+ scanner = _RecordScanner("156423")
186
+ for i in range(0, len(doc), size):
187
+ scanner.feed(doc[i:i + size])
188
+ assert scanner.record, f"missed at chunk size {size}"
189
+ assert _json.loads(scanner.record)["name"] == "Example Media Holdings Ltd"
190
+
191
+
192
+ def test_scanner_reports_nothing_for_an_absent_seller():
193
+ """It must not return a neighbouring record."""
194
+ import json as _json
195
+
196
+ from paytrace.sellersjson import _RecordScanner
197
+
198
+ doc = ('{"sellers":[' + _json.dumps({"seller_id": "1", "name": "A"}) + "]}").encode()
199
+ scanner = _RecordScanner("999")
200
+ scanner.feed(doc)
201
+ assert scanner.record is None
202
+
203
+
204
+ def test_oversized_sellers_json_is_streamed_and_resolved(monkeypatch):
205
+ """The ordinary 10 MB cap truncates Google's sellers.json, so the payee
206
+ could never be named — for the ad system serving most ad-funded sites.
207
+ Nothing is retained: the body is scanned as it arrives."""
208
+ import asyncio
209
+ import json as _json
210
+
211
+ import httpx
212
+
213
+ import paytrace.net as net
214
+ from paytrace.sellersjson import resolve_seller
215
+
216
+ target = _json.dumps({"seller_id": "pub-5446113378742009",
217
+ "name": "Example Media Holdings Ltd",
218
+ "domain": "x.example", "seller_type": "PUBLISHER"})
219
+ filler = ",".join(_json.dumps({"seller_id": str(i), "name": f"F{i}" * 40})
220
+ for i in range(60000))
221
+ doc = ('{"sellers":[' + filler + "," + target + "]}").encode()
222
+ assert len(doc) > 10 * 1024 * 1024, "the body must exceed the cap to be a test"
223
+
224
+ def handler(req):
225
+ if req.url.path == "/robots.txt":
226
+ return httpx.Response(200, text="User-agent: *\nAllow: /\n")
227
+ if req.url.path.endswith("sellers.json"):
228
+ return httpx.Response(200, content=doc,
229
+ headers={"content-type": "application/json"})
230
+ return httpx.Response(404)
231
+
232
+ original = net.Fetcher.__init__
233
+
234
+ def patched(self, *a, **k):
235
+ k["resolver"] = lambda host: ["93.184.216.34"]
236
+ original(self, *a, **k)
237
+ for route in self.egress.egresses:
238
+ self._clients[route.label] = httpx.AsyncClient(
239
+ transport=httpx.MockTransport(handler), follow_redirects=False)
240
+
241
+ monkeypatch.setattr(net.Fetcher, "__init__", patched)
242
+
243
+ async def go():
244
+ f = net.Fetcher(user_agent="t")
245
+ rec = await resolve_seller(f, "google.com", "pub-5446113378742009")
246
+ scan = f.last_scan
247
+ await f.aclose()
248
+ return rec, scan
249
+
250
+ rec, scan = asyncio.run(go())
251
+ assert rec and rec.name == "Example Media Holdings Ltd"
252
+ assert scan and scan["bytes"] == len(doc), "the whole body must be streamed"
253
+ assert scan["sha256"], "the retrieval must stay evidenced by a digest"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes