iotsploit-core 0.0.6__tar.gz → 0.0.7__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 (41) hide show
  1. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/PKG-INFO +3 -2
  2. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/pyproject.toml +1 -1
  3. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/execution_backend.py +9 -1
  4. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/tool_manager.py +132 -108
  5. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/tool_service.py +264 -96
  6. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/README.md +0 -0
  7. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/__init__.py +0 -0
  8. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/context.py +0 -0
  9. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/__init__.py +0 -0
  10. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/base_plugin.py +0 -0
  11. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/device_config.py +0 -0
  12. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/device_manager.py +0 -0
  13. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/device_registry.py +0 -0
  14. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/device_scanner.py +0 -0
  15. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/device_spec.py +0 -0
  16. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/device_store.py +0 -0
  17. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/execution_queue.py +0 -0
  18. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/exploit_manager.py +0 -0
  19. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/exploit_spec.py +0 -0
  20. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/stream_manager.py +0 -0
  21. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/core/tool_config.py +0 -0
  22. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/domain/__init__.py +0 -0
  23. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/domain/device.py +0 -0
  24. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/domain/execution_plan.py +0 -0
  25. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/domain/plugin.py +0 -0
  26. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/domain/stream.py +0 -0
  27. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/domain/target.py +0 -0
  28. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/platforms/__init__.py +0 -0
  29. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/platforms/consts.py +0 -0
  30. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/ports/__init__.py +0 -0
  31. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/ports/driver_state_repo.py +0 -0
  32. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/ports/plugin_repo.py +0 -0
  33. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/ports/stream_backend.py +0 -0
  34. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/ports/task_runner.py +0 -0
  35. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/ports/wifi_backend.py +0 -0
  36. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/py.typed +0 -0
  37. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/utils/__init__.py +0 -0
  38. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/utils/exceptions.py +0 -0
  39. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/utils/helpers.py +0 -0
  40. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/utils/iots_logger.py +0 -0
  41. {iotsploit_core-0.0.6 → iotsploit_core-0.0.7}/src/iotsploit_core/utils/result.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: iotsploit-core
3
- Version: 0.0.6
3
+ Version: 0.0.7
4
4
  Summary: IoTSploit core library (domain + ports + core) - IoT security testing framework
5
5
  License: GPL-3.0-or-later
6
6
  Keywords: iot,security,testing,pentest,vulnerability
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.10
16
16
  Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
19
20
  Classifier: Topic :: Security
20
21
  Classifier: Topic :: System :: Hardware
21
22
  Requires-Dist: dataclasses-json (>=0.6.3,<0.7.0)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "iotsploit-core"
3
- version = "0.0.6"
3
+ version = "0.0.7"
4
4
  description = "IoTSploit core library (domain + ports + core) - IoT security testing framework"
5
5
  authors = ["IoTSploit Team <support@iotsploit.org>"]
6
6
  readme = "README.md"
@@ -439,7 +439,15 @@ class ExecutionBackendManager:
439
439
  def list_available_backends(self) -> List[str]:
440
440
  """List all available backends"""
441
441
  return list(self.backends.keys())
442
-
442
+
443
+ def get_status(self) -> Dict[str, Any]:
444
+ """Get status of all execution backends"""
445
+ return {
446
+ 'default_backend': self.default_backend,
447
+ 'available_backends': self.list_available_backends(),
448
+ 'total_backends': len(self.backends),
449
+ }
450
+
443
451
  def set_default_backend(self, name: str):
444
452
  """Set default execution backend"""
445
453
  if name not in self.backends:
@@ -11,7 +11,7 @@ import logging
11
11
  from abc import ABC, abstractmethod
12
12
  from pathlib import Path
13
13
  from typing import Dict, List, Optional, Union, Any, Tuple
14
- from dataclasses import dataclass, field
14
+ from dataclasses import dataclass, field, asdict
15
15
  from enum import Enum
16
16
  import threading
17
17
  import time
@@ -24,6 +24,20 @@ from .execution_queue import get_execution_queue, TaskPriority
24
24
 
25
25
  logger = logging.getLogger(__name__)
26
26
 
27
+ # Detection results older than this (seconds) are re-probed; fresh ones are served
28
+ # from the runtime cache instead of re-running every tool's `--version`.
29
+ TOOL_CACHE_TTL_SECONDS = 3600
30
+
31
+ # Curated, version-controlled fields. These live in conf/tools.json and are never
32
+ # rewritten by runtime detection.
33
+ STATIC_TOOL_FIELDS = (
34
+ "name", "aliases", "dependencies", "platforms",
35
+ "min_version", "max_version", "metadata",
36
+ )
37
+ # Machine-specific detection results. These live in the gitignored runtime cache
38
+ # (.tools_cache.json) because they differ per host and change on every probe.
39
+ RUNTIME_TOOL_FIELDS = ("path", "version", "status", "last_checked")
40
+
27
41
  class ToolStatus(Enum):
28
42
  """Tool availability status"""
29
43
  AVAILABLE = "available"
@@ -77,6 +91,8 @@ class ToolRegistry:
77
91
  def __init__(self, config_path: Optional[str] = None):
78
92
  self.tools: Dict[str, ToolInfo] = {}
79
93
  self.config_path = config_path or self._get_default_config_path()
94
+ # Runtime detection cache lives next to the config but is gitignored.
95
+ self.cache_path = str(Path(self.config_path).parent / ".tools_cache.json")
80
96
  self._lock = threading.RLock()
81
97
  self._load_config()
82
98
 
@@ -93,25 +109,30 @@ class ToolRegistry:
93
109
  return str(config_dir / "tools.json")
94
110
 
95
111
  def _load_config(self):
96
- """Load tool configuration from file"""
112
+ """Load curated tool definitions from the version-controlled config file."""
97
113
  if os.path.exists(self.config_path):
98
114
  try:
99
115
  with open(self.config_path, 'r') as f:
100
116
  config = json.load(f)
101
- for tool_name, tool_data in config.get('tools', {}).items():
102
- # Convert status string back to enum if needed
103
- if 'status' in tool_data and isinstance(tool_data['status'], str):
104
- try:
105
- tool_data['status'] = ToolStatus(tool_data['status'])
106
- except ValueError:
107
- # If status string is invalid, default to MISSING
108
- tool_data['status'] = ToolStatus.MISSING
109
- self.tools[tool_name] = ToolInfo(**tool_data)
110
- logger.info(f"Loaded {len(self.tools)} tools from config")
117
+ known_fields = set(STATIC_TOOL_FIELDS) | set(RUNTIME_TOOL_FIELDS)
118
+ for tool_name, tool_data in config.get('tools', {}).items():
119
+ # Tolerate legacy files that still carry runtime fields, and drop
120
+ # any unknown keys so the ToolInfo constructor never fails.
121
+ data = {k: v for k, v in tool_data.items() if k in known_fields}
122
+ data.setdefault('name', tool_name)
123
+ if isinstance(data.get('status'), str):
124
+ try:
125
+ data['status'] = ToolStatus(data['status'])
126
+ except ValueError:
127
+ data['status'] = ToolStatus.MISSING
128
+ self.tools[tool_name] = ToolInfo(**data)
129
+ logger.info(f"Loaded {len(self.tools)} tool definitions from config")
111
130
  except Exception as e:
112
131
  logger.error(f"Failed to load tool config: {e}")
113
132
  else:
114
133
  self._create_default_config()
134
+ # Overlay machine-specific detection results from the runtime cache.
135
+ self._load_cache()
115
136
 
116
137
  def _create_default_config(self):
117
138
  """Create default tool configuration"""
@@ -154,36 +175,80 @@ class ToolRegistry:
154
175
 
155
176
  self.save_config()
156
177
 
178
+ def _load_cache(self):
179
+ """Overlay machine-specific detection results from the runtime cache onto
180
+ the loaded definitions. A missing or unreadable cache is normal."""
181
+ if not os.path.exists(self.cache_path):
182
+ return
183
+ try:
184
+ with open(self.cache_path, 'r') as f:
185
+ cache = json.load(f)
186
+ except Exception as e:
187
+ logger.warning(f"Ignoring unreadable tool cache {self.cache_path}: {e}")
188
+ return
189
+ for name, runtime in cache.get('tools', {}).items():
190
+ tool = self.tools.get(name)
191
+ if not tool:
192
+ continue
193
+ if 'path' in runtime:
194
+ tool.path = runtime['path']
195
+ if 'version' in runtime:
196
+ tool.version = runtime['version']
197
+ if 'last_checked' in runtime:
198
+ tool.last_checked = runtime['last_checked']
199
+ if isinstance(runtime.get('status'), str):
200
+ try:
201
+ tool.status = ToolStatus(runtime['status'])
202
+ except ValueError:
203
+ tool.status = ToolStatus.MISSING
204
+
157
205
  def save_config(self):
158
- """Save tool configuration to file"""
206
+ """Persist the curated tool definitions (static fields only).
207
+
208
+ Detection results are intentionally excluded — they belong in the runtime
209
+ cache (see save_cache) so this version-controlled file stays stable across
210
+ hosts and process restarts. Call this only when a definition changes."""
159
211
  try:
160
212
  config = {
213
+ 'tools': {
214
+ name: {field: getattr(tool, field) for field in STATIC_TOOL_FIELDS}
215
+ for name, tool in self.tools.items()
216
+ }
217
+ }
218
+ with open(self.config_path, 'w') as f:
219
+ json.dump(config, f, indent=2)
220
+ logger.debug(f"Saved tool definitions to {self.config_path}")
221
+ except Exception as e:
222
+ logger.error(f"Failed to save tool config: {e}")
223
+ import traceback
224
+ logger.debug(f"Save config traceback: {traceback.format_exc()}")
225
+
226
+ def save_cache(self):
227
+ """Persist machine-specific detection results to the gitignored runtime cache.
228
+
229
+ path/version/status/last_checked differ per host and change on every probe,
230
+ so they must never land in the version-controlled tools.json."""
231
+ try:
232
+ cache = {
161
233
  'tools': {
162
234
  name: {
163
- 'name': tool.name,
164
235
  'path': tool.path,
165
236
  'version': tool.version,
166
237
  'status': tool.status.value if hasattr(tool.status, 'value') else str(tool.status),
167
- 'aliases': tool.aliases,
168
- 'dependencies': tool.dependencies,
169
- 'platforms': tool.platforms,
170
- 'min_version': tool.min_version,
171
- 'max_version': tool.max_version,
172
238
  'last_checked': tool.last_checked,
173
- 'metadata': tool.metadata
174
239
  }
175
240
  for name, tool in self.tools.items()
176
241
  }
177
242
  }
178
-
179
- with open(self.config_path, 'w') as f:
180
- json.dump(config, f, indent=2)
181
- logger.debug(f"Saved tool config to {self.config_path}")
243
+ Path(self.cache_path).parent.mkdir(parents=True, exist_ok=True)
244
+ # Guarded by self._lock against threads, but not against multiple
245
+ # processes sharing one cache file. TODO: add file locking if iotsploit
246
+ # is ever run as concurrent processes against the same cache.
247
+ with open(self.cache_path, 'w') as f:
248
+ json.dump(cache, f, indent=2)
249
+ logger.debug(f"Saved tool runtime cache to {self.cache_path}")
182
250
  except Exception as e:
183
- logger.error(f"Failed to save tool config: {e}")
184
- # Add more detailed error information
185
- import traceback
186
- logger.debug(f"Save config traceback: {traceback.format_exc()}")
251
+ logger.error(f"Failed to save tool cache: {e}")
187
252
 
188
253
  def register_tool(self, tool_info: ToolInfo, save_config: bool = True):
189
254
  """Register a new tool"""
@@ -477,8 +542,8 @@ class ToolManager:
477
542
  validated_tool = self.validator.validate_tool(tool_info)
478
543
  self.registry.tools[tool_name] = validated_tool
479
544
  results[tool_name] = validated_tool.status
480
-
481
- self.registry.save_config()
545
+
546
+ self.registry.save_cache()
482
547
  return results
483
548
 
484
549
  def get_tool_status(self, tool_name: str) -> Optional[ToolStatus]:
@@ -488,11 +553,11 @@ class ToolManager:
488
553
  return None
489
554
 
490
555
  # Refresh status if not checked recently
491
- if not tool_info.last_checked or (time.time() - tool_info.last_checked) > 3600:
556
+ if not tool_info.last_checked or (time.time() - tool_info.last_checked) > TOOL_CACHE_TTL_SECONDS:
492
557
  tool_info = self.validator.validate_tool(tool_info)
493
558
  self.registry.tools[tool_info.name] = tool_info
494
- self.registry.save_config()
495
-
559
+ self.registry.save_cache()
560
+
496
561
  return tool_info.status
497
562
 
498
563
  def execute(self, tool_name: str, args: List[str], **kwargs) -> ExecutionResult:
@@ -525,91 +590,50 @@ class ToolManager:
525
590
  # Validate the tool
526
591
  validated_tool = self.validator.validate_tool(tool_info)
527
592
  self.registry.register_tool(validated_tool, save_config=save_config)
