toolframe-backup 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.
@@ -0,0 +1,220 @@
1
+ """Scoped Backup catalog, Dashboard and read-only MCP declarations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from toolframe_sdk import (
6
+ AuthorizationSpec,
7
+ MethodSpec,
8
+ PluginDefinition,
9
+ Resource,
10
+ ScopeSpec,
11
+ ServiceSpec,
12
+ )
13
+
14
+ SERVICE_NAME = "backup.catalog"
15
+ _ID = {"type": "string", "pattern": "^[0-9a-f]{32}$"}
16
+ _HASH = {"type": "string", "pattern": "^[0-9a-f]{64}$"}
17
+ _SUMMARY_PROPERTIES = {
18
+ "id": _ID,
19
+ "created_at": {"type": "string"},
20
+ "application_id": {"type": "string"},
21
+ "application_name": {"type": "string"},
22
+ "composition_revision": {"type": ["string", "null"]},
23
+ "file_count": {"type": "integer", "minimum": 0},
24
+ "total_bytes": {"type": "integer", "minimum": 0},
25
+ "archive_sha256": _HASH,
26
+ "verified": {"const": True},
27
+ "last_verified_at": {"type": "string"},
28
+ }
29
+ SUMMARY_SCHEMA = {
30
+ "type": "object",
31
+ "properties": _SUMMARY_PROPERTIES,
32
+ "required": list(_SUMMARY_PROPERTIES),
33
+ "additionalProperties": False,
34
+ }
35
+
36
+
37
+ def _input(properties: dict, required: tuple[str, ...] = ()) -> dict:
38
+ return {
39
+ "type": "object",
40
+ "properties": properties,
41
+ "required": list(required),
42
+ "additionalProperties": False,
43
+ }
44
+
45
+
46
+ LIST_INPUT = _input(
47
+ {"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50}}
48
+ )
49
+ _LIST_PROPERTIES = {
50
+ "backups": {"type": "array", "items": SUMMARY_SCHEMA, "maxItems": 100},
51
+ "count": {"type": "integer", "minimum": 0, "maximum": 100},
52
+ "verified_count": {"type": "integer", "minimum": 0, "maximum": 1000},
53
+ "rejected_count": {"type": "integer", "minimum": 0, "maximum": 1000},
54
+ "rejected": {
55
+ "type": "array",
56
+ "maxItems": 100,
57
+ "items": {
58
+ "type": "object",
59
+ "properties": {"id": _ID, "error_code": {"type": "string"}},
60
+ "required": ["id", "error_code"],
61
+ "additionalProperties": False,
62
+ },
63
+ },
64
+ "limit": {"type": "integer", "minimum": 1, "maximum": 100},
65
+ "destination": {"type": "string"},
66
+ "destination_kind": {"const": "local"},
67
+ "retention_keep": {"type": "integer", "minimum": 1, "maximum": 1000},
68
+ "status": {"enum": ["ready", "destination_missing"]},
69
+ "guidance": {"type": "string"},
70
+ }
71
+ LIST_OUTPUT = {
72
+ "type": "object",
73
+ "properties": _LIST_PROPERTIES,
74
+ "required": list(_LIST_PROPERTIES),
75
+ "additionalProperties": False,
76
+ }
77
+ _PREVIEW_PROPERTIES = {
78
+ "plan_token": _HASH,
79
+ "expires_at": {"type": "string"},
80
+ "retention_keep": {"type": "integer", "minimum": 1, "maximum": 1000},
81
+ "eligible_count": {"type": "integer", "minimum": 0, "maximum": 1000},
82
+ "delete_count": {"type": "integer", "minimum": 0, "maximum": 100},
83
+ "delete": {"type": "array", "items": SUMMARY_SCHEMA, "maxItems": 100},
84
+ "remaining_count": {"type": "integer", "minimum": 0, "maximum": 1000},
85
+ "max_delete": {"type": "integer", "minimum": 1, "maximum": 100},
86
+ "preserved_rejected_count": {"type": "integer", "minimum": 0, "maximum": 1000},
87
+ }
88
+ PREVIEW_OUTPUT = {
89
+ "type": "object",
90
+ "properties": _PREVIEW_PROPERTIES,
91
+ "required": list(_PREVIEW_PROPERTIES),
92
+ "additionalProperties": False,
93
+ }
94
+
95
+ plugin = PluginDefinition(
96
+ id="backup",
97
+ factory="toolframe_backup.service:BackupPlugin",
98
+ authorization=AuthorizationSpec(
99
+ scopes={
100
+ "backup.read": ScopeSpec(title="Read the local Backup catalog"),
101
+ }
102
+ ),
103
+ config_schema={
104
+ "type": "object",
105
+ "properties": {
106
+ "destination": {
107
+ "type": "string",
108
+ "minLength": 1,
109
+ "description": "Operator-configured absolute local directory with mode 0700.",
110
+ },
111
+ "retention_keep": {
112
+ "type": "integer",
113
+ "minimum": 1,
114
+ "maximum": 1000,
115
+ "default": 10,
116
+ },
117
+ },
118
+ "required": ["destination"],
119
+ "additionalProperties": False,
120
+ },
121
+ services={
122
+ SERVICE_NAME: ServiceSpec(
123
+ methods={
124
+ "list_backups": MethodSpec(
125
+ audience="business",
126
+ required_scopes=("backup.read",),
127
+ description="List verified local recovery archives for this application. This does not snapshot a running application.",
128
+ input_schema=LIST_INPUT,
129
+ output_schema=LIST_OUTPUT,
130
+ read_only=True,
131
+ ),
132
+ "verify_backup": MethodSpec(
133
+ audience="system",
134
+ required_scopes=("system.plugins.manage",),
135
+ description="Recheck one configured archive's structure and checksums; hashes are not authentication signatures.",
136
+ input_schema=_input({"backup_id": _ID}, ("backup_id",)),
137
+ output_schema=SUMMARY_SCHEMA,
138
+ read_only=True,
139
+ ),
140
+ "retention_preview": MethodSpec(
141
+ audience="system",
142
+ required_scopes=("system.plugins.manage",),
143
+ description="Preview bounded deletion of this application's oldest verified archives without removing them.",
144
+ input_schema=_input(
145
+ {
146
+ "max_delete": {
147
+ "type": "integer",
148
+ "minimum": 1,
149
+ "maximum": 100,
150
+ "default": 100,
151
+ }
152
+ }
153
+ ),
154
+ output_schema=PREVIEW_OUTPUT,
155
+ read_only=True,
156
+ ),
157
+ "apply_retention": MethodSpec(
158
+ audience="system",
159
+ required_scopes=("system.plugins.manage",),
160
+ description="Apply an unexpired unchanged retention preview after re-verification; unknown files are preserved.",
161
+ input_schema=_input({"plan_token": _HASH}, ("plan_token",)),
162
+ output_schema={
163
+ "type": "object",
164
+ "properties": {
165
+ "deleted_ids": {
166
+ "type": "array",
167
+ "items": _ID,
168
+ "maxItems": 100,
169
+ },
170
+ "deleted_count": {
171
+ "type": "integer",
172
+ "minimum": 0,
173
+ "maximum": 100,
174
+ },
175
+ },
176
+ "required": ["deleted_ids", "deleted_count"],
177
+ "additionalProperties": False,
178
+ },
179
+ ),
180
+ }
181
+ )
182
+ },
183
+ resources=(
184
+ Resource(
185
+ id="main",
186
+ kind="dashboard.page",
187
+ title="Backups",
188
+ required_scopes=("backup.read",),
189
+ data={"entrypoint": "toolframe_backup.page:render", "order": 40},
190
+ ),
191
+ Resource(
192
+ id="list_backups",
193
+ kind="mcp.tool",
194
+ title="List backups",
195
+ required_scopes=("backup.read",),
196
+ data={
197
+ "service": SERVICE_NAME,
198
+ "method": "list_backups",
199
+ "description": "Read the verified recovery archive catalog. Creating or restoring backups requires the stopped-application operator CLI.",
200
+ "input_schema": LIST_INPUT,
201
+ "output_schema": LIST_OUTPUT,
202
+ },
203
+ ),
204
+ Resource(
205
+ id="usage",
206
+ kind="llms.document",
207
+ title="Backup catalog and recovery workflow",
208
+ data={"package": "toolframe_backup", "path": "docs/usage.md"},
209
+ ),
210
+ ),
211
+ )
212
+
213
+ __all__ = [
214
+ "LIST_INPUT",
215
+ "LIST_OUTPUT",
216
+ "PREVIEW_OUTPUT",
217
+ "SERVICE_NAME",
218
+ "SUMMARY_SCHEMA",
219
+ "plugin",
220
+ ]
@@ -0,0 +1,105 @@
1
+ """Public SDK runtime facade for a local recovery archive catalog."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ from toolframe_sdk import Plugin, PluginError
9
+
10
+ from .archive import BackupError
11
+ from .catalog import BackupCatalog
12
+
13
+ SERVICE_NAME = "backup.catalog"
14
+ _PARAMETERS = {
15
+ "list_backups": {"limit"},
16
+ "verify_backup": {"backup_id"},
17
+ "retention_preview": {"max_delete"},
18
+ "apply_retention": {"plan_token"},
19
+ }
20
+
21
+
22
+ class BackupPlugin(Plugin):
23
+ """Inspect archives only; application snapshots belong to the stopped CLI."""
24
+
25
+ def __init__(self, context):
26
+ super().__init__(context)
27
+ self.catalog: BackupCatalog | None = None
28
+
29
+ async def activate(self):
30
+ try:
31
+ self.catalog = await asyncio.to_thread(
32
+ BackupCatalog,
33
+ self.context.config.get("destination"),
34
+ retention_keep=self.context.config.get("retention_keep", 10),
35
+ )
36
+ except BackupError as error:
37
+ raise PluginError(error.code, error.message) from None
38
+
39
+ def _catalog(self) -> BackupCatalog:
40
+ if self.catalog is None:
41
+ raise PluginError(
42
+ "backup_not_ready", "Backup catalog has not been activated."
43
+ )
44
+ return self.catalog
45
+
46
+ def _application_id(self) -> str | None:
47
+ value = self.context.call_info.get("application_id")
48
+ return value if isinstance(value, str) and value else None
49
+
50
+ async def _run(self, function, *args, **kwargs):
51
+ try:
52
+ return await asyncio.to_thread(function, *args, **kwargs)
53
+ except BackupError as error:
54
+ raise PluginError(error.code, error.message) from None
55
+
56
+ async def list_backups(self, limit: int = 50) -> dict[str, Any]:
57
+ return await self._run(
58
+ self._catalog().list_backups,
59
+ limit,
60
+ application_id=self._application_id(),
61
+ )
62
+
63
+ async def verify_backup(self, backup_id: str) -> dict[str, Any]:
64
+ return await self._run(
65
+ self._catalog().verify,
66
+ backup_id,
67
+ application_id=self._application_id(),
68
+ )
69
+
70
+ async def retention_preview(self, max_delete: int = 100) -> dict[str, Any]:
71
+ return await self._run(
72
+ self._catalog().retention_preview,
73
+ max_delete,
74
+ application_id=self._application_id(),
75
+ )
76
+
77
+ async def apply_retention(self, plan_token: str) -> dict[str, Any]:
78
+ return await self._run(
79
+ self._catalog().apply_retention,
80
+ plan_token,
81
+ application_id=self._application_id(),
82
+ )
83
+
84
+ async def health(self):
85
+ if self.catalog is None:
86
+ return {"runtime": "not_ready", "business": "not_ready"}
87
+ await self._run(self.catalog._directory)
88
+ return {"runtime": "ready", "business": "ready"}
89
+
90
+ async def invoke(self, service: str, method: str, params: dict) -> Any:
91
+ if service != SERVICE_NAME or method not in _PARAMETERS:
92
+ raise PluginError("method_not_found", "Unknown Backup service method.")
93
+ if not isinstance(params, dict) or set(params) - _PARAMETERS[method]:
94
+ raise PluginError(
95
+ "backup_validation", "Backup method received unsupported parameters."
96
+ )
97
+ required = {"verify_backup": "backup_id", "apply_retention": "plan_token"}.get(
98
+ method
99
+ )
100
+ if required is not None and required not in params:
101
+ raise PluginError("backup_validation", f"{required} is required.")
102
+ return await getattr(self, method)(**params)
103
+
104
+
105
+ __all__ = ["SERVICE_NAME", "BackupPlugin"]
@@ -0,0 +1,178 @@
1
+ """Bound a ZIP central directory before the standard library allocates its index.
2
+
3
+ Record layouts follow PKWARE APPNOTE sections 4.3.12 and 4.3.14–4.3.16:
4
+ https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT
5
+ This is the recovery archive's single-disk profile, not a general ZIP parser.
6
+ Payload names, member types and checksums remain the archive verifier's job.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import struct
13
+ from typing import BinaryIO
14
+
15
+ _ERROR = "ZIP directory is invalid or exceeds supported bounds."
16
+ _EOCD = struct.Struct("<4s4H2LH")
17
+ _LOCATOR = struct.Struct("<4sLQL")
18
+ _ZIP64 = struct.Struct("<4sQ2H2L4Q")
19
+ _CENTRAL = struct.Struct("<4s6H3I5H2I")
20
+ _TAIL_BYTES = _EOCD.size + 65535
21
+ _ZIP64_BYTES = 1024
22
+ _NAME_BYTES = 4096
23
+ _SHORT_MAX = 0xFFFF
24
+ _LONG_MAX = 0xFFFFFFFF
25
+
26
+
27
+ def _reject() -> None:
28
+ raise ValueError(_ERROR)
29
+
30
+
31
+ def _read_at(stream: BinaryIO, offset: int, size: int) -> bytes:
32
+ if offset < 0:
33
+ _reject()
34
+ stream.seek(offset)
35
+ value = stream.read(size)
36
+ if len(value) != size:
37
+ _reject()
38
+ return value
39
+
40
+
41
+ def _directory(stream: BinaryIO, end: tuple, end_offset: int) -> tuple[int, int, int]:
42
+ _, disk, directory_disk, disk_count, count, size, offset, _ = end
43
+ requires_zip64 = (
44
+ disk == _SHORT_MAX
45
+ or directory_disk == _SHORT_MAX
46
+ or disk_count == _SHORT_MAX
47
+ or count == _SHORT_MAX
48
+ or size == _LONG_MAX
49
+ or offset == _LONG_MAX
50
+ )
51
+ locator_offset = end_offset - _LOCATOR.size
52
+ locator = (
53
+ _read_at(stream, locator_offset, _LOCATOR.size) if locator_offset >= 0 else b""
54
+ )
55
+ if not locator.startswith(b"PK\x06\x07"):
56
+ if requires_zip64 or disk != 0 or directory_disk != 0 or disk_count != count:
57
+ _reject()
58
+ directory_end = end_offset
59
+ else:
60
+ _, locator_disk, record_offset, total_disks = _LOCATOR.unpack(locator)
61
+ if (
62
+ locator_disk != 0
63
+ or total_disks != 1
64
+ or record_offset + _ZIP64.size > locator_offset
65
+ ):
66
+ _reject()
67
+ record = _ZIP64.unpack(_read_at(stream, record_offset, _ZIP64.size))
68
+ (
69
+ signature,
70
+ record_size,
71
+ _,
72
+ _,
73
+ disk64,
74
+ directory_disk64,
75
+ disk_count64,
76
+ count64,
77
+ size64,
78
+ offset64,
79
+ ) = record
80
+ if (
81
+ signature != b"PK\x06\x06"
82
+ or record_size < _ZIP64.size - 12
83
+ or record_size + 12 > _ZIP64_BYTES
84
+ or record_offset + record_size + 12 != locator_offset
85
+ or disk64 != 0
86
+ or directory_disk64 != 0
87
+ or disk_count64 != count64
88
+ ):
89
+ _reject()
90
+ for classic, extended, sentinel in (
91
+ (disk, disk64, _SHORT_MAX),
92
+ (directory_disk, directory_disk64, _SHORT_MAX),
93
+ (disk_count, disk_count64, _SHORT_MAX),
94
+ (count, count64, _SHORT_MAX),
95
+ (size, size64, _LONG_MAX),
96
+ (offset, offset64, _LONG_MAX),
97
+ ):
98
+ if classic != sentinel and classic != extended:
99
+ _reject()
100
+ count, size, offset = count64, size64, offset64
101
+ directory_end = record_offset
102
+ if offset + size != directory_end:
103
+ _reject()
104
+ return offset, size, count
105
+
106
+
107
+ def _check(stream: BinaryIO, max_members: int, max_directory_bytes: int) -> None:
108
+ stream.seek(0, os.SEEK_END)
109
+ file_size = stream.tell()
110
+ if file_size < _EOCD.size:
111
+ _reject()
112
+ tail_size = min(file_size, _TAIL_BYTES)
113
+ tail_offset = file_size - tail_size
114
+ tail = _read_at(stream, tail_offset, tail_size)
115
+ relative_end = tail.rfind(b"PK\x05\x06")
116
+ if relative_end < 0 or relative_end + _EOCD.size > tail_size:
117
+ _reject()
118
+ end = _EOCD.unpack_from(tail, relative_end)
119
+ if relative_end + _EOCD.size + end[-1] != tail_size:
120
+ _reject()
121
+ offset, size, count = _directory(stream, end, tail_offset + relative_end)
122
+ if (
123
+ count > max_members
124
+ or size > max_directory_bytes
125
+ or count * _CENTRAL.size > size
126
+ ):
127
+ _reject()
128
+ central_end = offset + size
129
+ position, actual = offset, 0
130
+ while position < central_end:
131
+ if (
132
+ position + _CENTRAL.size > central_end
133
+ or actual >= max_members
134
+ or actual >= count
135
+ ):
136
+ _reject()
137
+ header = _CENTRAL.unpack(_read_at(stream, position, _CENTRAL.size))
138
+ name_size, extra_size, comment_size, disk = header[10:14]
139
+ if header[0] != b"PK\x01\x02" or disk != 0 or not 1 <= name_size <= _NAME_BYTES:
140
+ _reject()
141
+ next_position = position + _CENTRAL.size + name_size + extra_size + comment_size
142
+ if next_position > central_end:
143
+ _reject()
144
+ position = next_position
145
+ actual += 1
146
+ if position != central_end or actual != count:
147
+ _reject()
148
+
149
+
150
+ def check_zip_directory(
151
+ stream: BinaryIO, *, max_members: int, max_directory_bytes: int
152
+ ) -> None:
153
+ """Check directory bounds with fixed-size reads and preserve stream position.
154
+
155
+ ZIP64 records are supported; split archives, unrecognized directory records,
156
+ trailing data and inconsistent declarations are rejected. Every invalid
157
+ header or bound uses one fixed error string, without archive metadata.
158
+ """
159
+ if (
160
+ type(max_members) is not int
161
+ or type(max_directory_bytes) is not int
162
+ or max_members < 0
163
+ or max_directory_bytes < 0
164
+ ):
165
+ _reject()
166
+ try:
167
+ original = stream.tell()
168
+ except OSError, ValueError:
169
+ raise ValueError(_ERROR) from None
170
+ try:
171
+ _check(stream, max_members, max_directory_bytes)
172
+ except OSError, ValueError, struct.error, OverflowError:
173
+ raise ValueError(_ERROR) from None
174
+ finally:
175
+ try:
176
+ stream.seek(original)
177
+ except OSError, ValueError:
178
+ raise ValueError(_ERROR) from None
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.5
2
+ Name: toolframe-backup
3
+ Version: 0.1.0
4
+ Summary: Stopped-application recovery archives and a local Backup catalog for Toolframe
5
+ Project-URL: Homepage, https://github.com/toolframe/toolframe-backup
6
+ Project-URL: Repository, https://github.com/toolframe/toolframe-backup
7
+ Project-URL: Documentation, https://github.com/toolframe/toolframe-backup#readme
8
+ Project-URL: Issues, https://github.com/toolframe/toolframe-backup/issues
9
+ Author-email: byfb <62798989+byfb@users.noreply.github.com>
10
+ License-Expression: Apache-2.0
11
+ License-File: LICENSE
12
+ License-File: NOTICE
13
+ Requires-Python: >=3.14
14
+ Requires-Dist: packaging>=25
15
+ Requires-Dist: tomlkit<1,>=0.13
16
+ Requires-Dist: toolframe-sdk==0.3.0
17
+ Provides-Extra: operator
18
+ Requires-Dist: toolframe-core<0.7,>=0.6; extra == 'operator'
19
+ Provides-Extra: ui
20
+ Requires-Dist: streamlit==1.61.0; extra == 'ui'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # Toolframe Backup
24
+
25
+ Toolframe Backup creates recovery archives of cleanly stopped Toolframe applications
26
+ and provides a Dashboard and read-only MCP catalog for a configured local backup
27
+ directory. Its standalone CLI can verify or restore archives even when the source
28
+ application cannot start. The ordinary plugin never snapshots a running application.
29
+
30
+ ## Quick start
31
+
32
+ Use Python 3.14 or newer. Archive creation
33
+ requires a Core release providing `toolframe_core.maintenance.stopped_application`.
34
+ Restoring recorded environments also requires its public `application_lock` helper.
35
+ Install the operator extra from PyPI:
36
+
37
+ ```sh
38
+ uv venv --python 3.14
39
+ uv pip install 'toolframe-backup[operator]==0.1.0'
40
+ # Stop the application's host and workers cleanly before creating the archive.
41
+ .venv/bin/toolframe-backup create --home ./app --directory ./backups
42
+ .venv/bin/toolframe-backup list --directory ./backups
43
+ .venv/bin/toolframe-backup verify ./backups/backup-<id>.zip
44
+ .venv/bin/toolframe-backup restore ./backups/backup-<id>.zip --destination ./recovered-app
45
+ # Optional local-only reconstruction (requires every exact-hash dependency locally):
46
+ .venv/bin/toolframe-backup restore ./backups/backup-<id>.zip --destination ./offline-recovered-app --offline --wheelhouse ./wheelhouse
47
+ ```
48
+
49
+ The CLI prints nonsecret JSON summaries; errors use exit code 2 and interruption
50
+ uses 130. New backup directories and restored directories have mode 0700. Archive
51
+ files and restored payload files have mode 0600; uv sets reconstructed environment
52
+ file modes. Existing backup directories must already have
53
+ mode 0700. Restore requires a new destination with an existing parent directory;
54
+ it never replaces an existing home, including an empty directory.
55
+ Supported restore paths include spaces and Unicode. Paths containing quotes,
56
+ backslashes or control characters are rejected before staging.
57
+ File URI wheel locators encode spaces, Unicode and URI delimiters such as `#`, `?`
58
+ and `%`; direct wheel paths retain their filesystem spelling.
59
+
60
+ To install the catalog in an existing application, declare the plugin and its
61
+ required destination together in `app/toolframe.toml`. Core resolves the package
62
+ from PyPI; replace the destination with your operator-owned absolute backup path:
63
+
64
+ ```toml
65
+ [plugins.backup]
66
+ source = "pypi:toolframe-backup==0.1.0"
67
+ enabled = true
68
+
69
+ [plugins.backup.config]
70
+ destination = "/srv/toolframe-backups"
71
+ retention_keep = 10
72
+ ```
73
+
74
+ ```sh
75
+ toolframe-core --home ./app plan
76
+ toolframe-core --home ./app apply --plan-id <plan-id-from-the-plan-output>
77
+ ```
78
+
79
+ An installed Dashboard or MCP provider consumes the plugin's ordinary resources.
80
+ Grant `backup.read` to catalog readers. Verification and retention actions require
81
+ the built-in `system.plugins.manage` (Manage plugins) operator permission.
82
+ See the [catalog guide](src/toolframe_backup/docs/usage.md) for its service methods,
83
+ verification records and explicit retention workflow.
84
+
85
+ ## Support limits
86
+
87
+ - Version 1 supports local in-home files and local SQLite stores accepted by Core's
88
+ stopped maintenance inventory. Live or uncertain workers, pending recovery,
89
+ external SQL and external attachments are rejected by Core. S3 and other remote
90
+ archive destinations are not implemented.
91
+ - Archives are **unencrypted** and contain private keys, configuration and database
92
+ contents in their payloads. Store them in an operator-controlled local directory.
93
+ The public manifest contains identity, hashes, store ownership, retained-key
94
+ requirements and dependency prerequisites, without credential URLs or key bytes.
95
+ - SHA-256 checks detect corruption. They are not signatures and do not authenticate
96
+ an archive against an attacker who can replace both payload and manifest.
97
+ - Offline means the source host is stopped and the CLI needs no running application.
98
+ Restore reconstructs omitted backend/UI environments with uv from protected original
99
+ exact pylocks before reporting `ready: true`. It uses the final absolute directory
100
+ under Core's public application lock so console-script shebangs remain valid. Missing
101
+ dependency wheels require exact-hash wheels from a supplied wheelhouse or network;
102
+ an air-gapped restart is not guaranteed. uv performs locked dependency acquisition;
103
+ the CLI has no independent downloader. `--offline` forbids network acquisition and
104
+ `--wheelhouse` supplies local candidate wheels. Recovery requires the original Python
105
+ implementation, OS and machine architecture. A recorded two-component Python
106
+ version requires the same major/minor; a recorded three-component version requires
107
+ the exact patch too. The complete operator interpreter is checked against
108
+ Requires-Python, environment markers and wheel tags before publication. Reconstruction
109
+ requires compatible exact-hash wheels and supports at most 100 distinct environments;
110
+ source builds are disabled. Subprocess output is suppressed to protect credentials.
111
+ A failed reconstruction removes only the new destination created by that restore.
112
+ - A recovered clone retains the application ID and composition. Do not concurrently
113
+ run it with another copy using the same external identity. External state is outside
114
+ the supported recovery scope.
115
+ - Atomic restoration is supported on macOS and Linux. Default limits are 10,000
116
+ files, 512 MiB per payload file, 2 GiB total payload, 4 MiB manifest, 3 GiB archive,
117
+ 16 MiB protected environment payload and a maximum decompression ratio of 200.
118
+ ZIP central-directory size/count are checked before indexing. The catalog scans at
119
+ most 1,000 archives.
120
+
121
+ See the [archive architecture](docs/architecture.md) and
122
+ [offline recovery decision](docs/adr/0001-stopped-recovery-and-directory-catalog.md).
123
+
124
+ ## Development
125
+
126
+ Place the immutable matching Toolframe wheel set, including SDK, Core and their
127
+ prerequisites, in `build/wheelhouse/` before syncing. The Core wheel must provide the public
128
+ maintenance helpers used above. This library omits a committed `uv.lock` and
129
+ sibling source pins; development resolves dependencies from supplied or published
130
+ wheels.
131
+
132
+ ```sh
133
+ uv sync --no-sources --find-links ./build/wheelhouse --extra operator --extra ui --group dev
134
+ uv run --no-sync pytest
135
+ uv run --no-sync ruff check .
136
+ uv run --no-sync ruff format --check .
137
+ uv build
138
+ ```
139
+
140
+ Package tests cover archive boundaries with mocked Core inventories and guards, and
141
+ include real offline uv reconstruction of local environments in temporary homes.
142
+ Verify stopped-state eligibility, the actual Core lock and restored application,
143
+ Dashboard and client flows separately through an installed application. Package tests
144
+ do not establish remote-store or air-gap support.
145
+
146
+ Licensed under [Apache-2.0](LICENSE).
147
+
148
+ For release validation and Trusted Publisher setup, see [releasing](docs/releasing.md).
@@ -0,0 +1,16 @@
1
+ toolframe_backup/__init__.py,sha256=hrwz_zbpIDhpryFAChszK__fp1mxXaGG9djecoY8EJM,86
2
+ toolframe_backup/archive.py,sha256=JdrqAlkdxkXPBJjf0DCjZOg84z7UCvSwgSXy4QKeA9k,34573
3
+ toolframe_backup/catalog.py,sha256=mcPrFzAva5ZzZpmJ47UHNnBD005olIVfVvLBi23BeD4,19969
4
+ toolframe_backup/cli.py,sha256=GnNAZavnjaxw-66ZJ9m7cNe-aLIhuRADKtUPlcGyOmI,2899
5
+ toolframe_backup/environments.py,sha256=UzFFVzH_BbbSO5fF54tvrKSzPQWN2SNLvmGBp8MsBEA,18437
6
+ toolframe_backup/page.py,sha256=BrGui6hEU-HKDvqzHQS49_1Br7eKHJTWDq6KdQ4pgQk,5867
7
+ toolframe_backup/plugin.py,sha256=Y2vH512wHRNt83TaL0rMR3DPV8sdVhlZmuRaSVAtvig,7982
8
+ toolframe_backup/service.py,sha256=mdfC2MguLPQJ_3bnr2rikSNVwhoBLJvBM5U3PAUBUjU,3660
9
+ toolframe_backup/zipguard.py,sha256=RDeHfaGpChgm2x_ccd3emRBY_7vqfY9g9Zll1rNQ-ZQ,5953
10
+ toolframe_backup/docs/usage.md,sha256=biQ6FjzQTvz4X9JxWz6Oeb1Z35-30PKPQwa7M_Z6zvE,5048
11
+ toolframe_backup-0.1.0.dist-info/METADATA,sha256=lH0aH4HTESAsL3-CQK3MkfRItkOtgpoVto5OyZ2Ud4g,7665
12
+ toolframe_backup-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
13
+ toolframe_backup-0.1.0.dist-info/entry_points.txt,sha256=0228ZdqwBLjQfriPzzCM-O5J4BUygRM3gSxqQzb29QM,124
14
+ toolframe_backup-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
15
+ toolframe_backup-0.1.0.dist-info/licenses/NOTICE,sha256=-dYs0ndUSVr_LbH0NzVNGZsplU2xvVrlDOA4PmjiR84,104
16
+ toolframe_backup-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ toolframe-backup = toolframe_backup.cli:main
3
+
4
+ [toolframe.plugins]
5
+ backup = toolframe_backup.plugin:plugin