@mriekki/project-forge 4.26.0 → 4.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/assets/components.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.26.
|
|
4
|
+
"version": "4.26.1",
|
|
5
5
|
"defaultProfile": "greenfield-project",
|
|
6
6
|
"profiles": {
|
|
7
7
|
"greenfield-project": {
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
},
|
|
14
14
|
"components": {
|
|
15
15
|
"core": {
|
|
16
|
-
"revision": "
|
|
17
|
-
"version": "4.3.
|
|
16
|
+
"revision": "670b84af788323ce361a49677aff772c0d1541c4",
|
|
17
|
+
"version": "4.3.1",
|
|
18
18
|
"distribution": "greenfield-workspace",
|
|
19
19
|
"descriptor": "assets/core/distribution.json",
|
|
20
20
|
"sourceRoot": "assets/generated/core",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.26.
|
|
4
|
+
"version": "4.26.1",
|
|
5
5
|
"components": {
|
|
6
6
|
"core": {
|
|
7
|
-
"revision": "
|
|
8
|
-
"version": "4.3.
|
|
7
|
+
"revision": "670b84af788323ce361a49677aff772c0d1541c4",
|
|
8
|
+
"version": "4.3.1",
|
|
9
9
|
"distribution": "greenfield-workspace",
|
|
10
10
|
"files": [
|
|
11
11
|
{
|
|
@@ -376,7 +376,7 @@
|
|
|
376
376
|
{
|
|
377
377
|
"source": "tools/setup_maintenance/maintenance.py",
|
|
378
378
|
"destination": "tools/project-forge-setup/maintenance.py",
|
|
379
|
-
"sha256": "
|
|
379
|
+
"sha256": "15292e1059bc017139a11692346234fe34d98e5745acdfff2197d1232ce57c34"
|
|
380
380
|
},
|
|
381
381
|
{
|
|
382
382
|
"source": "core/process/WORKSTATION_MAINTENANCE.md",
|
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
"""Project Forge catalog-bound workstation updates. Status/preview never mutate."""
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
import argparse
|
|
5
|
+
from contextlib import ExitStack
|
|
5
6
|
import datetime as dt
|
|
6
7
|
import hashlib
|
|
7
8
|
import json
|
|
9
|
+
import os
|
|
8
10
|
import re
|
|
11
|
+
import secrets
|
|
9
12
|
import shutil
|
|
13
|
+
import stat
|
|
10
14
|
import subprocess
|
|
11
15
|
import sys
|
|
16
|
+
import tempfile
|
|
12
17
|
from pathlib import Path
|
|
13
18
|
|
|
14
19
|
TOOL_ID = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
|
@@ -125,6 +130,160 @@ def make_plan(root: Path, selected: list[str]) -> dict:
|
|
|
125
130
|
return {"plan": plan, "sha256": digest}
|
|
126
131
|
|
|
127
132
|
|
|
133
|
+
def _marker_payload() -> str:
|
|
134
|
+
return json.dumps({"checkedAtUtc": dt.datetime.now(dt.timezone.utc).isoformat()}) + "\n"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _marker_read_value(handle) -> str | None:
|
|
138
|
+
value = json.load(handle)
|
|
139
|
+
if not isinstance(value, dict):
|
|
140
|
+
raise ValueError("expected object in setup maintenance marker")
|
|
141
|
+
return value.get("checkedAtUtc")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _posix_marker_check(root: Path, record: bool) -> str | None:
|
|
145
|
+
"""Use directory handles and O_NOFOLLOW throughout: no path-based write races."""
|
|
146
|
+
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
|
147
|
+
with ExitStack() as closing:
|
|
148
|
+
parent = os.open(root, flags)
|
|
149
|
+
closing.callback(os.close, parent)
|
|
150
|
+
for name in (".project-forge", "setup-maintenance"):
|
|
151
|
+
try:
|
|
152
|
+
child = os.open(name, flags, dir_fd=parent)
|
|
153
|
+
except FileNotFoundError:
|
|
154
|
+
if not record:
|
|
155
|
+
return None
|
|
156
|
+
try:
|
|
157
|
+
os.mkdir(name, 0o700, dir_fd=parent)
|
|
158
|
+
except FileExistsError:
|
|
159
|
+
pass
|
|
160
|
+
try:
|
|
161
|
+
child = os.open(name, flags, dir_fd=parent)
|
|
162
|
+
except OSError as error:
|
|
163
|
+
raise ValueError(
|
|
164
|
+
f"unsafe setup maintenance marker directory: {name} (symlink or non-directory)"
|
|
165
|
+
) from error
|
|
166
|
+
except OSError as error:
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f"unsafe setup maintenance marker directory: {name} (symlink or non-directory)"
|
|
169
|
+
) from error
|
|
170
|
+
closing.callback(os.close, child)
|
|
171
|
+
parent = child
|
|
172
|
+
|
|
173
|
+
name = "last-check.json"
|
|
174
|
+
try:
|
|
175
|
+
info = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
|
176
|
+
except FileNotFoundError:
|
|
177
|
+
info = None
|
|
178
|
+
if info is not None and not stat.S_ISREG(info.st_mode):
|
|
179
|
+
raise ValueError("unsafe setup maintenance marker file: symlink or non-regular file")
|
|
180
|
+
|
|
181
|
+
previous = None
|
|
182
|
+
if info is not None:
|
|
183
|
+
# Re-open through the pinned directory handle and check the final
|
|
184
|
+
# inode too, so a swapped symlink or special file cannot be read.
|
|
185
|
+
with os.fdopen(
|
|
186
|
+
os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent),
|
|
187
|
+
"r", encoding="utf-8"
|
|
188
|
+
) as handle:
|
|
189
|
+
if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
|
|
190
|
+
raise ValueError("unsafe setup maintenance marker file: non-regular file")
|
|
191
|
+
previous = _marker_read_value(handle)
|
|
192
|
+
|
|
193
|
+
if record:
|
|
194
|
+
# Atomic replacement instead of truncating the existing inode:
|
|
195
|
+
# neither a symlink nor a hard link can redirect the marker write.
|
|
196
|
+
temporary = f".last-check-{secrets.token_hex(12)}.tmp"
|
|
197
|
+
created = False
|
|
198
|
+
try:
|
|
199
|
+
fd = os.open(
|
|
200
|
+
temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
201
|
+
0o600, dir_fd=parent
|
|
202
|
+
)
|
|
203
|
+
created = True
|
|
204
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
205
|
+
handle.write(_marker_payload())
|
|
206
|
+
handle.flush()
|
|
207
|
+
os.fsync(handle.fileno())
|
|
208
|
+
os.replace(temporary, name, src_dir_fd=parent, dst_dir_fd=parent)
|
|
209
|
+
created = False
|
|
210
|
+
finally:
|
|
211
|
+
if created:
|
|
212
|
+
os.unlink(temporary, dir_fd=parent)
|
|
213
|
+
return previous
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _windows_marker_component(path: Path, *, directory: bool) -> os.stat_result | None:
|
|
217
|
+
"""Refuse symlinks, junctions and other Windows reparse points."""
|
|
218
|
+
try:
|
|
219
|
+
info = os.lstat(path)
|
|
220
|
+
except FileNotFoundError:
|
|
221
|
+
return None
|
|
222
|
+
if (
|
|
223
|
+
stat.S_ISLNK(info.st_mode)
|
|
224
|
+
or getattr(info, "st_file_attributes", 0)
|
|
225
|
+
& getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
|
226
|
+
or (directory and not stat.S_ISDIR(info.st_mode))
|
|
227
|
+
or (not directory and not stat.S_ISREG(info.st_mode))
|
|
228
|
+
):
|
|
229
|
+
raise ValueError(f"unsafe setup maintenance marker path: symlink, reparse point or unexpected type at {path}")
|
|
230
|
+
return info
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _windows_marker_check(root: Path, record: bool) -> str | None:
|
|
234
|
+
"""Conservative native Windows checks with an atomic final-file replacement.
|
|
235
|
+
|
|
236
|
+
Native Windows Python has no POSIX dir_fd/O_NOFOLLOW operations. Check each
|
|
237
|
+
component for reparse points, retain its identity, and recheck before the
|
|
238
|
+
final atomic replace. Never use write_text() on the destination.
|
|
239
|
+
"""
|
|
240
|
+
parent = root
|
|
241
|
+
identities = []
|
|
242
|
+
for name in (".project-forge", "setup-maintenance"):
|
|
243
|
+
parent = parent / name
|
|
244
|
+
info = _windows_marker_component(parent, directory=True)
|
|
245
|
+
if info is None:
|
|
246
|
+
if not record:
|
|
247
|
+
return None
|
|
248
|
+
try:
|
|
249
|
+
parent.mkdir(mode=0o700)
|
|
250
|
+
except FileExistsError:
|
|
251
|
+
pass
|
|
252
|
+
info = _windows_marker_component(parent, directory=True)
|
|
253
|
+
if info is None:
|
|
254
|
+
raise ValueError(f"unable to establish safe setup maintenance directory: {parent}")
|
|
255
|
+
identities.append((parent, info.st_dev, info.st_ino))
|
|
256
|
+
|
|
257
|
+
marker = parent / "last-check.json"
|
|
258
|
+
existing = _windows_marker_component(marker, directory=False)
|
|
259
|
+
previous = None
|
|
260
|
+
if existing is not None:
|
|
261
|
+
with marker.open("r", encoding="utf-8") as handle:
|
|
262
|
+
previous = _marker_read_value(handle)
|
|
263
|
+
if record:
|
|
264
|
+
fd, tmp = tempfile.mkstemp(prefix=".last-check-", suffix=".tmp", dir=parent)
|
|
265
|
+
try:
|
|
266
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
267
|
+
handle.write(_marker_payload())
|
|
268
|
+
handle.flush()
|
|
269
|
+
os.fsync(handle.fileno())
|
|
270
|
+
for directory, dev, ino in identities:
|
|
271
|
+
current = _windows_marker_component(directory, directory=True)
|
|
272
|
+
if current is None or (current.st_dev, current.st_ino) != (dev, ino):
|
|
273
|
+
raise ValueError(f"unsafe setup maintenance directory changed before marker replacement: {directory}")
|
|
274
|
+
_windows_marker_component(marker, directory=False)
|
|
275
|
+
os.replace(tmp, marker)
|
|
276
|
+
finally:
|
|
277
|
+
if os.path.lexists(tmp):
|
|
278
|
+
os.unlink(tmp)
|
|
279
|
+
return previous
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def marker_check(root: Path, *, record: bool) -> str | None:
|
|
283
|
+
"""Read and optionally store a check timestamp only in a safe checkout path."""
|
|
284
|
+
return _windows_marker_check(root, record) if os.name == "nt" else _posix_marker_check(root, record)
|
|
285
|
+
|
|
286
|
+
|
|
128
287
|
def main() -> int:
|
|
129
288
|
parser = argparse.ArgumentParser()
|
|
130
289
|
parser.add_argument("action", choices=["status", "update"])
|
|
@@ -144,12 +303,9 @@ def main() -> int:
|
|
|
144
303
|
try:
|
|
145
304
|
if args.action == "status":
|
|
146
305
|
result = {"platform": platform(), "tools": inventory(root)}
|
|
147
|
-
|
|
148
|
-
if
|
|
149
|
-
result["previousCheckUtc"] =
|
|
150
|
-
if args.record_check:
|
|
151
|
-
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
-
marker.write_text(json.dumps({"checkedAtUtc": dt.datetime.now(dt.timezone.utc).isoformat()}) + "\n", encoding="utf-8")
|
|
306
|
+
previous = marker_check(root, record=args.record_check)
|
|
307
|
+
if previous is not None:
|
|
308
|
+
result["previousCheckUtc"] = previous
|
|
153
309
|
print(json.dumps(result, indent=2))
|
|
154
310
|
return 0
|
|
155
311
|
result = make_plan(root, args.tool)
|