@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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.26.0",
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": "39bc7661db49266743317e9dfedab800674f9c30",
17
- "version": "4.3.0",
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.0",
4
+ "version": "4.26.1",
5
5
  "components": {
6
6
  "core": {
7
- "revision": "39bc7661db49266743317e9dfedab800674f9c30",
8
- "version": "4.3.0",
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": "55d22445834108d2fb7f2ac8aa4a5c5b0326c01f67e5f305b8284e4f15a0dcd8"
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
- marker = root / ".project-forge/setup-maintenance/last-check.json"
148
- if marker.is_file():
149
- result["previousCheckUtc"] = load(marker).get("checkedAtUtc")
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.26.0",
3
+ "version": "4.26.1",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {