qyro-engine 0.1.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.
- qyro/__init__.py +69 -0
- qyro/adapters/__init__.py +20 -0
- qyro/adapters/environment/__init__.py +3 -0
- qyro/adapters/environment/system_environment.py +57 -0
- qyro/adapters/platform/__init__.py +3 -0
- qyro/adapters/platform/detector.py +73 -0
- qyro/adapters/resources/__init__.py +3 -0
- qyro/adapters/resources/filesystem_resources.py +217 -0
- qyro/adapters/resources/protected_bundle.py +227 -0
- qyro/adapters/resources/runtime_secret.py +48 -0
- qyro/adapters/resources/secrets_crypto.py +133 -0
- qyro/adapters/settings/__init__.py +3 -0
- qyro/adapters/settings/json_settings.py +198 -0
- qyro/adapters/telemetry/__init__.py +4 -0
- qyro/adapters/telemetry/console_hook.py +38 -0
- qyro/adapters/telemetry/sentry_hook.py +51 -0
- qyro/application/__init__.py +26 -0
- qyro/application/ports/__init__.py +16 -0
- qyro/application/ports/environment.py +37 -0
- qyro/application/ports/resources.py +23 -0
- qyro/application/ports/settings.py +22 -0
- qyro/application/ports/telemetry.py +27 -0
- qyro/application/use_cases/__init__.py +13 -0
- qyro/application/use_cases/initialize_engine.py +41 -0
- qyro/application/use_cases/load_settings.py +25 -0
- qyro/application/use_cases/resolve_resource.py +30 -0
- qyro/client/__init__.py +3 -0
- qyro/client/development_changes.py +135 -0
- qyro/client/development_pydux.py +64 -0
- qyro/client/development_state.py +96 -0
- qyro/client/velto_context.py +520 -0
- qyro/container.py +66 -0
- qyro/domain/__init__.py +33 -0
- qyro/domain/entities.py +83 -0
- qyro/domain/errors.py +49 -0
- qyro_engine-0.1.0.dist-info/METADATA +351 -0
- qyro_engine-0.1.0.dist-info/RECORD +40 -0
- qyro_engine-0.1.0.dist-info/WHEEL +4 -0
- qyro_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- qyro_engine-0.1.0.dist-info/licenses/NOTICE +159 -0
qyro/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Qyro.
|
|
3
|
+
Clean Architecture runtime engine and lifecycle framework for Python desktop GUI apps.
|
|
4
|
+
Decoupled from CLI tools, independent and multi-toolkit ready.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from qyro.client.velto_context import VeltoContext
|
|
8
|
+
from qyro.container import EngineContainer
|
|
9
|
+
from qyro.adapters.platform.detector import PlatformDetector
|
|
10
|
+
from qyro.domain.entities import PlatformType, ExecutionMode, AppMetadata
|
|
11
|
+
from qyro.domain.errors import (
|
|
12
|
+
QyroEngineError,
|
|
13
|
+
ResourceNotFoundError,
|
|
14
|
+
SettingsNotFoundError,
|
|
15
|
+
FrameworkNotAvailableError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
# Module-level singleton container for quick procedural usage
|
|
21
|
+
_default_container: EngineContainer | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_default_container() -> EngineContainer:
|
|
25
|
+
global _default_container
|
|
26
|
+
app_container = VeltoContext._global_container
|
|
27
|
+
if app_container is not None:
|
|
28
|
+
_default_container = app_container
|
|
29
|
+
return app_container
|
|
30
|
+
|
|
31
|
+
if _default_container is None:
|
|
32
|
+
_default_container = EngineContainer()
|
|
33
|
+
return _default_container
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_resource(*segments: str, required: bool = True) -> str:
|
|
37
|
+
"""Convenience helper to resolve a resource path without a context instance."""
|
|
38
|
+
container = _get_default_container()
|
|
39
|
+
return str(container.resolve_resource_use_case.execute(*segments, required=required))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_build_settings() -> dict:
|
|
43
|
+
"""Convenience helper to load build settings dictionary."""
|
|
44
|
+
container = _get_default_container()
|
|
45
|
+
return container.load_settings_use_case.execute().raw_settings
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_frozen() -> bool:
|
|
49
|
+
"""Convenience helper to check if running in a frozen executable."""
|
|
50
|
+
container = _get_default_container()
|
|
51
|
+
return container.env_adapter.is_frozen()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"VeltoContext",
|
|
57
|
+
"EngineContainer",
|
|
58
|
+
"PlatformDetector",
|
|
59
|
+
"PlatformType",
|
|
60
|
+
"ExecutionMode",
|
|
61
|
+
"AppMetadata",
|
|
62
|
+
"QyroEngineError",
|
|
63
|
+
"ResourceNotFoundError",
|
|
64
|
+
"SettingsNotFoundError",
|
|
65
|
+
"FrameworkNotAvailableError",
|
|
66
|
+
"get_resource",
|
|
67
|
+
"load_build_settings",
|
|
68
|
+
"is_frozen",
|
|
69
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Qyro - Adapters Layer.
|
|
3
|
+
Concrete implementations of application ports.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .environment.system_environment import SystemEnvironmentAdapter
|
|
7
|
+
from .settings.json_settings import JsonSettingsAdapter
|
|
8
|
+
from .resources.filesystem_resources import FileSystemResourceAdapter
|
|
9
|
+
from .telemetry.console_hook import ConsoleExceptionHookAdapter
|
|
10
|
+
from .telemetry.sentry_hook import SentryExceptionHookAdapter
|
|
11
|
+
from .platform.detector import PlatformDetector
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"SystemEnvironmentAdapter",
|
|
15
|
+
"JsonSettingsAdapter",
|
|
16
|
+
"FileSystemResourceAdapter",
|
|
17
|
+
"ConsoleExceptionHookAdapter",
|
|
18
|
+
"SentryExceptionHookAdapter",
|
|
19
|
+
"PlatformDetector",
|
|
20
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
System Environment Adapter.
|
|
3
|
+
Implements IEnvironmentPort for PyInstaller, cx_Freeze, and Source environments.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from qyro.application.ports.environment import IEnvironmentPort
|
|
9
|
+
from qyro.domain.entities import ExecutionMode, PlatformType
|
|
10
|
+
from qyro.adapters.platform.detector import PlatformDetector
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SystemEnvironmentAdapter(IEnvironmentPort):
|
|
14
|
+
"""Detects runtime execution specifics across operating systems."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, custom_root: Path | None = None) -> None:
|
|
17
|
+
self._custom_root = custom_root
|
|
18
|
+
|
|
19
|
+
def is_frozen(self) -> bool:
|
|
20
|
+
return getattr(sys, "frozen", False)
|
|
21
|
+
|
|
22
|
+
def get_execution_mode(self) -> ExecutionMode:
|
|
23
|
+
if self.is_frozen():
|
|
24
|
+
if hasattr(sys, "_MEIPASS"):
|
|
25
|
+
return ExecutionMode.FROZEN_PYINSTALLER
|
|
26
|
+
return ExecutionMode.FROZEN_GENERIC
|
|
27
|
+
return ExecutionMode.SOURCE
|
|
28
|
+
|
|
29
|
+
def get_platform(self) -> PlatformType:
|
|
30
|
+
return PlatformDetector.get_platform()
|
|
31
|
+
|
|
32
|
+
def get_root_dir(self) -> Path:
|
|
33
|
+
if self._custom_root:
|
|
34
|
+
return self._custom_root
|
|
35
|
+
|
|
36
|
+
if self.is_frozen():
|
|
37
|
+
# Executable directory
|
|
38
|
+
return Path(sys.executable).resolve().parent
|
|
39
|
+
|
|
40
|
+
# Source mode: find project root containing pyproject.toml or src/
|
|
41
|
+
cwd = Path.cwd()
|
|
42
|
+
for parent in [cwd] + list(cwd.parents):
|
|
43
|
+
if (parent / "pyproject.toml").exists() or (parent / "src").exists():
|
|
44
|
+
return parent
|
|
45
|
+
return cwd
|
|
46
|
+
|
|
47
|
+
def get_bundle_dir(self) -> Path:
|
|
48
|
+
if self.is_frozen() and hasattr(sys, "_MEIPASS"):
|
|
49
|
+
return Path(getattr(sys, "_MEIPASS")).resolve()
|
|
50
|
+
|
|
51
|
+
# macOS bundle check
|
|
52
|
+
if self.is_frozen() and sys.platform == "darwin":
|
|
53
|
+
mac_resources = Path(sys.executable).resolve().parent.parent / "Resources"
|
|
54
|
+
if mac_resources.exists():
|
|
55
|
+
return mac_resources
|
|
56
|
+
|
|
57
|
+
return self.get_root_dir()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Platform Detection Adapter.
|
|
3
|
+
Detects OS and desktop environment with fine-grained distro support.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from qyro.domain.entities import PlatformType
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PlatformDetector:
|
|
12
|
+
"""Detects host operating system, architecture, and desktop flavors."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def get_platform() -> PlatformType:
|
|
17
|
+
"""Return the platform type detected from the current operating system."""
|
|
18
|
+
if sys.platform.startswith("win"):
|
|
19
|
+
return PlatformType.WINDOWS
|
|
20
|
+
if sys.platform == "darwin":
|
|
21
|
+
return PlatformType.MACOS
|
|
22
|
+
if sys.platform.startswith("linux"):
|
|
23
|
+
return PlatformType.LINUX
|
|
24
|
+
if sys.platform.startswith("android"):
|
|
25
|
+
return PlatformType.ANDROID
|
|
26
|
+
return PlatformType.UNKNOWN
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def is_windows(cls) -> bool:
|
|
30
|
+
return cls.get_platform() == PlatformType.WINDOWS
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def is_mac(cls) -> bool:
|
|
34
|
+
return cls.get_platform() == PlatformType.MACOS
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def is_linux(cls) -> bool:
|
|
38
|
+
return cls.get_platform() == PlatformType.LINUX
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def is_ubuntu(cls) -> bool:
|
|
42
|
+
return cls._linux_distro_id() == "ubuntu"
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def is_fedora(cls) -> bool:
|
|
46
|
+
return cls._linux_distro_id() == "fedora"
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def is_arch(cls) -> bool:
|
|
50
|
+
return cls._linux_distro_id() in ("arch", "manjaro")
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def is_gnome(cls) -> bool:
|
|
54
|
+
de = os.environ.get("XDG_CURRENT_DESKTOP", "").lower()
|
|
55
|
+
return "gnome" in de
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def is_kde(cls) -> bool:
|
|
59
|
+
de = os.environ.get("XDG_CURRENT_DESKTOP", "").lower()
|
|
60
|
+
return "kde" in de
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _linux_distro_id() -> str:
|
|
64
|
+
if not sys.platform.startswith("linux"):
|
|
65
|
+
return ""
|
|
66
|
+
try:
|
|
67
|
+
with open("/etc/os-release", "r", encoding="utf-8") as f:
|
|
68
|
+
for line in f:
|
|
69
|
+
if line.startswith("ID="):
|
|
70
|
+
return line.strip().split("=")[1].strip('"').lower()
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
return ""
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
|
|
2
|
+
"""
|
|
3
|
+
Filesystem resource adapter implementing IResourcePort.
|
|
4
|
+
|
|
5
|
+
Resolves application resources across source and frozen environments,
|
|
6
|
+
including optional protected resource bundles.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from qyro.application.ports.environment import IEnvironmentPort
|
|
12
|
+
from qyro.application.ports.resources import IResourcePort
|
|
13
|
+
from qyro.adapters.resources.protected_bundle import ProtectedResourceBundle
|
|
14
|
+
from qyro.domain.entities import PlatformType, ResourceQuery, ResourceResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FileSystemResourceAdapter(IResourcePort):
|
|
18
|
+
"""Resolve resources across source and frozen directory hierarchies."""
|
|
19
|
+
|
|
20
|
+
_PLATFORM_DIRECTORIES = {
|
|
21
|
+
PlatformType.WINDOWS: ("windows", "win32"),
|
|
22
|
+
PlatformType.MACOS: ("mac", "darwin"),
|
|
23
|
+
PlatformType.LINUX: ("linux",),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_RESOURCE_ALIASES = frozenset({
|
|
27
|
+
"base",
|
|
28
|
+
"windows",
|
|
29
|
+
"win32",
|
|
30
|
+
"mac",
|
|
31
|
+
"darwin",
|
|
32
|
+
"linux",
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
env_port: IEnvironmentPort,
|
|
38
|
+
custom_resources_dir: Path | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Initialize the adapter with its environment and optional resource root."""
|
|
41
|
+
self._env = env_port
|
|
42
|
+
self._custom_resources_dir = custom_resources_dir
|
|
43
|
+
|
|
44
|
+
def _get_candidate_roots(self) -> list[Path]:
|
|
45
|
+
"""Return resource directories in resolution priority order."""
|
|
46
|
+
if self._custom_resources_dir is not None:
|
|
47
|
+
custom_root = Path(self._custom_resources_dir).resolve()
|
|
48
|
+
if custom_root.is_dir():
|
|
49
|
+
return [custom_root]
|
|
50
|
+
|
|
51
|
+
platform_dirs = self._PLATFORM_DIRECTORIES.get(
|
|
52
|
+
self._env.get_platform(),
|
|
53
|
+
(),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
candidates: list[Path] = []
|
|
57
|
+
|
|
58
|
+
if self._env.is_frozen():
|
|
59
|
+
protected_root = ProtectedResourceBundle.get_extracted_root(self._env)
|
|
60
|
+
if protected_root is not None:
|
|
61
|
+
protected_resources = protected_root / "resources"
|
|
62
|
+
candidates.extend(
|
|
63
|
+
self._resource_directories(
|
|
64
|
+
protected_resources,
|
|
65
|
+
platform_dirs,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
bundle = self._env.get_bundle_dir()
|
|
70
|
+
|
|
71
|
+
candidates.extend(
|
|
72
|
+
self._resource_directories(
|
|
73
|
+
bundle / "resources",
|
|
74
|
+
platform_dirs,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
candidates.extend(
|
|
78
|
+
self._resource_directories(
|
|
79
|
+
bundle / "src" / "main" / "resources",
|
|
80
|
+
platform_dirs,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
candidates.append(bundle)
|
|
84
|
+
|
|
85
|
+
else:
|
|
86
|
+
root = self._env.get_root_dir()
|
|
87
|
+
|
|
88
|
+
candidates.extend(
|
|
89
|
+
self._resource_directories(
|
|
90
|
+
root / "resources",
|
|
91
|
+
platform_dirs,
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
candidates.extend(
|
|
95
|
+
self._resource_directories(
|
|
96
|
+
root / "src" / "main" / "resources",
|
|
97
|
+
platform_dirs,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
protected_root = ProtectedResourceBundle.get_extracted_root(self._env)
|
|
102
|
+
if protected_root is not None:
|
|
103
|
+
protected_resources = protected_root / "resources"
|
|
104
|
+
candidates.extend(
|
|
105
|
+
self._resource_directories(
|
|
106
|
+
protected_resources,
|
|
107
|
+
platform_dirs,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return self._existing_unique_directories(candidates)
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _resource_directories(
|
|
115
|
+
root: Path,
|
|
116
|
+
platform_dirs: tuple[str, ...],
|
|
117
|
+
) -> list[Path]:
|
|
118
|
+
"""Build platform-specific and shared resource directories."""
|
|
119
|
+
return [
|
|
120
|
+
*(root / directory for directory in platform_dirs),
|
|
121
|
+
root / "base",
|
|
122
|
+
root,
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def _existing_unique_directories(
|
|
127
|
+
candidates: list[Path],
|
|
128
|
+
) -> list[Path]:
|
|
129
|
+
"""Filter missing directories and duplicates while preserving order."""
|
|
130
|
+
unique: list[Path] = []
|
|
131
|
+
seen: set[Path] = set()
|
|
132
|
+
|
|
133
|
+
for candidate in candidates:
|
|
134
|
+
path = candidate.resolve()
|
|
135
|
+
|
|
136
|
+
if not path.is_dir() or path in seen:
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
seen.add(path)
|
|
140
|
+
unique.append(path)
|
|
141
|
+
|
|
142
|
+
return unique
|
|
143
|
+
|
|
144
|
+
def resolve(self, query: ResourceQuery) -> ResourceResult:
|
|
145
|
+
"""Resolve a resource query to an absolute filesystem path."""
|
|
146
|
+
rel_path = Path(*query.relative_path_segments)
|
|
147
|
+
rel_candidates = self._candidate_relative_paths(rel_path)
|
|
148
|
+
candidates = self._get_candidate_roots()
|
|
149
|
+
|
|
150
|
+
frozen = self._env.is_frozen()
|
|
151
|
+
platform = self._env.get_platform()
|
|
152
|
+
|
|
153
|
+
for root in candidates:
|
|
154
|
+
for relative_path in rel_candidates:
|
|
155
|
+
target = root / relative_path
|
|
156
|
+
|
|
157
|
+
if target.is_file():
|
|
158
|
+
return ResourceResult(
|
|
159
|
+
absolute_path=target.resolve(),
|
|
160
|
+
exists=True,
|
|
161
|
+
is_bundled=frozen,
|
|
162
|
+
target_os=platform,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
primary_root = (
|
|
166
|
+
candidates[0]
|
|
167
|
+
if candidates
|
|
168
|
+
else self._env.get_root_dir()
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return ResourceResult(
|
|
172
|
+
absolute_path=(primary_root / rel_path).resolve(),
|
|
173
|
+
exists=False,
|
|
174
|
+
is_bundled=frozen,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def _candidate_relative_paths(
|
|
178
|
+
self,
|
|
179
|
+
rel_path: Path,
|
|
180
|
+
) -> list[Path]:
|
|
181
|
+
"""Generate relative path variants for resource directory aliases."""
|
|
182
|
+
alternatives = [rel_path]
|
|
183
|
+
parts = rel_path.parts
|
|
184
|
+
|
|
185
|
+
if (
|
|
186
|
+
len(parts) >= 2
|
|
187
|
+
and parts[0].lower() in self._RESOURCE_ALIASES
|
|
188
|
+
):
|
|
189
|
+
alternatives.append(Path(*parts[1:]))
|
|
190
|
+
|
|
191
|
+
return list(dict.fromkeys(alternatives))
|
|
192
|
+
|
|
193
|
+
def list_resources(
|
|
194
|
+
self,
|
|
195
|
+
subdirectory: str = "",
|
|
196
|
+
) -> list[Path]:
|
|
197
|
+
"""List resources while preserving directory resolution priority."""
|
|
198
|
+
relative_path = Path(subdirectory)
|
|
199
|
+
results: list[Path] = []
|
|
200
|
+
seen: set[Path] = set()
|
|
201
|
+
|
|
202
|
+
for root in self._get_candidate_roots():
|
|
203
|
+
target_dir = root / relative_path
|
|
204
|
+
|
|
205
|
+
if not target_dir.is_dir():
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
for path in target_dir.iterdir():
|
|
209
|
+
resolved = path.resolve()
|
|
210
|
+
|
|
211
|
+
if resolved in seen:
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
seen.add(resolved)
|
|
215
|
+
results.append(resolved)
|
|
216
|
+
|
|
217
|
+
return results
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Protected bundle runtime loader.
|
|
3
|
+
|
|
4
|
+
Loads and extracts Velto's protected resources package generated by velto-cli,
|
|
5
|
+
making embedded settings/ and resources/ folders available through filesystem
|
|
6
|
+
paths without changing existing public APIs.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import atexit
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import io
|
|
15
|
+
import shutil
|
|
16
|
+
import tempfile
|
|
17
|
+
import zipfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from threading import Lock
|
|
20
|
+
|
|
21
|
+
from qyro.application.ports.environment import IEnvironmentPort
|
|
22
|
+
from qyro.adapters.resources.runtime_secret import (
|
|
23
|
+
find_runtime_secret_module,
|
|
24
|
+
read_runtime_secret,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_MAGIC = b"QYRPKG"
|
|
29
|
+
_NONCE_LEN = 16
|
|
30
|
+
_SALT_LEN = 16
|
|
31
|
+
_TAG_LEN = 32
|
|
32
|
+
_KEY_LEN = 32
|
|
33
|
+
_RUNTIME_SECRET_MODULE_STEM = "runtime"
|
|
34
|
+
_SECRETS_ARCHIVE_PATH = ".velto/secrets.enc"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ProtectedResourceBundle:
|
|
38
|
+
"""Utility that extracts protected bundle contents when available."""
|
|
39
|
+
|
|
40
|
+
_lock = Lock()
|
|
41
|
+
_extracted_cache: dict[str, Path] = {}
|
|
42
|
+
_encrypted_secrets_cache: dict[str, bytes | None] = {}
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def get_extracted_root(cls, env_port: IEnvironmentPort) -> Path | None:
|
|
46
|
+
"""Return extracted root containing settings/ and resources/ when present."""
|
|
47
|
+
base_dir = env_port.get_bundle_dir() if env_port.is_frozen() else env_port.get_root_dir()
|
|
48
|
+
package_path = base_dir / ".velto" / "resources.pak"
|
|
49
|
+
secret_module_path = cls._find_runtime_secret_module(base_dir / ".velto")
|
|
50
|
+
|
|
51
|
+
if not package_path.exists() or secret_module_path is None:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
cache_key = cls._cache_key(package_path, secret_module_path)
|
|
55
|
+
with cls._lock:
|
|
56
|
+
cached = cls._extracted_cache.get(cache_key)
|
|
57
|
+
if cached and cached.exists():
|
|
58
|
+
return cached
|
|
59
|
+
try:
|
|
60
|
+
extracted_root, encrypted_payload = cls._extract_bundle(package_path, secret_module_path)
|
|
61
|
+
except Exception:
|
|
62
|
+
return None
|
|
63
|
+
cls._extracted_cache[cache_key] = extracted_root
|
|
64
|
+
cls._encrypted_secrets_cache[cache_key] = encrypted_payload
|
|
65
|
+
return extracted_root
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def get_encrypted_secrets_payload(cls, env_port: IEnvironmentPort) -> bytes | None:
|
|
69
|
+
"""Return encrypted secrets payload embedded in resources.pak when available."""
|
|
70
|
+
base_dir = env_port.get_bundle_dir() if env_port.is_frozen() else env_port.get_root_dir()
|
|
71
|
+
package_path = base_dir / ".velto" / "resources.pak"
|
|
72
|
+
secret_module_path = cls._find_runtime_secret_module(base_dir / ".velto")
|
|
73
|
+
|
|
74
|
+
if not package_path.exists() or secret_module_path is None:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
cache_key = cls._cache_key(package_path, secret_module_path)
|
|
78
|
+
with cls._lock:
|
|
79
|
+
if cache_key in cls._encrypted_secrets_cache:
|
|
80
|
+
return cls._encrypted_secrets_cache[cache_key]
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
extracted_root, encrypted_payload = cls._extract_bundle(package_path, secret_module_path)
|
|
84
|
+
except Exception:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
cls._extracted_cache[cache_key] = extracted_root
|
|
88
|
+
cls._encrypted_secrets_cache[cache_key] = encrypted_payload
|
|
89
|
+
return encrypted_payload
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _find_runtime_secret_module(protected_dir: Path) -> Path | None:
|
|
93
|
+
return find_runtime_secret_module(protected_dir)
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _cache_key(package_path: Path, secret_module_path: Path) -> str:
|
|
97
|
+
stat_a = package_path.stat()
|
|
98
|
+
stat_b = secret_module_path.stat()
|
|
99
|
+
return (
|
|
100
|
+
f"{package_path}:{stat_a.st_mtime_ns}:{stat_a.st_size}:"
|
|
101
|
+
f"{secret_module_path}:{stat_b.st_mtime_ns}:{stat_b.st_size}"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def _extract_bundle(cls, package_path: Path, secret_module_path: Path) -> tuple[Path, bytes | None]:
|
|
106
|
+
zip_bytes = cls._decrypt_package_to_zip_bytes(package_path, secret_module_path)
|
|
107
|
+
encrypted_secrets_payload = cls._read_embedded_secrets_payload(zip_bytes)
|
|
108
|
+
extract_root = Path(tempfile.mkdtemp(prefix="velto_protected_"))
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as archive:
|
|
112
|
+
for member in archive.infolist():
|
|
113
|
+
normalized_name = member.filename.strip("/")
|
|
114
|
+
if not normalized_name or member.is_dir():
|
|
115
|
+
continue
|
|
116
|
+
if normalized_name == _SECRETS_ARCHIVE_PATH:
|
|
117
|
+
continue
|
|
118
|
+
if normalized_name.startswith(".velto/"):
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
relative_path = Path(normalized_name)
|
|
122
|
+
if (
|
|
123
|
+
"\\" in normalized_name
|
|
124
|
+
or relative_path.is_absolute()
|
|
125
|
+
or ".." in relative_path.parts
|
|
126
|
+
or not relative_path.parts
|
|
127
|
+
or relative_path.parts[0] not in {"settings", "resources"}
|
|
128
|
+
):
|
|
129
|
+
raise ValueError("Protected resources package contains an invalid path")
|
|
130
|
+
|
|
131
|
+
destination = extract_root / relative_path
|
|
132
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
with archive.open(member) as source, destination.open("wb") as target:
|
|
134
|
+
shutil.copyfileobj(source, target)
|
|
135
|
+
except Exception:
|
|
136
|
+
shutil.rmtree(extract_root, ignore_errors=True)
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
atexit.register(shutil.rmtree, extract_root, ignore_errors=True)
|
|
140
|
+
return extract_root, encrypted_secrets_payload
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def _decrypt_package_to_zip_bytes(cls, package_path: Path, secret_module_path: Path) -> bytes:
|
|
144
|
+
payload = package_path.read_bytes()
|
|
145
|
+
runtime_secret = cls._read_runtime_secret(secret_module_path)
|
|
146
|
+
key, ciphertext, nonce, tag = cls._parse_payload(payload, runtime_secret)
|
|
147
|
+
|
|
148
|
+
expected_tag = hmac.new(key, nonce + ciphertext, hashlib.sha256).digest()
|
|
149
|
+
if not hmac.compare_digest(tag, expected_tag):
|
|
150
|
+
raise ValueError("Protected resources signature mismatch")
|
|
151
|
+
|
|
152
|
+
return cls._xor_keystream(ciphertext, key=key, nonce=nonce)
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _read_embedded_secrets_payload(zip_bytes: bytes) -> bytes | None:
|
|
156
|
+
with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as archive:
|
|
157
|
+
try:
|
|
158
|
+
return archive.read(_SECRETS_ARCHIVE_PATH)
|
|
159
|
+
except KeyError:
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def _parse_payload(cls, payload: bytes, runtime_secret: bytes) -> tuple[bytes, bytes, bytes, bytes]:
|
|
164
|
+
min_len = len(_MAGIC) + _SALT_LEN + _KEY_LEN + _NONCE_LEN + _TAG_LEN
|
|
165
|
+
if len(payload) <= min_len or not payload.startswith(_MAGIC):
|
|
166
|
+
raise ValueError("Invalid protected resources package format")
|
|
167
|
+
|
|
168
|
+
cursor = len(_MAGIC)
|
|
169
|
+
salt = payload[cursor:cursor + _SALT_LEN]
|
|
170
|
+
cursor += _SALT_LEN
|
|
171
|
+
|
|
172
|
+
wrapped_key = payload[cursor:cursor + _KEY_LEN]
|
|
173
|
+
cursor += _KEY_LEN
|
|
174
|
+
|
|
175
|
+
nonce = payload[cursor:cursor + _NONCE_LEN]
|
|
176
|
+
cursor += _NONCE_LEN
|
|
177
|
+
|
|
178
|
+
tag = payload[cursor:cursor + _TAG_LEN]
|
|
179
|
+
cursor += _TAG_LEN
|
|
180
|
+
|
|
181
|
+
ciphertext = payload[cursor:]
|
|
182
|
+
key = cls._unwrap_key(wrapped_key=wrapped_key, salt=salt, runtime_secret=runtime_secret)
|
|
183
|
+
return key, ciphertext, nonce, tag
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def _unwrap_key(cls, *, wrapped_key: bytes, salt: bytes, runtime_secret: bytes) -> bytes:
|
|
187
|
+
mask = cls._keystream(seed=cls._master_seed(salt, runtime_secret), size=len(wrapped_key))
|
|
188
|
+
return bytes(value ^ mask[i] for i, value in enumerate(wrapped_key))
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def _master_seed(salt: bytes, runtime_secret: bytes) -> bytes:
|
|
192
|
+
return hashlib.sha256(runtime_secret + salt).digest()
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def _keystream(cls, *, seed: bytes, size: int) -> bytes:
|
|
196
|
+
blocks: list[bytes] = []
|
|
197
|
+
index = 0
|
|
198
|
+
total = 0
|
|
199
|
+
while total < size:
|
|
200
|
+
block = hashlib.sha256(seed + index.to_bytes(8, "big")).digest()
|
|
201
|
+
blocks.append(block)
|
|
202
|
+
total += len(block)
|
|
203
|
+
index += 1
|
|
204
|
+
return b"".join(blocks)[:size]
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def _read_runtime_secret(secret_module_path: Path) -> bytes:
|
|
208
|
+
return read_runtime_secret(secret_module_path)
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _xor_keystream(data: bytes, *, key: bytes, nonce: bytes) -> bytes:
|
|
212
|
+
out = bytearray(len(data))
|
|
213
|
+
block_index = 0
|
|
214
|
+
offset = 0
|
|
215
|
+
|
|
216
|
+
while offset < len(data):
|
|
217
|
+
block_seed = nonce + block_index.to_bytes(8, "big")
|
|
218
|
+
stream = hashlib.sha256(key + block_seed).digest()
|
|
219
|
+
chunk = data[offset:offset + len(stream)]
|
|
220
|
+
|
|
221
|
+
for i, value in enumerate(chunk):
|
|
222
|
+
out[offset + i] = value ^ stream[i]
|
|
223
|
+
|
|
224
|
+
offset += len(chunk)
|
|
225
|
+
block_index += 1
|
|
226
|
+
|
|
227
|
+
return bytes(out)
|