528
-
593
+ if save_config:
594
+ # Persisting a new tool also persists its freshly-probed runtime state.
595
+ self.registry.save_cache()
596
+
529
597
  return validated_tool.status == ToolStatus.AVAILABLE
530
598
 
531
599
  # ========== Methods from ToolCategoryManager ==========
532
600
 
533
601
  def _initialize_tools(self):
534
- """Initialize all tools from JSON configuration"""
535
- logger.info("Initializing tools from JSON config")
536
-
537
- # Get tools from the main tools.json file
538
- tool_configs = self.config_manager.get_tools_by_category("tools")
539
-
540
- # If no tools found in "tools" category, try loading from the main config
541
- if not tool_configs:
542
- # Try to load from tools.json in conf directory first, then fallback
543
- tools_file = Path('conf') / 'tools.json'
544
- if not tools_file.exists():
545
- tools_file = self.config_manager.config_dir / "tools.json"
546
-
547
- if tools_file.exists():
548
- with open(tools_file, 'r', encoding='utf-8') as f:
549
- tools_data = json.load(f)
550
-
551
- tools_added = 0
552
- # Support tools field as dict or list
553
- tools_section = tools_data.get('tools', [])
554
- if isinstance(tools_section, dict):
555
- items = tools_section.items()
556
- else:
557
- items = [(td.get('name', 'unknown'), td) for td in tools_section if isinstance(td, dict)]
558
- for tool_name, tool_data in items:
559
- try:
560
- # Build tool_data dict and ensure it contains name
561
- data = tool_data.copy() if isinstance(tool_data, dict) else {}
562
- data.setdefault('name', tool_name)
563
- tool_config = ToolConfig.from_dict(data)
564
- tool_config.category = "tools"
565
- # Add tool to manager (don't save config for each tool)
566
- success = self.add_tool(
567
- name=tool_config.name,
568
- aliases=tool_config.aliases,
569
- min_version=tool_config.min_version,
570
- platforms=tool_config.platforms,
571
- path=tool_config.path,
572
- save_config=False
573
- )
574
- if success:
575
- tools_added += 1
576
- logger.debug(f"Added tool: {tool_config.name}")
577
- else:
578
- logger.warning(f"Failed to add tool: {tool_config.name}")
579
- except Exception as e:
580
- logger.error(f"Error adding tool {tool_name}: {e}")
581
-
582
- # Save config once at the end
583
- if tools_added > 0:
584
- self.registry.save_config()
585
- logger.info(f"Successfully initialized {tools_added} tools")
586
- else:
587
- # Load tools from category
588
- tools_added = 0
589
- for tool_config in tool_configs:
602
+ """Validate registered tools, honoring the runtime cache so tools probed
603
+ recently are not re-probed on every startup."""
604
+ logger.info("Initializing tools from configuration")
605
+
606
+ # The registry already loaded curated definitions (and any cached runtime
607
+ # state) from tools.json. If it came up empty, fall back to the category
608
+ # manager as the definition source.
609
+ if not self.registry.tools:
610
+ for tool_config in self.config_manager.get_tools_by_category("tools"):
590
611
  try:
591
- success = self.add_tool(
612
+ self.registry.tools[tool_config.name] = ToolInfo(
592
613
  name=tool_config.name,
593
- aliases=tool_config.aliases,
594
- min_version=tool_config.min_version,
595
- platforms=tool_config.platforms,
596
614
  path=tool_config.path,
597
- save_config=False # Don't save for each tool
615
+ aliases=tool_config.aliases or [],
616
+ min_version=tool_config.min_version,
617
+ platforms=tool_config.platforms or ["linux", "darwin", "windows"],
598
618
  )
599
-
600
- if success:
601
- tools_added += 1
602
- logger.debug(f"Added tool: {tool_config.name}")
603
- else:
604
- logger.warning(f"Failed to add tool: {tool_config.name}")
605
-
606
619
  except Exception as e:
607
620
  logger.error(f"Error adding tool {tool_config.name}: {e}")
608
-
609
- # Save config once at the end
610
- if tools_added > 0:
611
- self.registry.save_config()
612
- logger.info(f"Successfully initialized {tools_added} tools")
621
+
622
+ now = time.time()
623
+ revalidated = 0
624
+ for name, tool_info in list(self.registry.tools.items()):
625
+ # Skip tools whose cached detection result is still fresh.
626
+ if tool_info.last_checked and (now - tool_info.last_checked) < TOOL_CACHE_TTL_SECONDS:
627
+ continue
628
+ self.registry.tools[name] = self.validator.validate_tool(tool_info)
629
+ revalidated += 1
630
+
631
+ # Only touch the runtime cache when something was actually re-probed.
632
+ if revalidated:
633
+ self.registry.save_cache()
634
+ logger.info(
635
+ f"Initialized {len(self.registry.tools)} tools ({revalidated} re-probed)"
636
+ )
613
637
 
614
638
  def get_category_info(self) -> CategoryInfo:
615
639
  """Get category information from JSON config"""
@@ -883,7 +907,7 @@ class ToolManager:
883
907
  },
884
908
  'recommendations': status.recommendations,
885
909
  'backend_status': self.backend_manager.get_status(),
886
- 'queue_status': self.queue_manager.get_stats()
910
+ 'queue_status': asdict(self.queue_manager.get_stats())
887
911
  }
888
912
 
889
913
  return report
@@ -594,37 +594,193 @@ class GreatFETProgrammer(BaseProgrammer):
594
594
  return self.execute_tool('greatfet_firmware', args, timeout=300)
595
595
 
596
596
  class FirmwareToolService(ToolService):
597
- """Tool service specialized for firmware operations"""
598
-
597
+ """Tool service specialized for firmware operations.
598
+
599
+ Manifest resolution order (later entries override earlier ones):
600
+ 1. Built-in manifest shipped inside the ``iotsploit_drivers`` package at
601
+ ``resources/firmware_manifest.json``.
602
+ 2. User override manifest at ``~/.iotsploit/firmware_manifest.json``
603
+ (also where runtime-added firmware entries are persisted).
604
+
605
+ Firmware entries may reference their binary in two ways:
606
+ * ``"resource": "<package>:<relpath>"`` — a logical reference resolved
607
+ via :func:`importlib.resources.as_file`. Preferred for bundled
608
+ defaults so wheels and editable installs both work.
609
+ * ``"path": "<filesystem path>"`` — a plain filesystem path. Intended
610
+ for user-added firmware.
611
+
612
+ Multi-file entries may use ``flash_options.files`` where each item has
613
+ either ``"resource"`` or ``"path"``.
614
+
615
+ Call :meth:`resolve_firmware` to obtain a context manager that yields
616
+ a dict with all ``resource`` entries materialized to real filesystem
617
+ paths — required for subprocess tools like ``esptool`` and
618
+ ``openFPGALoader`` that need a concrete path on disk.
619
+ """
620
+
621
+ #: Name of the built-in manifest resource inside iotsploit_drivers.
622
+ _BUILTIN_MANIFEST_PACKAGE = "iotsploit_drivers"
623
+ _BUILTIN_MANIFEST_RELPATH = "resources/firmware_manifest.json"
624
+
599
625
  def __init__(self):
600
626
  super().__init__()
601
-
602
- # Initialize firmware registry
627
+
603
628
  from pathlib import Path
604
- import json
605
- # Use centralized configuration directory
606
- self.conf_dir = Path('conf')
607
- self.conf_dir.mkdir(exist_ok=True)
608
- self.manifest_file = self.conf_dir / 'firmware_manifest.json'
629
+
630
+ # User directory for overrides and runtime-added firmware.
631
+ self.user_dir = Path.home() / ".iotsploit"
632
+ self.user_dir.mkdir(exist_ok=True)
633
+ self.user_manifest_file = self.user_dir / "firmware_manifest.json"
634
+
609
635
  self.manifests = self._load_manifests()
610
-
636
+
611
637
  # Initialize programmers
612
638
  self.esp32 = ESP32Programmer(self)
613
639
  self.stm32 = STM32Programmer(self)
614
640
  self.dfu = DFUProgrammer(self)
615
641
  self.fpga = FPGAProgrammer(self)
616
642
  self.greatfet = GreatFETProgrammer(self)
