@mriekki/lean 0.4.5 → 0.4.6

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": 1,
3
3
  "package": "@mriekki/lean",
4
- "version": "0.4.5",
4
+ "version": "0.4.6",
5
5
  "defaultProfile": "greenfield-poc",
6
6
  "profiles": {
7
7
  "greenfield-poc": {
@@ -14,7 +14,7 @@
14
14
  "components": {
15
15
  "core": {
16
16
  "repository": "mriekki/lean-agentic-core",
17
- "commit": "68f3f45e663cab535c5a9544cf4efe2f048f8f34",
17
+ "commit": "52905468c06fe39caa491a15a77e33cd400a60d9",
18
18
  "version": "1.13.0",
19
19
  "distribution": "greenfield-workspace",
20
20
  "descriptor": "assets/core/distribution.json",
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "package": "@mriekki/lean",
4
- "version": "0.4.5",
4
+ "version": "0.4.6",
5
5
  "components": {
6
6
  "core": {
7
7
  "repository": "mriekki/lean-agentic-core",
8
- "commit": "68f3f45e663cab535c5a9544cf4efe2f048f8f34",
8
+ "commit": "52905468c06fe39caa491a15a77e33cd400a60d9",
9
9
  "version": "1.13.0",
10
10
  "distribution": "greenfield-workspace",
11
11
  "files": [
@@ -192,7 +192,7 @@
192
192
  {
193
193
  "source": "core/process/PROJECT_DOCUMENTATION_FRAMEWORK.md",
194
194
  "destination": "docs/process/PROJECT_DOCUMENTATION_FRAMEWORK.md",
195
- "sha256": "e6697c8d9bc346e8c8229625fc422b869a6428a083e4fee717b97d17afde7b03"
195
+ "sha256": "8dc0773a245ed23089caa02a1a2f0dd5670710646d446d190406ae47a85da4c6"
196
196
  },
197
197
  {
198
198
  "source": "core/process/LEAN_ENGINEERING_GUIDE.md",
@@ -282,7 +282,7 @@
282
282
  {
283
283
  "source": "tools/docs_site/docs_site.py",
284
284
  "destination": "tools/lean-docs/docs-site.py",
285
- "sha256": "d8516175150b48b85b2a0b8013d5790b78bdd6eb6de73ab8dce9ed8e32e2dc68"
285
+ "sha256": "50ea30f5d42f71176a74a642123089c6e246f2da616e0c0bba2f16168d2c15c9"
286
286
  },
287
287
  {
288
288
  "source": "tools/package/check_baseline.py",
@@ -190,6 +190,8 @@ Build-only mode:
190
190
  python .\tools\lean-docs\docs-site.py build --root . --out .\.lean\docs-site
191
191
  ```
192
192
 
193
+ The builder protects existing data: it refuses the repository root, paths overlapping either lab cache, linked paths, and non-empty output directories without its `.lean-docs-site` ownership marker. Existing unmarked output from an older viewer must be inspected and moved or a new output path selected before rebuilding.
194
+
193
195
  The viewer should:
194
196
 
195
197
  - bind to loopback only;
@@ -13,13 +13,15 @@ import webbrowser
13
13
  from datetime import datetime, timezone
14
14
  from functools import partial
15
15
  from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
16
- from pathlib import Path
16
+ from pathlib import Path, PurePosixPath, PureWindowsPath
17
17
  from typing import Any
18
18
 
19
19
  IGNORE_DIRS = {".git", ".lean", "node_modules", "bin", "obj", "dist", "build", "target", ".venv", "venv", "__pycache__"}
20
20
  DEFAULT_CACHE = Path(".lean/docs-cache")
21
21
  DEFAULT_SITE = Path(".lean/docs-site")
22
22
  MANIFEST = Path("docs/lean-docs.json")
23
+ SITE_MARKER = ".lean-docs-site"
24
+ SITE_MARKER_CONTENT = "lean-docs-site\n"
23
25
 
24
26
 
25
27
  class DocsSiteError(RuntimeError):
@@ -86,17 +88,30 @@ def _manifest_documents(root: Path, manifest: dict[str, Any] | None) -> tuple[li
86
88
  if not isinstance(item, dict) or not isinstance(item.get("path"), str):
87
89
  warnings.append(f"docs/lean-docs.json documents[{index}] is invalid")
88
90
  continue
89
- rel = Path(item["path"]).as_posix().lstrip("/")
90
- if rel.startswith("../") or "/../" in rel:
91
+ raw_path = item["path"]
92
+ windows_path = PureWindowsPath(raw_path)
93
+ relative_path = PurePosixPath(raw_path.replace("\\", "/"))
94
+ if (
95
+ relative_path.is_absolute()
96
+ or windows_path.root
97
+ or windows_path.drive
98
+ or ".." in relative_path.parts
99
+ ):
91
100
  warnings.append(f"Skipped unsafe documentation path: {item['path']}")
92
101
  continue
102
+ rel = relative_path.as_posix()
93
103
  path = root / rel
104
+ try:
105
+ resolved_path = root / _safe_rel(root, path)
106
+ except DocsSiteError:
107
+ warnings.append(f"Skipped unsafe documentation path: {item['path']}")
108
+ continue
94
109
  seen.add(rel)
95
- if not path.is_file():
110
+ if not resolved_path.is_file():
96
111
  if item.get("optional") is not True:
97
112
  warnings.append(f"Listed documentation is not present: {rel}")
98
113
  continue
99
- content = path.read_text(encoding="utf-8", errors="replace")
114
+ content = resolved_path.read_text(encoding="utf-8", errors="replace")
100
115
  docs.append(
101
116
  {
102
117
  "path": rel,
@@ -116,14 +131,30 @@ def _manifest_documents(root: Path, manifest: dict[str, Any] | None) -> tuple[li
116
131
  candidates.append(path)
117
132
  docs_root = root / "docs"
118
133
  if docs_root.is_dir():
119
- for base, dirs, names in os.walk(docs_root):
120
- dirs[:] = [d for d in dirs if d not in IGNORE_DIRS]
121
- for name in names:
122
- if name.lower().endswith(".md"):
123
- candidates.append(Path(base) / name)
134
+ if _path_contains_link(docs_root):
135
+ warnings.append(f"Skipped unsafe documentation directory: {docs_root}")
136
+ else:
137
+ for base, dirs, names in os.walk(docs_root):
138
+ safe_dirs = []
139
+ for directory in dirs:
140
+ candidate = Path(base) / directory
141
+ if directory in IGNORE_DIRS:
142
+ continue
143
+ if _is_link(candidate):
144
+ warnings.append(f"Skipped unsafe documentation directory: {candidate}")
145
+ continue
146
+ safe_dirs.append(directory)
147
+ dirs[:] = safe_dirs
148
+ for name in names:
149
+ if name.lower().endswith(".md"):
150
+ candidates.append(Path(base) / name)
124
151
 
125
152
  for path in sorted(candidates):
126
- rel = _safe_rel(root, path)
153
+ try:
154
+ rel = _safe_rel(root, path)
155
+ except DocsSiteError:
156
+ warnings.append(f"Skipped unsafe documentation path: {path}")
157
+ continue
127
158
  if rel in seen:
128
159
  continue
129
160
  content = path.read_text(encoding="utf-8", errors="replace")
@@ -220,14 +251,113 @@ def _prepare_system_lab(root: Path) -> str | None:
220
251
  return None
221
252
 
222
253
 
223
- def _copy_lab_cache(root: Path, out: Path, kind: str) -> None:
254
+ def _validate_lab_cache(root: Path, kind: str) -> Path:
224
255
  source = root / DEFAULT_CACHE / kind
225
- if not source.is_dir():
226
- return
256
+ if _path_contains_link(source):
257
+ raise DocsSiteError(f"Lab cache contains a symlink or junction: {source}")
258
+ if source.exists():
259
+ try:
260
+ source.resolve().relative_to(root)
261
+ except ValueError as exc:
262
+ raise DocsSiteError(f"Lab cache escapes repository root: {source}") from exc
263
+ if source.is_dir() and _directory_contains_link(source):
264
+ raise DocsSiteError(f"Lab cache contains a symlink or junction: {source}")
265
+ return source
266
+
267
+
268
+ def _copy_lab_cache(root: Path, out: Path, kind: str) -> None:
269
+ source = _validate_lab_cache(root, kind)
227
270
  target = out / "labs" / kind
271
+ try:
272
+ target.resolve().relative_to(out)
273
+ except ValueError as exc:
274
+ raise DocsSiteError(f"Lab output escapes generated site directory: {target}") from exc
275
+ if _path_contains_link(target):
276
+ raise DocsSiteError(f"Lab output contains a symlink or junction: {target}")
228
277
  if target.exists():
278
+ if target.is_symlink() or not target.is_dir():
279
+ raise DocsSiteError(f"Unexpected lab output path: {target}")
229
280
  shutil.rmtree(target)
230
- shutil.copytree(source, target)
281
+ if source.is_dir():
282
+ shutil.copytree(source, target)
283
+
284
+
285
+ def _is_link(path: Path) -> bool:
286
+ if path.is_symlink():
287
+ return True
288
+ is_junction = getattr(path, "is_junction", None)
289
+ return bool(is_junction and is_junction())
290
+
291
+
292
+ def _path_contains_link(path: Path) -> bool:
293
+ absolute = Path(os.path.abspath(path))
294
+ current = Path(absolute.anchor)
295
+ for part in absolute.parts[1:]:
296
+ current /= part
297
+ if _is_link(current):
298
+ return True
299
+ return False
300
+
301
+
302
+ def _paths_overlap(left: Path, right: Path) -> bool:
303
+ left = Path(os.path.abspath(left))
304
+ right = Path(os.path.abspath(right))
305
+ return left == right or left in right.parents or right in left.parents
306
+
307
+
308
+ def _directory_contains_link(root: Path) -> bool:
309
+ directories = [root]
310
+ while directories:
311
+ current = directories.pop()
312
+ with os.scandir(current) as entries:
313
+ for entry in entries:
314
+ path = Path(entry.path)
315
+ if _is_link(path):
316
+ return True
317
+ if entry.is_dir(follow_symlinks=False):
318
+ directories.append(path)
319
+ return False
320
+
321
+
322
+ def _prepare_output(root: Path, out: Path) -> None:
323
+ if _path_contains_link(out):
324
+ raise DocsSiteError(f"Refusing to use an output path containing a symlink or junction: {out}")
325
+ if out == root or out in root.parents:
326
+ raise DocsSiteError(f"Refusing to use the repository root or one of its parents as output: {out}")
327
+ for kind in ("system", "database"):
328
+ cache = root / DEFAULT_CACHE / kind
329
+ if _paths_overlap(out, cache):
330
+ raise DocsSiteError(f"Output path overlaps a lab cache: {cache}")
331
+
332
+ if out.exists():
333
+ if not out.is_dir():
334
+ raise DocsSiteError(f"Output path is not a directory: {out}")
335
+ marker = out / SITE_MARKER
336
+ if any(out.iterdir()):
337
+ if marker.is_symlink() or not marker.is_file():
338
+ raise DocsSiteError(f"Refusing to overwrite an unowned output directory: {out}")
339
+ try:
340
+ marker_content = marker.read_text(encoding="utf-8")
341
+ except OSError as exc:
342
+ raise DocsSiteError(f"Unable to verify output ownership: {marker}") from exc
343
+ if marker_content != SITE_MARKER_CONTENT:
344
+ raise DocsSiteError(f"Refusing to overwrite an unowned output directory: {out}")
345
+
346
+ if _directory_contains_link(out):
347
+ raise DocsSiteError(f"Refusing to write through a symlink in output directory: {out}")
348
+
349
+ for name in (SITE_MARKER, "index.html", "site.json"):
350
+ path = out / name
351
+ if path.is_file():
352
+ try:
353
+ link_count = path.stat().st_nlink
354
+ except OSError as exc:
355
+ raise DocsSiteError(f"Unable to verify output file links: {path}") from exc
356
+ if link_count > 1:
357
+ raise DocsSiteError(f"Refusing to overwrite hard-linked output file: {path}")
358
+
359
+ out.mkdir(parents=True, exist_ok=True)
360
+ (out / SITE_MARKER).write_text(SITE_MARKER_CONTENT, encoding="utf-8")
231
361
 
232
362
 
233
363
  def _site_payload(root: Path) -> dict[str, Any]:
@@ -299,16 +429,16 @@ document.querySelector('#siteTitle').textContent=SITE.title;document.querySelect
299
429
 
300
430
  def build(root: Path, out: Path, *, prepare_system_lab: bool = False) -> tuple[Path, dict[str, Any]]:
301
431
  root = root.resolve()
302
- out = out.resolve()
432
+ out = Path(os.path.abspath(out))
303
433
  if not root.is_dir():
304
434
  raise DocsSiteError(f"Repository root does not exist: {root}")
435
+ for kind in ("system", "database"):
436
+ _validate_lab_cache(root, kind)
437
+ _prepare_output(root, out)
305
438
  prep_warning = _prepare_system_lab(root) if prepare_system_lab else None
306
439
  payload = _site_payload(root)
307
440
  if prep_warning:
308
441
  payload["warnings"].append(prep_warning)
309
- if out.exists():
310
- shutil.rmtree(out)
311
- out.mkdir(parents=True, exist_ok=True)
312
442
  _copy_lab_cache(root, out, "system")
313
443
  _copy_lab_cache(root, out, "database")
314
444
  index = out / "index.html"
@@ -359,7 +489,7 @@ def parse_args() -> argparse.Namespace:
359
489
  def main() -> int:
360
490
  args = parse_args()
361
491
  root = args.root.resolve()
362
- out = args.out.resolve() if args.out else (root / DEFAULT_SITE).resolve()
492
+ out = args.out if args.out else root / DEFAULT_SITE
363
493
  try:
364
494
  if args.command == "build":
365
495
  index, payload = build(root, out, prepare_system_lab=args.prepare_system_lab)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/lean",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "Public consumer distribution and updater for Lean engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {