@djordje-stojanovic/sigmaskills 0.9.0 → 0.9.2

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to [Sigmaskills](https://github.com/Djordje-Stojanovic/Sigma
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.2] — 2026-10-18
8
+
9
+ ### Fixed
10
+
11
+ - `sigmadeeplook` readers from the held-out rounds: binaries, archives, tables and spreadsheets read more fields correctly (typed binary fields, VBA source, `.reg`, `.deb` members, list columns, MIDI events, docx Title) (#111, #112). The headerless-records test now skips when numpy is missing, which turns the main branch CI green again.
12
+
13
+ ## [0.9.1] — 2026-10-18
14
+
15
+ ### Fixed
16
+
17
+ - `sigmacreatearena`, `sigmaresearch` and `sigmawritegui` had a plain YAML description containing `: `, which strict YAML parsers reject. The descriptions are now quoted, the installer reads quoted values as plain text, and a test keeps every header strict YAML.
18
+
7
19
  ## [0.9.0] — 2026-10-18
8
20
 
9
21
  ### Changed
@@ -269,7 +281,9 @@ Ship high-rigor, installable agent skills (not apps): full-repo audits, performa
269
281
  | 2026-08-11 | `5dab832` | Add CHANGELOG and mark v0.1.0 as the first Sigmaskills release |
270
282
  | 2026-08-11 | `124a666` | Polish v0.1.0 docs for multi-host install and use |
271
283
 
272
- [Unreleased]: https://github.com/Djordje-Stojanovic/Sigmaskills/compare/v0.9.0...HEAD
284
+ [Unreleased]: https://github.com/Djordje-Stojanovic/Sigmaskills/compare/v0.9.2...HEAD
285
+ [0.9.2]: https://github.com/Djordje-Stojanovic/Sigmaskills/compare/v0.9.1...v0.9.2
286
+ [0.9.1]: https://github.com/Djordje-Stojanovic/Sigmaskills/compare/v0.9.0...v0.9.1
273
287
  [0.9.0]: https://github.com/Djordje-Stojanovic/Sigmaskills/compare/v0.8.0...v0.9.0
274
288
  [0.8.0]: https://github.com/Djordje-Stojanovic/Sigmaskills/compare/v0.7.0...v0.8.0
275
289
  [0.7.0]: https://github.com/Djordje-Stojanovic/Sigmaskills/compare/v0.6.1...v0.7.0
package/README.md CHANGED
@@ -6,7 +6,7 @@ Portable [Agent Skills](https://agentskills.io/) that install once and run on **
6
6
 
7
7
  | | |
8
8
  |---|---|
9
- | **Release** | [**v0.9.0**](https://github.com/Djordje-Stojanovic/Sigmaskills/releases/tag/v0.9.0) |
9
+ | **Release** | [**v0.9.2**](https://github.com/Djordje-Stojanovic/Sigmaskills/releases/tag/v0.9.2) |
10
10
  | **Changelog** | [CHANGELOG.md](CHANGELOG.md) |
11
11
  | **License** | [MIT](LICENSE) |
12
12
  | **Spec** | [agentskills.io](https://agentskills.io/) |
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "name": "sigmaskills",
4
- "version": "0.9.0",
4
+ "version": "0.9.2",
5
5
  "description": "Portable Agent Skills monorepo and first-party Sigma Installer",
6
6
  "skills": [
7
7
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djordje-stojanovic/sigmaskills",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Portable Agent Skills monorepo and first-party Sigma Installer",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: sigmacreatearena
3
- description: Builds a fast blind-pairs arena so the user ranks any options by Elo per metric: models, settings, voices, captions, thumbnails, outfits. Grills first, then ships a local page and a board. Use to rank, compare or pick the best of many options by taste. Do not use for 2-3 options (just ask), for measurable checks (script them), or to judge one draft against a reference.
3
+ description: 'Builds a fast blind-pairs arena so the user ranks any options by Elo per metric: models, settings, voices, captions, thumbnails, outfits. Grills first, then ships a local page and a board. Use to rank, compare or pick the best of many options by taste. Do not use for 2-3 options (just ask), for measurable checks (script them), or to judge one draft against a reference.'
4
4
  ---
5
5
 
6
6
  # SigmaCreateArena
@@ -45,7 +45,7 @@ Every row below is a 'Not looked at' row when the tool is absent. Nothing needs
45
45
  | cairosvg | python package | rendering svg | pip install cairosvg |
46
46
  | rapidocr_onnxruntime | python package | OCR of video frames, images, scanned pages | pip install rapidocr-onnxruntime |
47
47
  | lief | python package | real Authenticode verification (chain and hash), imphash | pip install lief |
48
- | oletools | python package | VBA macro source, auto-run routines, suspicious calls | pip install oletools |
48
+ | oletools | python package | fuller VBA analysis (the stdlib reader already decompresses the modules, lists auto-run routines, suspicious calls and strings) | pip install oletools |
49
49
  | python_calamine | python package | very large xlsx files | pip install python-calamine |
50
50
  | dnfile | python package | .NET assembly names, references, types | pip install dnfile |
51
51
  | pymupdf4llm | python package | (reserved) PDF to markdown with tables | pip install pymupdf4llm |
@@ -120,7 +120,14 @@ def look_3d(look):
120
120
  look.flag("PARSE-ERROR", "glTF", f"{type(e).__name__}: {e}"[:100], "")
121
121
  elif st in ("stl", "stl-ascii"):
122
122
  tris = parse_stl(b, st == "stl-ascii")
123
- look.line(f"STL ({'ASCII' if st == 'stl-ascii' else 'binary'}): {len(tris)} triangles. STL has no units and no colour.")
123
+ hdr = ""
124
+ if st != "stl-ascii":
125
+ hdr = b[:80].split(b"\0")[0].decode("latin-1").strip()
126
+ hdr = f" Header text: {hdr!r}." if hdr else " Empty 80-byte header."
127
+ else:
128
+ first = b[:200].decode("latin-1").splitlines()[0].strip()
129
+ hdr = f" First line: {first!r}."
130
+ look.line(f"STL ({'ASCII' if st == 'stl-ascii' else 'binary'}): {len(tris)} triangles.{hdr} STL has no units and no colour.")
124
131
  elif st == "obj":
125
132
  tris, c = parse_obj(b.decode("utf-8", "replace"))
126
133
  look.fact("vertices", c["v"])
@@ -158,6 +165,8 @@ def look_3d(look):
158
165
  look.fact("triangles", len(tris))
159
166
  look.fact("bbox", stats["bbox"])
160
167
  look.line(f"{len(tris)} triangles; bounding box {stats['bbox']['min']} to {stats['bbox']['max']} (size {stats['bbox']['size']}).")
168
+ look.line(f"Mesh measures: surface area {stats['surface_area']:,.3f}, signed volume {stats['signed_volume']:,.3f} (a true volume only when watertight), "
169
+ + (f"centroid {stats['centroid']}, " if stats.get("centroid") else "") + f"{stats['unique_vertices']} unique vertices, {stats['degenerate_triangles']} zero-area triangles (units are those of the file).", 61)
161
170
  if stats.get("watertight") is not None:
162
171
  look.fact("watertight", stats["watertight"])
163
172
  look.line("Watertight (every edge shared by exactly two triangles): " + ("yes" if stats["watertight"] else f"NO ({stats['open_edges']} open edges)"))
@@ -257,6 +266,27 @@ def mesh_stats(look, tris):
257
266
  mn = [round(min(xs), 4), round(min(ys), 4), round(min(zs), 4)]
258
267
  mx = [round(max(xs), 4), round(max(ys), 4), round(max(zs), 4)]
259
268
  out = {"triangles": len(tris), "bbox": {"min": mn, "max": mx, "size": [round(a - b, 4) for a, b in zip(mx, mn)]}}
269
+ area = vol = 0.0
270
+ cx = cy = cz = 0.0
271
+ degenerate = 0
272
+ for a, b, c in tris:
273
+ ux, uy, uz = b[0] - a[0], b[1] - a[1], b[2] - a[2]
274
+ vx, vy, vz = c[0] - a[0], c[1] - a[1], c[2] - a[2]
275
+ nx, ny, nz = uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx
276
+ ar = 0.5 * math.sqrt(nx * nx + ny * ny + nz * nz)
277
+ if ar < 1e-12:
278
+ degenerate += 1
279
+ area += ar
280
+ d = (a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0])) / 6.0
281
+ vol += d
282
+ cx += d * (a[0] + b[0] + c[0]) / 4.0
283
+ cy += d * (a[1] + b[1] + c[1]) / 4.0
284
+ cz += d * (a[2] + b[2] + c[2]) / 4.0
285
+ out["surface_area"] = round(area, 4)
286
+ out["signed_volume"] = round(vol, 4)
287
+ out["centroid"] = [round(cx / vol, 4), round(cy / vol, 4), round(cz / vol, 4)] if abs(vol) > 1e-12 else None
288
+ out["degenerate_triangles"] = degenerate
289
+ out["unique_vertices"] = len({(round(v[0] * 1e5), round(v[1] * 1e5), round(v[2] * 1e5)) for t in tris for v in t}) if len(tris) <= MAX_TRIS_PARSE else None
260
290
  if len(tris) <= MAX_TRIS_PARSE:
261
291
  q = lambda v: (round(v[0] * 1e5), round(v[1] * 1e5), round(v[2] * 1e5))
262
292
  edges = {}
@@ -108,6 +108,15 @@ def look_avro(look):
108
108
  break
109
109
  look.write_text("sample.json", json.dumps(rows, indent=1, default=str)[:20000], "the first 5 records", "json", value=6)
110
110
  look.step("sample records", "fastavro")
111
+ if not look.quick and records:
112
+ import dl_data as D
113
+ names = [n for n, _t in fields]
114
+
115
+ def rowsgen():
116
+ with open(look.path, "rb") as f2:
117
+ for k, rec in enumerate(fa.reader(f2), 1):
118
+ yield k, [json.dumps(rec.get(n), default=str, sort_keys=True) if isinstance(rec.get(n), (list, dict)) else rec.get(n) for n in names]
119
+ D.table_profile(look, "Records", names, rowsgen, "record", records, 84, 28)
111
120
  except Exception as e:
112
121
  look.fail("record values", "fastavro", e)
113
122
 
@@ -327,8 +336,10 @@ def summarize_olevba(results):
327
336
 
328
337
  def olevba_facts(look, data, name):
329
338
  m = look.mod("oletools")
330
- if not m:
331
- look.skip("macro source: auto-run routines, suspicious calls and addresses", "python package oletools not importable" if not look.no_tools else "disabled by --no-tools", "oletools")
339
+ if not m: # the standard-library reader: decompresses the VBA modules itself
340
+ import dl_vba
341
+ if not dl_vba.vba_facts(look, data, name):
342
+ look.skip("macro source: full oletools analysis", "python package oletools not importable" if not look.no_tools else "disabled by --no-tools", "oletools")
332
343
  return
333
344
  try:
334
345
  from oletools.olevba import VBA_Parser
@@ -348,6 +359,8 @@ def olevba_facts(look, data, name):
348
359
  look.step("macro analysis", "oletools")
349
360
  except Exception as e:
350
361
  look.fail("macro analysis", "oletools", e)
362
+ import dl_vba
363
+ dl_vba.vba_facts(look, data, name)
351
364
 
352
365
 
353
366
  # ================================================================= glTF validator
@@ -14,6 +14,7 @@ import os
14
14
  import re
15
15
  import shutil
16
16
  import struct
17
+ import subprocess
17
18
  import tarfile
18
19
  import time
19
20
  import zipfile
@@ -55,9 +56,16 @@ def look_unknown(look):
55
56
  look.skip("file -b type guess", "`file` not found" if not look.no_tools else "disabled by --no-tools", "file", "install the `file` utility (Git for Windows ships it)")
56
57
  if b:
57
58
  look.write_text("hexdump.txt", core.hexdump(b[:512]), "first 512 bytes as hex", "internal")
58
- st = core.strings_of(b, 8, 2000)
59
+ if b and look.size == len(b) or (b and look.size <= (64 << 20)):
60
+ import dl_misc
61
+ for t in dl_misc.record_layout(read_bytes(look, 64 << 20), look.size):
62
+ look.headline = "Headerless binary file that looks like a table of fixed-size records (a guess from the bytes)"
63
+ look.line(t, 93)
64
+ if b:
65
+ look.line("First 32 bytes: " + b[:32].hex(" ") + " | " + "".join(chr(c) if 32 <= c < 127 else "." for c in b[:32]), 52)
66
+ st = [x for x in core.strings_of(b, 6, 4000) if wordy(x[2])]
59
67
  if st:
60
- look.write_text("strings.txt", "\n".join(f"{o:08x} {s[:200]}" for o, k, s in st), "printable strings (min 8)")
68
+ look.write_text("strings.txt", "\n".join(f"{o:08x} {s[:200]}" for o, k, s in st), "printable strings that read as words, paths or names (min 6)")
61
69
  look.ask_human("What is this file meant to be, and where did it come from?")
62
70
 
63
71
 
@@ -438,7 +446,12 @@ def parse_pe(b):
438
446
  end = b.find(b"\0", no)
439
447
  imports.append(b[no:end].decode("latin-1"))
440
448
  p += 20
441
- return {"machine": PE_MACHINES.get(machine, hex(machine)), "timestamp": ts, "subsystem": SUBSYS.get(subsystem, str(subsystem)),
449
+ base = struct.unpack_from("<Q", b, opt + 24)[0] if magic == 0x20B else struct.unpack_from("<I", b, opt + 28)[0]
450
+ osv = struct.unpack_from("<HH", b, opt + 40)
451
+ soi, _hs, csum, _sub, dllc = struct.unpack_from("<IIIHH", b, opt + 56)
452
+ dllflags = [n for bit, n in ((0x40, "ASLR"), (0x20, "high-entropy ASLR"), (0x100, "DEP/NX"), (0x4000, "control-flow guard"), (0x400, "no SEH"), (0x1000, "AppContainer")) if dllc & bit]
453
+ return {"image_base": base, "image_size": soi, "checksum": csum, "linker": f"{b[opt + 2]}.{b[opt + 3]}", "os_version": f"{osv[0]}.{osv[1]}", "mitigations": dllflags, "characteristics": chars,
454
+ "machine": PE_MACHINES.get(machine, hex(machine)), "timestamp": ts, "subsystem": SUBSYS.get(subsystem, str(subsystem)),
442
455
  "pe32plus": magic == 0x20B, "sections": secs, "imports": imports, "dll": bool(chars & 0x2000), "entry_rva": entry,
443
456
  "exports": exports, "export_count": export_count, "signed": bool(sec_off and sec_sz), "version": version}
444
457
 
@@ -481,6 +494,10 @@ def look_binary(look):
481
494
  look.headline = f"Windows {kind} ({info['machine']})" + (f", {info['version']['FileDescription']}" if info["version"].get("FileDescription") else "") + (f", {info['export_count']} exports" if info["export_count"] else "")
482
495
  look.dims = f"{info['machine']}"
483
496
  look.line(f"Windows PE ({kind}): machine {info['machine']}, subsystem {info['subsystem']}." + (f" Exports {info['export_count']} functions, e.g. " + ", ".join(info["exports"][:5]) + "." if info["export_count"] else " No exports."), 80)
497
+ look.line(f"PE header: entry point RVA 0x{info['entry_rva']:x}, image base 0x{info['image_base']:x}, image size {info['image_size']:,} B, {len(info['sections'])} sections, "
498
+ f"linker {info['linker']}, minimum OS {info['os_version']}, mitigations: {', '.join(info['mitigations']) or 'none'}, header checksum {'set' if info['checksum'] else 'zero'}; "
499
+ f"{len(info['imports'])} imported DLLs.", 76)
500
+ look.line("Section table (name, virtual size, raw size, rights): " + "; ".join(f"{x['name']} {x['vsize']:,} / {x['raw']:,} {x['perms']}" for x in info["sections"][:16]) + ".", 47)
484
501
  if info["version"]:
485
502
  look.line("Version info: " + "; ".join(f"{k} {v}" for k, v in info["version"].items()), 78)
486
503
  import dl_pe
@@ -514,9 +531,19 @@ def look_binary(look):
514
531
  look.line(f"Mach-O: cpu {MACHO_CPU.get(cpu, hex(cpu))}.")
515
532
  look.fact("arch", MACHO_CPU.get(cpu, hex(cpu)))
516
533
  elif st == "wasm":
517
- look.line(f"WebAssembly module, version {struct.unpack('<I', b[4:8])[0]}.")
534
+ import dl_cls
535
+ w = dl_cls.parse_wasm(b)
536
+ look.headline = f"WebAssembly module: {len(w['imports'])} imports, {len(w['exports'])} exports, {w['imported_funcs'] + w['functions']} functions"
537
+ for k, t in enumerate(dl_cls.wasm_lines(w)):
538
+ look.line(t, 97 - k)
539
+ look.fact("exports", [n for n, _ in w["exports"]])
518
540
  elif st == "java-class":
519
- look.line(f"Java class file, major version {struct.unpack('>H', b[6:8])[0]}.")
541
+ import dl_cls
542
+ c = dl_cls.parse_class(b)
543
+ look.headline = f"Java class {c['name']}: {len(c['methods'])} methods, {len(c['fields'])} fields, Java {c['release']}"
544
+ for k, t in enumerate(dl_cls.class_lines(c)):
545
+ look.line(t, 97 - k)
546
+ look.fact("methods", [m["name"] for m in c["methods"]])
520
547
  elif st == "dex":
521
548
  look.line("Android Dalvik executable (dex).")
522
549
  else:
@@ -524,7 +551,7 @@ def look_binary(look):
524
551
  except (struct.error, ValueError, IndexError) as e:
525
552
  look.flag("PARSE-ERROR", "header", str(e)[:100], "the header is truncated or malformed; strings and entropy still ran")
526
553
  look.fact("entropy", round(core.entropy(b[:4 << 20]), 3))
527
- st_list = core.strings_of(b, 8, STRINGS_CAP)
554
+ st_list = [x for x in core.strings_of(b, 8, STRINGS_CAP) if wordy(x[2])] # machine-code bytes that happen to be printable are not strings
528
555
  lines = [f"{o:08x} {k} {s[:300]}" for o, k, s in st_list]
529
556
  if lines:
530
557
  look.write_text("strings.txt", "\n".join(lines), f"ASCII and UTF-16LE strings (min 8, {len(lines)} shown, capped at {STRINGS_CAP})", value=-5)
@@ -547,6 +574,19 @@ def look_binary(look):
547
574
  look.ask_human("May the strings or the file be uploaded anywhere? Default: no.", "Which function or behaviour do you need explained?")
548
575
 
549
576
 
577
+ _WORD = re.compile(r"[a-z]{4,}|[A-Za-z]{3,}[_./\:-][A-Za-z]{2,}|[A-Z][A-Z0-9]*_[A-Z0-9_]{3,}|https?://|\d{1,4}[.:/-]\d{1,4}[.:/-]\d{1,4}")
578
+
579
+
580
+ def wordy(s):
581
+ """True when a printable run reads like text (a word, a path, a constant name, a version or date) rather than code bytes."""
582
+ s = s.strip()
583
+ if len(s) < 6:
584
+ return False
585
+ if not _WORD.search(s):
586
+ return False
587
+ return sum(c.isalnum() or c in " _./\\:-,'\"()%=@#<>[]{}$&*+?!;" for c in s) / len(s) >= 0.85
588
+
589
+
550
590
  # ================================================================= archives
551
591
  RESERVED = {"CON", "PRN", "AUX", "NUL", *(f"COM{i}" for i in range(1, 10)), *(f"LPT{i}" for i in range(1, 10))}
552
592
  MAX_MEMBERS = 10000
@@ -639,7 +679,7 @@ def screen_members(look, members, depth):
639
679
  seen.add(key)
640
680
  if m["packed"] and m["size"] / max(1, m["packed"]) > MAX_RATIO:
641
681
  refusals.append(("ratio", m["name"], f"ratio {m['size'] // max(1, m['packed'])}:1 (limit {MAX_RATIO}:1)"))
642
- if m["packed"]:
682
+ if m["packed"] and m.get("offset") is not None: # a 7-Zip listing gives no offsets: nothing to compare
643
683
  ranges.append((m["offset"], m["offset"] + m["packed"], m["name"]))
644
684
  ranges.sort()
645
685
  for a, b in zip(ranges, ranges[1:]):
@@ -730,7 +770,7 @@ def archive_overview(members):
730
770
  out.append("Largest: " + "; ".join(f"{m['name']} ({core.human(m['size'])})" for m in big))
731
771
  nested = [m["name"] for m in files if m["name"].lower().endswith(NESTED_EXT)]
732
772
  if nested:
733
- out.append(f"{len(nested)} members are archives themselves (not opened), e.g. {', '.join(nested[:2])}.")
773
+ out.append(f"{len(nested)} members are archives themselves, e.g. {', '.join(nested[:2])}.")
734
774
  risky = [m["name"] for m in files if m["name"].lower().endswith(RISKY_EXT)]
735
775
  if risky:
736
776
  out.append(f"{len(risky)} runnable-looking members (never run), e.g. {', '.join(risky[:3])}.")
@@ -766,9 +806,70 @@ def member_reader(look, fmt):
766
806
  return None
767
807
  return t.extractfile(m).read(cap + 1)
768
808
  return read, t.close
809
+ sz = look.tool("7z")
810
+ if sz:
811
+ def read(name, cap): # streamed to memory: `7z e -so` writes nothing to disk
812
+ r = subprocess.run([sz, "e", "-so", "-y", str(look.path), name], capture_output=True, timeout=40)
813
+ return r.stdout[:cap + 1] if r.returncode == 0 else None
814
+ return read, (lambda: None)
769
815
  return None, (lambda: None)
770
816
 
771
817
 
818
+ NEST_OPEN = (".zip", ".jar", ".war", ".whl", ".nupkg", ".tar", ".tgz", ".tar.gz", ".tar.xz", ".tar.bz2", ".txz", ".tbz2")
819
+ SMALL_NOTE = 600 # a text member this short is quoted whole
820
+ SMALL_ANY = 65536 # a member this small is read whatever its name: a file with no extension is often text
821
+ INNER_NEST_MAX = 48 << 20 # a nested archive up to this size is opened in memory
822
+
823
+
824
+ def _is_textname(name):
825
+ import dl_prof as P
826
+ return P.is_text_name(name) or P.is_manifest(name)
827
+
828
+
829
+ def nested_items(b, outer, depth, budget):
830
+ """Members of an archive held in memory -> (name, size, text or None); one more level for archives inside it (depth 2 at most).
831
+ Reads only text-like members of up to 4 MB; budget[0] counts the bytes read so far over the whole look."""
832
+ out = []
833
+ try:
834
+ if zipfile.is_zipfile(io.BytesIO(b)):
835
+ z = zipfile.ZipFile(io.BytesIO(b))
836
+ infos = [i for i in z.infolist() if not i.is_dir()][:5000]
837
+
838
+ def get(i):
839
+ return None if i.flag_bits & 1 else z.open(i).read(INNER_TEXT_MAX + 1)
840
+ ent = [(i.filename, i.file_size, i) for i in infos]
841
+ else:
842
+ t = tarfile.open(fileobj=io.BytesIO(b), mode="r:*")
843
+ ms = [m for m in t.getmembers() if m.isfile()][:5000]
844
+
845
+ def get(m):
846
+ return t.extractfile(m).read(INNER_TEXT_MAX + 1)
847
+ ent = [(m.name, m.size, m) for m in ms]
848
+ except Exception:
849
+ return out
850
+ for name, size, h in ent:
851
+ text = None
852
+ if depth < 2 and name.lower().endswith(NEST_OPEN) and size <= INNER_NEST_MAX and budget[0] + size <= INNER_TOTAL_MAX:
853
+ try:
854
+ nb = get(h) if size <= INNER_TEXT_MAX else None
855
+ except Exception:
856
+ nb = None
857
+ if nb is not None:
858
+ budget[0] += len(nb)
859
+ for n2, s2, t2 in nested_items(nb, name, depth + 1, budget):
860
+ out.append((f"{name}!/{n2}", s2, t2))
861
+ elif size <= INNER_TEXT_MAX and (_is_textname(name) or 0 < size <= SMALL_ANY) and budget[0] + size <= INNER_TOTAL_MAX:
862
+ try:
863
+ bb = get(h)
864
+ except Exception:
865
+ bb = None
866
+ if bb is not None:
867
+ budget[0] += len(bb)
868
+ text, _enc = core.decode_text(bb)
869
+ out.append((name, size, text))
870
+ return out
871
+
872
+
772
873
  def archive_contents(look, members, fmt):
773
874
  """What is inside, read in memory: tree stats (lines, languages, tests, env vars, constants), every manifest / README / licence /
774
875
  changelog, and a field profile of each table (CSV, TSV, JSON, JSONL) member. Nothing is extracted to disk."""
@@ -778,11 +879,28 @@ def archive_contents(look, members, fmt):
778
879
  files = [m for m in members if m["kind"] == "file"]
779
880
  used, t0 = 0, time.time()
780
881
  texts = {}
882
+ opened, small = [], {}
781
883
  try:
782
884
  for m in files:
783
885
  name, size = m["name"], m["size"]
784
886
  text = None
785
- want = read and not m["enc"] and (P.is_text_name(name) or P.is_manifest(name)) and size <= max(INNER_TEXT_MAX, INNER_TABLE_MAX if name.lower().endswith((".csv", ".tsv", ".json", ".jsonl", ".ndjson")) else 0)
887
+ if read and not m["enc"] and name.lower().endswith(NEST_OPEN) and size <= INNER_NEST_MAX and used + size <= INNER_TOTAL_MAX and time.time() - t0 < 60:
888
+ try:
889
+ nb = read(name, INNER_NEST_MAX)
890
+ except Exception:
891
+ nb = None
892
+ if nb is not None and len(nb) <= INNER_NEST_MAX:
893
+ used += len(nb)
894
+ n_in = 0
895
+ for iname, isize, itext in nested_items(nb, name, 1, [used]):
896
+ tree.add(f"{name}!/{iname}", isize, itext)
897
+ n_in += 1
898
+ if itext is not None and len(itext) <= SMALL_NOTE and not P.is_manifest(iname):
899
+ small[f"{name}!/{iname}"] = itext
900
+ if itext is not None and (P.is_manifest(iname) or iname.lower().endswith((".csv", ".tsv", ".json", ".jsonl", ".ndjson", ".yaml", ".yml", ".toml", ".xml", ".properties", ".ini", ".cfg", ".txt", ".md"))):
901
+ texts[f"{name}!/{iname}"] = itext
902
+ opened.append(f"{name} ({n_in} inner files)")
903
+ want = read and not m["enc"] and (P.is_text_name(name) or P.is_manifest(name) or 0 < size <= SMALL_ANY) and size <= max(INNER_TEXT_MAX, INNER_TABLE_MAX if name.lower().endswith((".csv", ".tsv", ".json", ".jsonl", ".ndjson")) else 0)
786
904
  if want and used + size <= INNER_TOTAL_MAX and time.time() - t0 < 60:
787
905
  try:
788
906
  b = read(name, max(INNER_TEXT_MAX, INNER_TABLE_MAX))
@@ -791,11 +909,18 @@ def archive_contents(look, members, fmt):
791
909
  if b is not None:
792
910
  used += len(b)
793
911
  text, _enc = core.decode_text(b)
912
+ if text is not None and len(text) <= SMALL_NOTE and not P.is_manifest(name):
913
+ small[name] = text
794
914
  if text is not None and (P.is_manifest(name) or name.lower().endswith((".csv", ".tsv", ".json", ".jsonl", ".ndjson", ".yaml", ".yml", ".toml", ".xml", ".properties", ".ini", ".cfg"))):
795
915
  texts[name] = text
796
916
  tree.add(name, size, text)
797
917
  finally:
798
918
  close()
919
+ for n, t in list(small.items())[:20]:
920
+ if t.strip():
921
+ look.line(f"Small text member {n}: " + " | ".join(x.strip() for x in t.strip().splitlines()[:8])[:SMALL_NOTE], 64, "small")
922
+ if opened:
923
+ look.line("Archives inside this archive, opened in memory (nested names are written outer!/inner): " + "; ".join(opened[:10]) + ".", 91)
799
924
  tree.emit(look, "Contents", 90, [("members", len(members)), ("folders", sum(1 for m in members if m["kind"] == "dir") or None),
800
925
  ("encrypted members", sum(1 for m in members if m["enc"]) or None), ("bytes read to look inside", used)])
801
926
  # member dates
@@ -818,6 +943,57 @@ def archive_contents(look, members, fmt):
818
943
  look.line(f"Config member {n} ({len(t.splitlines())} lines): " + " | ".join(P.short(x, 80) for x in kv), 40, "config")
819
944
 
820
945
 
946
+ MEMBER_LOOK_EXT = {".xlsx", ".xlsm", ".xls", ".ods", ".docx", ".doc", ".odt", ".pptx", ".odp", ".pdf", ".sqlite", ".sqlite3", ".db", ".parquet", ".avro", ".orc", ".npz", ".npy", ".dta",
947
+ ".mid", ".ttf", ".otf", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".eml", ".ics", ".vcf", ".torrent", ".plist", ".pyc", ".class", ".wasm", ".exe", ".dll", ".deb", ".stl",
948
+ ".glb", ".log", ".mp3", ".wav", ".flac", ".ogg", ".opus", ".m4a", ".mp4", ".mkv", ".mov", ".avi", ".webm", ".epub", ".duckdb", ".pcap", ".evtx", ".ipynb"}
949
+ MEMBER_LOOK_N = 8 # members of a document or media type that get a look of their own, in place
950
+ MEMBER_LOOK_BYTES = 40 << 20
951
+ MEMBER_LOOK_TOKENS = 420 # facts carried up from one member's look
952
+
953
+
954
+ def member_looks(look, members, fmt):
955
+ """After the safety screen passed: the members that are documents, tables, media or binaries get their own look (written under the look folder,
956
+ never run), and their top facts are carried up here, because a list of names says nothing about what the file inside holds."""
957
+ if getattr(look, "depth", 0) >= 1 or look.quick or not hasattr(core, "recurse_into"):
958
+ return
959
+ read, close = member_reader(look, fmt)
960
+ if not read:
961
+ return
962
+ import dl_write
963
+ files = [m for m in members if m["kind"] == "file" and not m["enc"] and 0 < m["size"] <= MEMBER_LOOK_BYTES and os.path.splitext(m["name"])[1].lower() in MEMBER_LOOK_EXT]
964
+ t0, done = time.time(), 0
965
+ try:
966
+ for m in files[:MEMBER_LOOK_N]:
967
+ if time.time() - t0 > 150:
968
+ look.line(f"Member looks stopped after {done} members (time cap); the rest are named in the list.", 30)
969
+ break
970
+ try:
971
+ b = read(m["name"], MEMBER_LOOK_BYTES)
972
+ except Exception:
973
+ b = None
974
+ if b is None or len(b) > MEMBER_LOOK_BYTES:
975
+ continue
976
+ d = look.out / "members" / f"{done + 1:02d}"
977
+ d.mkdir(parents=True, exist_ok=True)
978
+ fp = d / re.sub(r"[^\w.\-]+", "_", os.path.basename(m["name"].replace("\\", "/")))
979
+ fp.write_bytes(b)
980
+ tier = "quick" if os.path.splitext(fp.name)[1].lower() in (".mp3", ".wav", ".flac", ".ogg", ".opus", ".m4a", ".mp4", ".mkv", ".mov", ".avi", ".webm") and len(b) > (8 << 20) else "standard"
981
+ c = core.recurse_into(look, fp, m["name"], tier)
982
+ done += 1
983
+ chunks = [x for x in (c.stream or []) if x["sec"] in ("watch", "key")]
984
+ chunks.sort(key=lambda x: -x["pri"])
985
+ used, picked = 0, []
986
+ for x in chunks:
987
+ if used + x["tok"] > MEMBER_LOOK_TOKENS and picked:
988
+ break
989
+ picked.append(x["text"])
990
+ used += x["tok"]
991
+ if picked:
992
+ look.line(f"Member {m['name']} ({c.type}/{c.subtype}), looked at in place: " + " | ".join(" ".join(t.split()) for t in picked), 66, "member")
993
+ finally:
994
+ close()
995
+
996
+
821
997
  def inner_tables(look, texts):
822
998
  """Each CSV/TSV/JSON/JSONL member: rows, columns and an exact field profile (sums, counts, extremes)."""
823
999
  import dl_prof as P
@@ -876,14 +1052,14 @@ def parse_7z_slt(text):
876
1052
  if in_files and cur.get("Path"):
877
1053
  attr = cur.get("Attributes", "")
878
1054
  kind = "dir" if cur.get("Folder") == "+" or attr.startswith("D") else "symlink" if cur.get("Symbolic Link") or attr.startswith("l") or attr.startswith("_l") else "file"
879
- members.append(dict(name=cur["Path"], size=int(cur.get("Size") or 0), packed=int(cur.get("Packed Size") or 0), kind=kind, enc=cur.get("Encrypted") == "+", offset=0))
1055
+ members.append(dict(name=cur["Path"], size=int(cur.get("Size") or 0), packed=int(cur.get("Packed Size") or 0), kind=kind, enc=cur.get("Encrypted") == "+", offset=None))
880
1056
  cur = {}
881
1057
  continue
882
1058
  if "=" in ln:
883
1059
  k, v = ln.split("=", 1)
884
1060
  (cur if in_files else extra).__setitem__(k.strip(), v.strip())
885
1061
  if in_files and cur.get("Path"):
886
- members.append(dict(name=cur["Path"], size=int(cur.get("Size") or 0), packed=int(cur.get("Packed Size") or 0), kind="file", enc=cur.get("Encrypted") == "+", offset=0))
1062
+ members.append(dict(name=cur["Path"], size=int(cur.get("Size") or 0), packed=int(cur.get("Packed Size") or 0), kind="file", enc=cur.get("Encrypted") == "+", offset=None))
887
1063
  extra = {"method": extra.get("Method", ""), "solid": extra.get("Solid", ""), "type": extra.get("Type", "")}
888
1064
  return members, extra
889
1065
 
@@ -1001,6 +1177,11 @@ def look_archive(look):
1001
1177
  archive_contents(look, members, extra.get("format"))
1002
1178
  except Exception as e: # the listing stands even when a member cannot be read
1003
1179
  look.fail("member contents (manifests, tables, tree stats)", "stdlib", e)
1180
+ if not refusals and extra.get("format") in ("zip", "tar", "7z", "iso", "rar"):
1181
+ try:
1182
+ member_looks(look, members, extra.get("format"))
1183
+ except Exception as e:
1184
+ look.fail("member looks (documents and media inside the archive)", "stdlib", e)
1004
1185
  rows =[(m["name"], m["size"], m["packed"], f"{m['size'] / max(1, m['packed']):.1f}" if m["packed"] else "", m["kind"], "encrypted" if m["enc"] else "") for m in members[:2000]]
1005
1186
  buf = io.StringIO()
1006
1187
  w = csv.writer(buf)
@@ -1227,9 +1408,15 @@ def look_font(look):
1227
1408
  facts["family"] = names.get(1, "")
1228
1409
  facts["subfamily"] = names.get(2, "")
1229
1410
  facts["full_name"] = names.get(4, "")
1411
+ if font is not None:
1412
+ try:
1413
+ font_details(look, font, facts)
1414
+ except Exception as e:
1415
+ look.fail("font metrics", "fontTools", e)
1230
1416
  for k, v in facts.items():
1231
1417
  look.fact(k, v)
1232
1418
  if facts.get("family") or facts.get("glyphs"):
1419
+ look.headline = f"Font {facts.get('full_name') or facts.get('family')!r} ({st}): {facts.get('glyphs', '?')} glyphs, {'variable' if facts.get('variable') else 'static'}; {len(facts.get('tables', []))} tables"
1233
1420
  look.line(f"Font {facts.get('full_name') or facts.get('family')!r} ({st}): {facts.get('glyphs', '?')} glyphs, {'variable' if facts.get('variable') else 'static'}; "
1234
1421
  f"{len(facts.get('tables', []))} tables.")
1235
1422
  else:
@@ -1254,6 +1441,53 @@ def look_font(look):
1254
1441
  look.ask_human("Which scripts or languages must this font cover?", "Is the licence in the file the one you expect?")
1255
1442
 
1256
1443
 
1444
+ def font_details(look, font, facts):
1445
+ """Name records, design metrics, style bits and layout features: the facts a type designer or a web developer asks first."""
1446
+ h = font["head"]
1447
+ labels = {0: "copyright", 1: "family", 2: "subfamily", 3: "unique id", 4: "full name", 5: "version string", 6: "PostScript name", 7: "trademark", 8: "manufacturer",
1448
+ 9: "designer", 11: "vendor URL", 12: "designer URL", 13: "licence", 14: "licence URL", 16: "typographic family", 17: "typographic subfamily"}
1449
+ got = {}
1450
+ for rec in font["name"].names:
1451
+ if rec.nameID in labels and rec.nameID not in got:
1452
+ try:
1453
+ got[rec.nameID] = rec.toUnicode()
1454
+ except Exception:
1455
+ pass
1456
+ look.line("Name records: " + "; ".join(f"{labels[i]} = {got[i][:90]}" for i in sorted(got)) + ".", 74)
1457
+ bits = []
1458
+ bits.append(f"{h.unitsPerEm} units per em, font revision {float(h.fontRevision):.3f}")
1459
+ bits.append(f"glyph box x {h.xMin}..{h.xMax}, y {h.yMin}..{h.yMax}")
1460
+ bits.append("macStyle: " + ", ".join(n for bit, n in ((1, "bold"), (2, "italic"), (4, "underline")) if h.macStyle & bit) if h.macStyle else "macStyle: regular")
1461
+ if "OS/2" in font:
1462
+ o = font["OS/2"]
1463
+ sel = [n for bit, n in ((1, "italic"), (32, "bold"), (64, "regular"), (512, "oblique")) if o.fsSelection & bit]
1464
+ bits.append(f"OS/2 weight class {o.usWeightClass}, width class {o.usWidthClass}, fsSelection {'+'.join(sel) or 'none'}, typo ascender {o.sTypoAscender}, typo descender {o.sTypoDescender}, "
1465
+ f"line gap {o.sTypoLineGap}, win ascent {o.usWinAscent}, win descent {o.usWinDescent}"
1466
+ + (f", x-height {o.sxHeight}, cap height {o.sCapHeight}" if getattr(o, "version", 0) >= 2 and getattr(o, "sxHeight", 0) else ""))
1467
+ if "hhea" in font:
1468
+ hh = font["hhea"]
1469
+ bits.append(f"hhea ascent {hh.ascent}, descent {hh.descent}, line gap {hh.lineGap}")
1470
+ if "post" in font:
1471
+ bits.append(f"italic angle {font['post'].italicAngle}, fixed pitch {'yes' if font['post'].isFixedPitch else 'no'}")
1472
+ try:
1473
+ import datetime
1474
+ epoch = datetime.datetime(1904, 1, 1)
1475
+ bits.append(f"head created {(epoch + datetime.timedelta(seconds=h.created)).date()}, modified {(epoch + datetime.timedelta(seconds=h.modified)).date()}")
1476
+ except Exception:
1477
+ pass
1478
+ look.line("Design metrics: " + "; ".join(bits) + ".", 73)
1479
+ feats = {}
1480
+ for tab in ("GSUB", "GPOS"):
1481
+ if tab in font and getattr(font[tab].table, "FeatureList", None):
1482
+ feats[tab] = sorted({r.FeatureTag for r in font[tab].table.FeatureList.FeatureRecord})
1483
+ scripts = sorted({r.ScriptTag for tab in ("GSUB", "GPOS") if tab in font and getattr(font[tab].table, "ScriptList", None) for r in font[tab].table.ScriptList.ScriptRecord})
1484
+ if feats or scripts:
1485
+ look.line("Layout features: " + "; ".join(f"{t} {', '.join(v)}" for t, v in feats.items()) + (f"; scripts {', '.join(scripts)}" if scripts else "") + ".", 66)
1486
+ look.line(f"cmap subtables: {len(font['cmap'].tables)}; kern table {'present' if 'kern' in font else 'absent'}; hinting {'TrueType instructions (fpgm/prep)' if 'fpgm' in font or 'prep' in font else 'none or unhinted'}; "
1487
+ f"glyph outlines {'CFF (OpenType)' if 'CFF ' in font or 'CFF2' in font else 'TrueType quadratic'}.", 60)
1488
+ facts["units_per_em"] = h.unitsPerEm
1489
+
1490
+
1257
1491
  def specimen(look, font, facts):
1258
1492
  from PIL import Image, ImageDraw, ImageFont
1259
1493
  buf = io.BytesIO()
@@ -43,7 +43,8 @@ def run_diec(look):
43
43
  p = parse_diec(out)
44
44
  packers = [e for e in p["entries"] if e[0].lower() in ("packer", "protector", "cryptor")]
45
45
  keep = [(c, n, v) for c, n, v in p["entries"] if c.lower() in ("linker", "compiler", "tool", "packer", "protector", "installer", "library")][:4]
46
- look.line("Detect It Easy: " + "; ".join(f"{c.lower()} {n}" + (f" {v}" if v else "") for c, n, v in keep) + ".", 68)
46
+ if keep:
47
+ look.line("Detect It Easy: " + "; ".join(f"{c.lower()} {n}" + (f" {v}" if v else "") for c, n, v in keep) + ".", 68)
47
48
  if packers:
48
49
  look.flag("PACKED?", "Detect It Easy", ", ".join(f"{n} {v}".strip() for _, n, v in packers), "a packer or protector is identified: static strings and imports describe the stub, not the program")
49
50
  look.step("packer and compiler identification", "diec")