617
-
643
+
644
+ # ------------------------------------------------------------------
645
+ # Manifest loading
646
+ # ------------------------------------------------------------------
647
+
648
+ def _load_builtin_manifest(self) -> Dict:
649
+ """Load the manifest shipped inside the iotsploit_drivers package."""
650
+ try:
651
+ from importlib.resources import files
652
+ except ImportError: # pragma: no cover - Python <3.9 not supported
653
+ return {}
654
+
655
+ try:
656
+ traversable = files(self._BUILTIN_MANIFEST_PACKAGE).joinpath(
657
+ self._BUILTIN_MANIFEST_RELPATH
658
+ )
659
+ data = json.loads(traversable.read_text(encoding="utf-8"))
660
+ except (ModuleNotFoundError, FileNotFoundError):
661
+ self.logger.warning(
662
+ "Built-in firmware manifest not found in %s:%s",
663
+ self._BUILTIN_MANIFEST_PACKAGE,
664
+ self._BUILTIN_MANIFEST_RELPATH,
665
+ )
666
+ return {}
667
+ except json.JSONDecodeError as exc:
668
+ self.logger.error("Built-in firmware manifest is invalid JSON: %s", exc)
669
+ return {}
670
+
671
+ # Drop schema metadata key if present.
672
+ data.pop("_schema", None)
673
+ return data
674
+
675
+ def _load_user_manifest(self) -> Dict:
676
+ """Load the user override manifest from ~/.iotsploit."""
677
+ if not self.user_manifest_file.exists():
678
+ return {}
679
+ try:
680
+ with open(self.user_manifest_file, "r") as f:
681
+ data = json.load(f)
682
+ except json.JSONDecodeError as exc:
683
+ self.logger.error(
684
+ "User firmware manifest %s is invalid JSON: %s",
685
+ self.user_manifest_file,
686
+ exc,
687
+ )
688
+ return {}
689
+ data.pop("_schema", None)
690
+ return data
691
+
618
692
  def _load_manifests(self) -> Dict:
619
- """Load firmware manifests from JSON file"""
620
- if self.manifest_file.exists():
621
- try:
622
- with open(self.manifest_file, 'r') as f:
623
- return json.load(f)
624
- except json.JSONDecodeError:
625
- self.logger.error("Error loading firmware manifest file")
626
- return {}
627
- return {}
693
+ """Merge built-in and user manifests. User entries take precedence."""
694
+ merged: Dict = {}
695
+ merged.update(self._load_builtin_manifest())
696
+ merged.update(self._load_user_manifest())
697
+ return merged
698
+
699
+ # ------------------------------------------------------------------
700
+ # Firmware resolution
701
+ # ------------------------------------------------------------------
702
+
703
+ @staticmethod
704
+ def _parse_resource_ref(resource_ref: str):
705
+ """Parse ``"<package>:<relpath>"`` into its components."""
706
+ if ":" not in resource_ref:
707
+ raise ValueError(
708
+ f"Invalid resource ref {resource_ref!r}; "
709
+ f"expected '<package>:<relpath>'"
710
+ )
711
+ package, relpath = resource_ref.split(":", 1)
712
+ package = package.strip()
713
+ relpath = relpath.strip().lstrip("/")
714
+ if not package or not relpath:
715
+ raise ValueError(f"Invalid resource ref {resource_ref!r}")
716
+ return package, relpath
717
+
718
+ @classmethod
719
+ def _resource_as_path(cls, resource_ref: str):
720
+ """Return a context manager yielding a concrete Path for a resource ref.
721
+
722
+ Uses :func:`importlib.resources.as_file` so resources packed inside
723
+ zipped wheels are materialized to a temp file for the duration of the
724
+ ``with`` block.
725
+ """
726
+ from importlib.resources import files, as_file
727
+
728
+ package, relpath = cls._parse_resource_ref(resource_ref)
729
+ traversable = files(package).joinpath(relpath)
730
+ return as_file(traversable)
731
+
732
+ def resolve_firmware(self, name: str):
733
+ """Context manager yielding a firmware_info dict with real paths.
734
+
735
+ The yielded dict is a shallow copy of the manifest entry where:
736
+ * ``resource`` has been replaced by ``path`` (a real filesystem str)
737
+ * ``flash_options.files[*].resource`` has been replaced by ``path``
738
+
739
+ All temporary files (if any) are kept alive for the duration of the
740
+ ``with`` block. Raises :class:`KeyError` if ``name`` is not in the
741
+ manifest.
742
+ """
743
+ from contextlib import contextmanager, ExitStack
744
+ from pathlib import Path
745
+
746
+ info = self.manifests.get(name)
747
+ if info is None:
748
+ raise KeyError(f"Firmware {name!r} not found in manifest")
749
+
750
+ @contextmanager
751
+ def _ctx():
752
+ with ExitStack() as stack:
753
+ resolved = dict(info)
754
+
755
+ # Top-level resource/path.
756
+ if "resource" in info:
757
+ p = stack.enter_context(self._resource_as_path(info["resource"]))
758
+ resolved["path"] = str(p)
759
+ resolved.pop("resource", None)
760
+ elif "path" in info:
761
+ resolved["path"] = str(info["path"])
762
+
763
+ # flash_options.files[*]
764
+ flash_options = dict(info.get("flash_options", {}))
765
+ if "files" in flash_options:
766
+ new_files = []
767
+ for entry in flash_options["files"]:
768
+ new_entry = dict(entry)
769
+ if "resource" in entry:
770
+ p = stack.enter_context(
771
+ self._resource_as_path(entry["resource"])
772
+ )
773
+ new_entry["path"] = str(p)
774
+ new_entry.pop("resource", None)
775
+ elif "path" in entry:
776
+ new_entry["path"] = str(entry["path"])
777
+ new_files.append(new_entry)
778
+ flash_options["files"] = new_files
779
+
780
+ resolved["flash_options"] = flash_options
781
+ yield resolved
782
+
783
+ return _ctx()
628
784
 
