oscura 0.3.0__py3-none-any.whl → 0.5.0__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.
Files changed (59) hide show
  1. oscura/__init__.py +1 -7
  2. oscura/acquisition/__init__.py +147 -0
  3. oscura/acquisition/file.py +255 -0
  4. oscura/acquisition/hardware.py +186 -0
  5. oscura/acquisition/saleae.py +340 -0
  6. oscura/acquisition/socketcan.py +315 -0
  7. oscura/acquisition/streaming.py +38 -0
  8. oscura/acquisition/synthetic.py +229 -0
  9. oscura/acquisition/visa.py +376 -0
  10. oscura/analyzers/__init__.py +3 -0
  11. oscura/analyzers/digital/__init__.py +48 -0
  12. oscura/analyzers/digital/clock.py +9 -1
  13. oscura/analyzers/digital/edges.py +1 -1
  14. oscura/analyzers/digital/extraction.py +195 -0
  15. oscura/analyzers/digital/ic_database.py +498 -0
  16. oscura/analyzers/digital/timing.py +41 -11
  17. oscura/analyzers/digital/timing_paths.py +339 -0
  18. oscura/analyzers/digital/vintage.py +377 -0
  19. oscura/analyzers/digital/vintage_result.py +148 -0
  20. oscura/analyzers/protocols/__init__.py +22 -1
  21. oscura/analyzers/protocols/parallel_bus.py +449 -0
  22. oscura/analyzers/side_channel/__init__.py +52 -0
  23. oscura/analyzers/side_channel/power.py +690 -0
  24. oscura/analyzers/side_channel/timing.py +369 -0
  25. oscura/analyzers/signal_integrity/sparams.py +1 -1
  26. oscura/automotive/__init__.py +4 -2
  27. oscura/automotive/can/patterns.py +3 -1
  28. oscura/automotive/can/session.py +277 -78
  29. oscura/automotive/can/state_machine.py +5 -2
  30. oscura/builders/__init__.py +9 -11
  31. oscura/builders/signal_builder.py +99 -191
  32. oscura/core/exceptions.py +5 -1
  33. oscura/export/__init__.py +12 -0
  34. oscura/export/wavedrom.py +430 -0
  35. oscura/exporters/json_export.py +47 -0
  36. oscura/exporters/vintage_logic_csv.py +247 -0
  37. oscura/loaders/__init__.py +1 -0
  38. oscura/loaders/chipwhisperer.py +393 -0
  39. oscura/loaders/touchstone.py +1 -1
  40. oscura/reporting/__init__.py +7 -0
  41. oscura/reporting/vintage_logic_report.py +523 -0
  42. oscura/session/session.py +54 -46
  43. oscura/sessions/__init__.py +70 -0
  44. oscura/sessions/base.py +323 -0
  45. oscura/sessions/blackbox.py +640 -0
  46. oscura/sessions/generic.py +189 -0
  47. oscura/utils/autodetect.py +5 -1
  48. oscura/visualization/digital_advanced.py +718 -0
  49. oscura/visualization/figure_manager.py +156 -0
  50. {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/METADATA +86 -5
  51. {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/RECORD +54 -33
  52. oscura/automotive/dtc/data.json +0 -2763
  53. oscura/schemas/bus_configuration.json +0 -322
  54. oscura/schemas/device_mapping.json +0 -182
  55. oscura/schemas/packet_format.json +0 -418
  56. oscura/schemas/protocol_definition.json +0 -363
  57. {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/WHEEL +0 -0
  58. {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/entry_points.txt +0 -0
  59. {oscura-0.3.0.dist-info → oscura-0.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,523 @@
1
+ """Vintage logic analysis report generation.
2
+
3
+ This module provides comprehensive reporting capabilities for vintage logic
4
+ analysis results, including HTML, PDF, and Markdown formats.
5
+
6
+ Example:
7
+ >>> from oscura.reporting.vintage_logic_report import generate_vintage_logic_report
8
+ >>> report = generate_vintage_logic_report(result, traces, output_dir="./output")
9
+ >>> report.save_html("analysis.html")
10
+ >>> report.save_markdown("analysis.md")
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ if TYPE_CHECKING:
21
+ from oscura.analyzers.digital.vintage_result import VintageLogicAnalysisResult
22
+ from oscura.core.types import DigitalTrace, WaveformTrace
23
+
24
+
25
+ @dataclass
26
+ class ReportMetadata:
27
+ """Report metadata.
28
+
29
+ Attributes:
30
+ title: Report title.
31
+ author: Report author.
32
+ timestamp: Report generation timestamp.
33
+ version: Report format version.
34
+ """
35
+
36
+ title: str
37
+ author: str = "Oscura Vintage Logic Analyzer"
38
+ timestamp: datetime = field(default_factory=datetime.now)
39
+ version: str = "1.0"
40
+
41
+
42
+ @dataclass
43
+ class VintageLogicReport:
44
+ """Vintage logic analysis report.
45
+
46
+ Attributes:
47
+ result: Analysis result object.
48
+ plots: Dictionary mapping plot names to file paths.
49
+ metadata: Report metadata.
50
+ """
51
+
52
+ result: VintageLogicAnalysisResult
53
+ plots: dict[str, Path]
54
+ metadata: ReportMetadata
55
+
56
+ def save_html(self, path: str | Path, **options: Any) -> Path:
57
+ """Generate comprehensive HTML report.
58
+
59
+ Args:
60
+ path: Output HTML file path.
61
+ **options: Additional options (currently unused, reserved for future).
62
+
63
+ Returns:
64
+ Path to saved HTML file.
65
+
66
+ Example:
67
+ >>> report.save_html("analysis.html")
68
+ """
69
+
70
+ path = Path(path)
71
+
72
+ # Generate HTML content
73
+ html = _generate_html_report(self)
74
+
75
+ # Write to file
76
+ path.write_text(html, encoding="utf-8")
77
+
78
+ return path
79
+
80
+ def save_markdown(self, path: str | Path) -> Path:
81
+ """Generate markdown summary.
82
+
83
+ Args:
84
+ path: Output markdown file path.
85
+
86
+ Returns:
87
+ Path to saved markdown file.
88
+
89
+ Example:
90
+ >>> report.save_markdown("analysis.md")
91
+ """
92
+ path = Path(path)
93
+
94
+ # Generate markdown content
95
+ md = _generate_markdown_report(self)
96
+
97
+ # Write to file
98
+ path.write_text(md, encoding="utf-8")
99
+
100
+ return path
101
+
102
+ def save_pdf(self, path: str | Path, **options: Any) -> Path:
103
+ """Generate PDF report.
104
+
105
+ Currently generates HTML first, then suggests using browser print
106
+ or external tools for PDF conversion.
107
+
108
+ Args:
109
+ path: Output PDF file path.
110
+ **options: Additional options.
111
+
112
+ Returns:
113
+ Path to saved PDF file (or HTML file with instructions).
114
+
115
+ Example:
116
+ >>> report.save_pdf("analysis.pdf")
117
+ """
118
+ path = Path(path)
119
+
120
+ # For now, save as HTML with instructions
121
+ html_path = path.with_suffix(".html")
122
+ self.save_html(html_path)
123
+
124
+ # Add note about PDF conversion
125
+ print(f"HTML report generated: {html_path}")
126
+ print("To convert to PDF:")
127
+ print(" 1. Open in browser and use Print > Save as PDF")
128
+ print(" 2. Use weasyprint: weasyprint {html_path} {path}")
129
+ print(" 3. Use wkhtmltopdf: wkhtmltopdf {html_path} {path}")
130
+
131
+ return html_path
132
+
133
+
134
+ def generate_vintage_logic_report(
135
+ result: VintageLogicAnalysisResult,
136
+ traces: dict[str, WaveformTrace | DigitalTrace],
137
+ *,
138
+ title: str | None = None,
139
+ include_plots: list[str] | None = None,
140
+ output_dir: Path | None = None,
141
+ ) -> VintageLogicReport:
142
+ """Generate complete vintage logic report.
143
+
144
+ Args:
145
+ result: Analysis result from analyze_vintage_logic().
146
+ traces: Original signal traces.
147
+ title: Report title. Defaults to auto-generated title.
148
+ include_plots: List of plot types to include (None = all).
149
+ output_dir: Directory for saving plot files. If None, uses temp directory.
150
+
151
+ Returns:
152
+ VintageLogicReport object ready for export.
153
+
154
+ Example:
155
+ >>> from oscura.analyzers.digital.vintage import analyze_vintage_logic
156
+ >>> result = analyze_vintage_logic(traces)
157
+ >>> report = generate_vintage_logic_report(result, traces, output_dir="./output")
158
+ >>> report.save_html("report.html")
159
+ """
160
+ from pathlib import Path
161
+ from tempfile import mkdtemp
162
+
163
+ from oscura.visualization.digital_advanced import generate_all_vintage_logic_plots
164
+
165
+ # Use temp directory if no output directory specified
166
+ if output_dir is None:
167
+ output_dir = Path(mkdtemp(prefix="oscura_vintage_"))
168
+ else:
169
+ output_dir = Path(output_dir)
170
+ output_dir.mkdir(parents=True, exist_ok=True)
171
+
172
+ # Generate default title if not provided
173
+ if title is None:
174
+ title = f"Vintage Logic Analysis - {result.detected_family}"
175
+
176
+ # Create metadata
177
+ metadata = ReportMetadata(title=title)
178
+
179
+ # Generate all plots
180
+ plot_objects = generate_all_vintage_logic_plots(
181
+ result,
182
+ traces,
183
+ output_dir=output_dir,
184
+ save_formats=["png"],
185
+ )
186
+
187
+ # Get plot paths from output directory
188
+ plot_paths: dict[str, Path] = {}
189
+ for plot_name in plot_objects:
190
+ plot_path = output_dir / f"{plot_name}.png"
191
+ if plot_path.exists():
192
+ plot_paths[plot_name] = plot_path
193
+
194
+ # Filter plots if specified
195
+ if include_plots:
196
+ plot_paths = {k: v for k, v in plot_paths.items() if k in include_plots}
197
+
198
+ return VintageLogicReport(
199
+ result=result,
200
+ plots=plot_paths,
201
+ metadata=metadata,
202
+ )
203
+
204
+
205
+ def _generate_html_report(report: VintageLogicReport) -> str:
206
+ """Generate HTML report content."""
207
+ result = report.result
208
+
209
+ # Embed images as base64
210
+ from base64 import b64encode
211
+
212
+ plot_html = []
213
+ for plot_name, plot_path in report.plots.items():
214
+ if plot_path.exists():
215
+ with plot_path.open("rb") as f:
216
+ img_data = b64encode(f.read()).decode("utf-8")
217
+ plot_html.append(
218
+ f'<div class="plot"><h3>{plot_name.replace("_", " ").title()}</h3>'
219
+ f'<img src="data:image/png;base64,{img_data}" alt="{plot_name}" /></div>'
220
+ )
221
+
222
+ plots_section = "\n".join(plot_html)
223
+
224
+ # Generate IC table
225
+ ic_rows = []
226
+ for ic in result.identified_ics:
227
+ validation_status = (
228
+ "PASS" if all(v.get("passes", True) for v in ic.validation.values()) else "FAIL"
229
+ )
230
+ status_class = "pass" if validation_status == "PASS" else "fail"
231
+
232
+ ic_rows.append(
233
+ f"<tr>"
234
+ f"<td>{ic.ic_name}</td>"
235
+ f"<td>{ic.confidence * 100:.1f}%</td>"
236
+ f"<td>{ic.family}</td>"
237
+ f'<td class="{status_class}">{validation_status}</td>'
238
+ f"</tr>"
239
+ )
240
+
241
+ ic_table = (
242
+ "<table><tr><th>IC</th><th>Confidence</th><th>Family</th><th>Validation</th></tr>\n"
243
+ + "\n".join(ic_rows)
244
+ + "</table>"
245
+ if ic_rows
246
+ else "<p>No ICs identified</p>"
247
+ )
248
+
249
+ # Generate BOM table
250
+ bom_rows = []
251
+ for entry in result.bom:
252
+ bom_rows.append(
253
+ f"<tr>"
254
+ f"<td>{entry.part_number}</td>"
255
+ f"<td>{entry.description}</td>"
256
+ f"<td>{entry.quantity}</td>"
257
+ f"<td>{entry.category}</td>"
258
+ f"<td>{entry.notes or ''}</td>"
259
+ f"</tr>"
260
+ )
261
+
262
+ bom_table = (
263
+ "<table><tr><th>Part Number</th><th>Description</th><th>Qty</th><th>Category</th><th>Notes</th></tr>\n"
264
+ + "\n".join(bom_rows)
265
+ + "</table>"
266
+ if bom_rows
267
+ else "<p>No BOM entries</p>"
268
+ )
269
+
270
+ # Generate timing measurements table
271
+ timing_rows = []
272
+ for param, value in result.timing_measurements.items():
273
+ timing_rows.append(f"<tr><td>{param}</td><td>{value * 1e9:.2f} ns</td></tr>")
274
+
275
+ timing_table = (
276
+ "<table><tr><th>Parameter</th><th>Value</th></tr>\n" + "\n".join(timing_rows) + "</table>"
277
+ if timing_rows
278
+ else "<p>No timing measurements</p>"
279
+ )
280
+
281
+ # Generate warnings
282
+ warnings_html = ""
283
+ if result.warnings:
284
+ warnings_list = "\n".join(f"<li>{w}</li>" for w in result.warnings)
285
+ warnings_html = f'<div class="warnings"><h2>Warnings</h2><ul>{warnings_list}</ul></div>'
286
+
287
+ html = f"""<!DOCTYPE html>
288
+ <html lang="en">
289
+ <head>
290
+ <meta charset="UTF-8">
291
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
292
+ <title>{report.metadata.title}</title>
293
+ <style>
294
+ body {{
295
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
296
+ line-height: 1.6;
297
+ color: #333;
298
+ max-width: 1200px;
299
+ margin: 0 auto;
300
+ padding: 20px;
301
+ background: #f5f5f5;
302
+ }}
303
+ .container {{
304
+ background: white;
305
+ padding: 30px;
306
+ border-radius: 8px;
307
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
308
+ }}
309
+ h1 {{
310
+ color: #2c3e50;
311
+ border-bottom: 3px solid #3498db;
312
+ padding-bottom: 10px;
313
+ }}
314
+ h2 {{
315
+ color: #34495e;
316
+ margin-top: 30px;
317
+ border-bottom: 2px solid #ecf0f1;
318
+ padding-bottom: 5px;
319
+ }}
320
+ h3 {{
321
+ color: #7f8c8d;
322
+ }}
323
+ table {{
324
+ width: 100%;
325
+ border-collapse: collapse;
326
+ margin: 20px 0;
327
+ }}
328
+ th, td {{
329
+ padding: 12px;
330
+ text-align: left;
331
+ border-bottom: 1px solid #ddd;
332
+ }}
333
+ th {{
334
+ background-color: #3498db;
335
+ color: white;
336
+ font-weight: bold;
337
+ }}
338
+ tr:hover {{
339
+ background-color: #f5f5f5;
340
+ }}
341
+ .plot {{
342
+ margin: 30px 0;
343
+ text-align: center;
344
+ }}
345
+ .plot img {{
346
+ max-width: 100%;
347
+ height: auto;
348
+ border: 1px solid #ddd;
349
+ border-radius: 4px;
350
+ padding: 5px;
351
+ }}
352
+ .summary-box {{
353
+ background: #ecf0f1;
354
+ padding: 20px;
355
+ border-radius: 4px;
356
+ margin: 20px 0;
357
+ }}
358
+ .summary-box strong {{
359
+ color: #2c3e50;
360
+ }}
361
+ .pass {{
362
+ color: #27ae60;
363
+ font-weight: bold;
364
+ }}
365
+ .fail {{
366
+ color: #e74c3c;
367
+ font-weight: bold;
368
+ }}
369
+ .warnings {{
370
+ background: #fff3cd;
371
+ border-left: 4px solid #ffc107;
372
+ padding: 15px;
373
+ margin: 20px 0;
374
+ }}
375
+ .warnings h2 {{
376
+ color: #856404;
377
+ margin-top: 0;
378
+ }}
379
+ .warnings ul {{
380
+ margin: 10px 0;
381
+ }}
382
+ .warnings li {{
383
+ color: #856404;
384
+ }}
385
+ .metadata {{
386
+ font-size: 0.9em;
387
+ color: #7f8c8d;
388
+ margin-bottom: 20px;
389
+ }}
390
+ </style>
391
+ </head>
392
+ <body>
393
+ <div class="container">
394
+ <h1>{report.metadata.title}</h1>
395
+
396
+ <div class="metadata">
397
+ <p>Generated: {report.metadata.timestamp.strftime("%Y-%m-%d %H:%M:%S")}</p>
398
+ <p>Analysis Date: {result.timestamp.strftime("%Y-%m-%d %H:%M:%S")}</p>
399
+ <p>Analysis Duration: {result.analysis_duration:.2f} seconds</p>
400
+ </div>
401
+
402
+ <div class="summary-box">
403
+ <h2>Summary</h2>
404
+ <p><strong>Detected Logic Family:</strong> {result.detected_family} ({result.family_confidence * 100:.1f}% confidence)</p>
405
+ <p><strong>ICs Identified:</strong> {len(result.identified_ics)}</p>
406
+ <p><strong>Timing Measurements:</strong> {len(result.timing_measurements)}</p>
407
+ <p><strong>Open-Collector Detected:</strong> {"Yes" if result.open_collector_detected else "No"}</p>
408
+ {f"<p><strong>Asymmetry Ratio:</strong> {result.asymmetry_ratio:.2f}</p>" if result.open_collector_detected else ""}
409
+ </div>
410
+
411
+ {warnings_html}
412
+
413
+ <h2>IC Identification</h2>
414
+ {ic_table}
415
+
416
+ <h2>Timing Measurements</h2>
417
+ {timing_table}
418
+
419
+ <h2>Bill of Materials</h2>
420
+ {bom_table}
421
+
422
+ <h2>Visualizations</h2>
423
+ {plots_section}
424
+ </div>
425
+ </body>
426
+ </html>"""
427
+
428
+ return html
429
+
430
+
431
+ def _generate_markdown_report(report: VintageLogicReport) -> str:
432
+ """Generate markdown report content."""
433
+ result = report.result
434
+
435
+ # Generate IC table
436
+ ic_rows = []
437
+ for ic in result.identified_ics:
438
+ validation_status = (
439
+ "PASS" if all(v.get("passes", True) for v in ic.validation.values()) else "FAIL"
440
+ )
441
+ ic_rows.append(
442
+ f"| {ic.ic_name} | {ic.confidence * 100:.1f}% | {ic.family} | {validation_status} |"
443
+ )
444
+
445
+ ic_table = (
446
+ "| IC | Confidence | Family | Validation |\n|---|---|---|---|\n" + "\n".join(ic_rows)
447
+ if ic_rows
448
+ else "*No ICs identified*"
449
+ )
450
+
451
+ # Generate BOM table
452
+ bom_rows = []
453
+ for entry in result.bom:
454
+ bom_rows.append(
455
+ f"| {entry.part_number} | {entry.description} | {entry.quantity} | {entry.category} | {entry.notes or ''} |"
456
+ )
457
+
458
+ bom_table = (
459
+ "| Part Number | Description | Qty | Category | Notes |\n|---|---|---|---|---|\n"
460
+ + "\n".join(bom_rows)
461
+ if bom_rows
462
+ else "*No BOM entries*"
463
+ )
464
+
465
+ # Generate warnings
466
+ warnings_md = ""
467
+ if result.warnings:
468
+ warnings_list = "\n".join(f"- {w}" for w in result.warnings)
469
+ warnings_md = f"\n## Warnings\n\n{warnings_list}\n"
470
+
471
+ md = f"""# {report.metadata.title}
472
+
473
+ **Generated:** {report.metadata.timestamp.strftime("%Y-%m-%d %H:%M:%S")}
474
+ **Analysis Date:** {result.timestamp.strftime("%Y-%m-%d %H:%M:%S")}
475
+ **Analysis Duration:** {result.analysis_duration:.2f} seconds
476
+
477
+ ## Summary
478
+
479
+ - **Detected Logic Family:** {result.detected_family} ({result.family_confidence * 100:.1f}% confidence)
480
+ - **ICs Identified:** {len(result.identified_ics)}
481
+ - **Timing Measurements:** {len(result.timing_measurements)}
482
+ - **Open-Collector Detected:** {"Yes" if result.open_collector_detected else "No"}
483
+ {"- **Asymmetry Ratio:** " + f"{result.asymmetry_ratio:.2f}" if result.open_collector_detected else ""}
484
+
485
+ {warnings_md}
486
+
487
+ ## IC Identification
488
+
489
+ {ic_table}
490
+
491
+ ## Timing Measurements
492
+
493
+ | Parameter | Value |
494
+ |---|---|
495
+ """
496
+
497
+ for param, value in result.timing_measurements.items():
498
+ md += f"| {param} | {value * 1e9:.2f} ns |\n"
499
+
500
+ if not result.timing_measurements:
501
+ md += "*No timing measurements*\n"
502
+
503
+ md += f"""
504
+ ## Bill of Materials
505
+
506
+ {bom_table}
507
+
508
+ ## Visualizations
509
+
510
+ """
511
+
512
+ for plot_name, plot_path in report.plots.items():
513
+ md += f"### {plot_name.replace('_', ' ').title()}\n\n"
514
+ md += f"![{plot_name}]({plot_path})\n\n"
515
+
516
+ return md
517
+
518
+
519
+ __all__ = [
520
+ "ReportMetadata",
521
+ "VintageLogicReport",
522
+ "generate_vintage_logic_report",
523
+ ]
oscura/session/session.py CHANGED
@@ -18,7 +18,6 @@ import gzip
18
18
  import hashlib
19
19
  import hmac
20
20
  import pickle
21
- import warnings
22
21
  from dataclasses import dataclass, field
23
22
  from datetime import datetime
24
23
  from pathlib import Path
@@ -421,19 +420,21 @@ class Session:
421
420
  return "\n".join(lines)
422
421
 
423
422
 
424
- def load_session(path: str | Path, *, verify_signature: bool = True) -> Session:
425
- """Load session from file with optional signature verification.
423
+ def load_session(path: str | Path) -> Session:
424
+ """Load session from file with HMAC signature verification.
425
+
426
+ Session files must be in the current OSC1 format with HMAC signature.
427
+ Legacy session files without signatures are not supported.
426
428
 
427
429
  Args:
428
430
  path: Path to session file (.tks).
429
- verify_signature: Verify HMAC signature (default: True). Set to False
430
- only when loading legacy session files without signatures.
431
431
 
432
432
  Returns:
433
433
  Loaded Session object.
434
434
 
435
435
  Raises:
436
- SecurityError: If signature verification fails.
436
+ SecurityError: If signature verification fails or file is not in OSC1 format.
437
+ gzip.BadGzipFile: If file is neither valid gzip nor uncompressed session.
437
438
 
438
439
  Example:
439
440
  >>> session = load_session('debug_session.tks')
@@ -444,61 +445,68 @@ def load_session(path: str | Path, *, verify_signature: bool = True) -> Session:
444
445
  trusted sources. Loading a malicious .tks file could execute arbitrary
445
446
  code. Never load session files from untrusted or unknown sources.
446
447
 
447
- New session files include HMAC signatures for integrity verification.
448
- Legacy files without signatures will trigger a warning.
449
-
450
- For secure data exchange, consider exporting to JSON or HDF5 formats
451
- instead of using pickle-based session files.
448
+ All session files must include HMAC signatures for integrity verification.
449
+ For secure data exchange with untrusted parties, consider exporting to
450
+ JSON or HDF5 formats instead of using pickle-based session files.
452
451
  """
453
452
  path = Path(path)
454
453
 
455
- # Helper to load with signature verification
456
- def _load_with_verification(f: Any, is_compressed: bool = False) -> dict[str, Any]:
457
- # Read magic bytes to detect format
454
+ def _load_with_verification(f: Any) -> dict[str, Any]:
455
+ """Load and verify session file with HMAC signature.
456
+
457
+ Args:
458
+ f: File object (gzip or regular).
459
+
460
+ Returns:
461
+ Deserialized session dictionary.
462
+
463
+ Raises:
464
+ SecurityError: If magic bytes or signature verification fails.
465
+ """
466
+ # Read magic bytes
458
467
  magic = f.read(len(_SESSION_MAGIC))
459
468
 
460
- if magic == _SESSION_MAGIC:
461
- # New format with signature
462
- signature = f.read(_SESSION_SIGNATURE_SIZE)
463
- serialized = f.read()
464
-
465
- if verify_signature:
466
- # Verify HMAC signature
467
- expected = hmac.new(_SECURITY_KEY, serialized, hashlib.sha256).digest()
468
- if not hmac.compare_digest(signature, expected):
469
- raise SecurityError(
470
- "Session file signature verification failed",
471
- file_path=str(path),
472
- check_type="HMAC signature",
473
- details="File may be corrupted or tampered with",
474
- )
469
+ if magic != _SESSION_MAGIC:
470
+ raise SecurityError(
471
+ "This is a legacy session file. Please re-save with current version.",
472
+ file_path=str(path),
473
+ check_type="Session format",
474
+ details="Expected OSC1 format with HMAC signature",
475
+ )
475
476
 
476
- # Deserialize verified data
477
- data = cast("dict[str, Any]", pickle.loads(serialized))
478
- else:
479
- # Legacy format without signature
480
- warnings.warn(
481
- f"Loading legacy session file without signature verification: {path}. "
482
- "Re-save the session to enable security features.",
483
- UserWarning,
484
- stacklevel=3,
477
+ # Read signature and payload
478
+ signature = f.read(_SESSION_SIGNATURE_SIZE)
479
+ serialized = f.read()
480
+
481
+ if not signature or not serialized:
482
+ raise SecurityError(
483
+ "This is a legacy session file. Please re-save with current version.",
484
+ file_path=str(path),
485
+ check_type="Session format",
486
+ details="File is incomplete or corrupted",
485
487
  )
486
488
 
487
- # Rewind and load as legacy pickle
488
- f.seek(0)
489
- data = cast("dict[str, Any]", pickle.load(f))
489
+ # Verify HMAC signature
490
+ expected = hmac.new(_SECURITY_KEY, serialized, hashlib.sha256).digest()
491
+ if not hmac.compare_digest(signature, expected):
492
+ raise SecurityError(
493
+ "Session file signature verification failed",
494
+ file_path=str(path),
495
+ check_type="HMAC signature",
496
+ details="File may be corrupted or tampered with",
497
+ )
490
498
 
499
+ # Deserialize verified data
500
+ data = cast("dict[str, Any]", pickle.loads(serialized))
491
501
  return data
492
502
 
493
- # Try loading (compressed or uncompressed)
503
+ # Try loading (compressed first, then uncompressed)
494
504
  try:
495
- # Try gzip compressed first
496
505
  with gzip.open(path, "rb") as f:
497
- data = _load_with_verification(f, is_compressed=True)
506
+ data = _load_with_verification(f)
498
507
  except gzip.BadGzipFile:
499
- # Fall back to uncompressed
500
508
  with open(path, "rb") as f: # type: ignore[assignment]
501
- data = _load_with_verification(f, is_compressed=False)
509
+ data = _load_with_verification(f)
502
510
 
503
511
  session = Session._from_dict(data)
504
512
  session._file_path = path