629
785
  def add_firmware(self, name: str, path: str, device_type: str, version: str,
630
786
  flash_options: Optional[Dict[str, Any]] = None) -> bool:
@@ -690,7 +846,9 @@ class FirmwareToolService(ToolService):
690
846
  filename = url.split('/')[-1]
691
847
  if not filename:
692
848
  filename = "downloaded_firmware.bin"
693
- output_path = str(self.conf_dir / filename)
849
+ downloads_dir = self.user_dir / "firmware"
850
+ downloads_dir.mkdir(parents=True, exist_ok=True)
851
+ output_path = str(downloads_dir / filename)
694
852
 
695
853
  with open(output_path, 'wb') as f:
696
854
  for chunk in response.iter_content(chunk_size=8192):
@@ -719,93 +877,103 @@ class FirmwareToolService(ToolService):
719
877
  self.logger.error(f"Firmware not found: {name}")
720
878
  return False
721
879
 
722
- firmware_info = self.manifests[name]
723
- device_type = firmware_info.get('device_type', '').lower()
724
- firmware_path = firmware_info['path']
725
-
726
- # Merge options from manifest and provided options
727
- flash_options = firmware_info.get('flash_options', {}).copy()
728
- if options:
729
- flash_options.update(options)
730
-
731
- # Route to appropriate programmer based on device type
732
- if device_type.startswith('esp32'):
733
- if 'files' in flash_options:
734
- # Multi-file ESP32 flash
735
- result = self.esp32.flash_multi(
736
- port=flash_options.get('port', '/dev/ttyUSB0'),
737
- files=flash_options['files'],
738
- chip=flash_options.get('chip', 'esp32s3'),
739
- baud=flash_options.get('baud', '460800')
880
+ with self.resolve_firmware(name) as firmware_info:
881
+ device_type = firmware_info.get('device_type', '').lower()
882
+ firmware_path = firmware_info.get('path')
883
+
884
+ # Merge options from manifest and provided options
885
+ flash_options = dict(firmware_info.get('flash_options', {}))
886
+ if options:
887
+ flash_options.update(options)
888
+
889
+ # Route to appropriate programmer based on device type
890
+ if device_type.startswith('esp32'):
891
+ if 'files' in flash_options:
892
+ # Multi-file ESP32 flash
893
+ result = self.esp32.flash_multi(
894
+ port=flash_options.get('port', '/dev/ttyUSB0'),
895
+ files=flash_options['files'],
896
+ chip=flash_options.get('chip', 'esp32s3'),
897
+ baud=flash_options.get('baud', '460800')
898
+ )
899
+ else:
900
+ # Single file ESP32 flash
901
+ result = self.esp32.flash_single(
902
+ port=flash_options.get('port', '/dev/ttyUSB0'),
903
+ firmware_path=firmware_path,
904
+ address=flash_options.get('address', '0x10000'),
905
+ chip=flash_options.get('chip', 'esp32'),
906
+ baud=flash_options.get('baud', '460800')
907
+ )
908
+ return result.success
909
+
910
+ elif device_type.startswith('stm32'):
911
+ result = self.stm32.flash_firmware(
912
+ firmware_path=firmware_path,
913
+ interface=flash_options.get('interface', 'stlink'),
914
+ target=flash_options.get('target', 'stm32f4x')
740
915
  )
741
- else:
742
- # Single file ESP32 flash
743
- result = self.esp32.flash_single(
744
- port=flash_options.get('port', '/dev/ttyUSB0'),
916
+ return result.success
917
+
918
+ elif device_type == 'dfu':
919
+ result = self.dfu.flash_firmware(
745
920
  firmware_path=firmware_path,
746
- address=flash_options.get('address', '0x10000'),
747
- chip=flash_options.get('chip', 'esp32'),
748
- baud=flash_options.get('baud', '460800')
921
+ vid=flash_options.get('vid'),
922
+ pid=flash_options.get('pid'),
923
+ alt=flash_options.get('alt')
749
924
  )
750
- return result.success
751
-
752
- elif device_type.startswith('stm32'):
753
- result = self.stm32.flash_firmware(
754
- firmware_path=firmware_path,
755
- interface=flash_options.get('interface', 'stlink'),
756
- target=flash_options.get('target', 'stm32f4x')
757
- )
758
- return result.success
759
-
760
- elif device_type == 'dfu':
761
- result = self.dfu.flash_firmware(
762
- firmware_path=firmware_path,
763
- vid=flash_options.get('vid'),
764
- pid=flash_options.get('pid'),
765
- alt=flash_options.get('alt')
766
- )
767
- return result.success
768
-
769
- elif device_type.startswith('fpga'):
770
- target = flash_options.get('target', 'sram').lower()
771
- if target == 'sram':
772
- result = self.fpga.load_sram(
773
- bitstream_path=firmware_path,
774
- board=flash_options.get('board'),
775
- cable=flash_options.get('cable')
925
+ return result.success
926
+
927
+ elif device_type.startswith('fpga'):
928
+ target = flash_options.get('target', 'sram').lower()
929
+ if target == 'sram':
930
+ result = self.fpga.load_sram(
931
+ bitstream_path=firmware_path,
932
+ board=flash_options.get('board'),
933
+ cable=flash_options.get('cable')
934
+ )
935
+ else:
936
+ result = self.fpga.flash_bitstream(
937
+ bitstream_path=firmware_path,
938
+ board=flash_options.get('board'),
939
+ cable=flash_options.get('cable'),
940
+ external_flash=flash_options.get('external_flash', False)
941
+ )
942
+ return result.success
943
+
944
+ elif device_type.startswith('greatfet'):
945
+ result = self.greatfet.flash_firmware(
946
+ firmware_path=firmware_path,
947
+ target=flash_options.get('target', 'spi'),
948
+ serial=flash_options.get('serial'),
949
+ board=flash_options.get('board')
776
950
  )
951
+ return result.success
952
+
777
953
  else:
778
- result = self.fpga.flash_bitstream(
779
- bitstream_path=firmware_path,
780
- board=flash_options.get('board'),
781
- cable=flash_options.get('cable'),
782
- external_flash=flash_options.get('external_flash', False)
783
- )
784
- return result.success
785
-
786
- elif device_type.startswith('greatfet'):
787
- result = self.greatfet.flash_firmware(
788
- firmware_path=firmware_path,
789
- target=flash_options.get('target', 'spi'),
790
- serial=flash_options.get('serial'),
791
- board=flash_options.get('board')
792
- )
793
- return result.success
794
-
795
- else:
796
- self.logger.error(f"Unsupported device type: {device_type}")
797
- return False
954
+ self.logger.error(f"Unsupported device type: {device_type}")
955
+ return False
798
956
 
799
957
  except Exception as e:
800
958
  self.logger.error(f"Error flashing firmware: {str(e)}")
801
959
  return False
802
960
 
803
961
  def _save_manifests(self):
804
- """Save firmware manifests to JSON file"""
962
+ """Persist user-facing manifest entries to ``~/.iotsploit/firmware_manifest.json``.
963
+
964
+ Only entries that differ from the built-in manifest are written so
965
+ that the user override file stays minimal and keeps working even when
966
+ the bundled defaults change in a future iotsploit-drivers release.
967
+ """
805
968
  try:
806
- import json
807
- with open(self.manifest_file, 'w') as f:
808
- json.dump(self.manifests, f, indent=2)
969
+ builtin = self._load_builtin_manifest()
970
+ user_entries = {
971
+ name: info
972
+ for name, info in self.manifests.items()
973
+ if builtin.get(name) != info
974
+ }
975
+ with open(self.user_manifest_file, 'w') as f:
976
+ json.dump(user_entries, f, indent=2)
809
977
  except Exception as e:
810
978
  self.logger.error(f"Error saving firmware manifest: {str(e)}")
811
979
 
File without changes