@zalkera/client 0.21.3 → 0.21.5

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.
@@ -170,6 +170,9 @@ function resolveSourceRoot() {
170
170
  */
171
171
  const unmeasured = [];
172
172
 
173
+ /** 한 파일을 읽어들이는 상한. 근거는 `readSafe` 주석. */
174
+ const MAX_READ_BYTES = 8 * 1024 * 1024;
175
+
173
176
  /** 못 잰 것을 적는다. 사유(errno)와 무엇을 못 했는지를 함께 남긴다 — "안 잰 것을 잰 척" 하지 않기 위해. */
174
177
  function markUnmeasured(path, code, what) {
175
178
  unmeasured.push({path: relative(process.cwd(), path), code: code ?? "UNKNOWN", what});
@@ -183,7 +186,33 @@ function markUnmeasured(path, code, what) {
183
186
  * 한 자리로 모은다.
184
187
  */
185
188
  function readSafe(file, what) {
189
+ // ⚠ **심링크를 따라가지 않는다.** 0.21.4 는 파생 경로(`containedPath`)만 봉쇄했는데, 검사기가
190
+ // **이름으로 여는 파일** — `AGENTS.md`·`globals.css`·`content/index.ts` 등 — 에는 봉쇄가 없었다.
191
+ // 테넌트가 그 이름을 임의 절대경로로 심링크하면 서빙 샌드박스에서 존재·가독성·정확한 MB·
192
+ // 백틱 안 경로형 문자열을 회신으로 되받는다(심의 실측). 여는 자리에도 같은 규칙을 건다.
193
+ //
194
+ // 레포 **안**을 가리키는 심링크는 그대로 읽는다(모노레포의 공유 설정 등) — 경계는 이탈이지
195
+ // 심링크 자체가 아니다.
196
+ const safe = containedPath(repoRootDir, file);
197
+ if (safe === null) {
198
+ markUnmeasured(file, "EXDEV", `${what} — 레포 밖을 가리켜 읽지 않았습니다`);
199
+ return null;
200
+ }
201
+ file = safe;
186
202
  try {
203
+ // ⚠ 크기 상한. 이 검사기는 **신뢰 밖 소스**에도 도는데(서빙 샌드박스), 상한이 없으면 import
204
+ // 한 줄로 임의 크기 파일을 읽혀 빌드 박스 메모리를 밀어낼 수 있다 — 실측으로 레포 밖 400MB
205
+ // 파일 하나에 RSS 47MB → 812MB 였다. 넘으면 **못 잰 것으로 적는다**(조용히 건너뛰면 통과로
206
+ // 읽힌다). 소스 한 파일이 이 크기를 넘는 것은 그 자체로 사람이 볼 일이다.
207
+ const {size} = statSync(file);
208
+ if (size > MAX_READ_BYTES) {
209
+ markUnmeasured(
210
+ file,
211
+ "EFBIG",
212
+ `${what} — ${Math.round(size / 1048576)}MB(상한 ${MAX_READ_BYTES / 1048576}MB)`,
213
+ );
214
+ return null;
215
+ }
187
216
  return readFileSync(file, "utf8");
188
217
  } catch (e) {
189
218
  if (e.code === "ENOENT") return null; // 없는 파일은 "못 잰 것"이 아니다 — 규칙이 판단할 사실이다
@@ -1438,6 +1467,22 @@ function fileExists(path) {
1438
1467
  }
1439
1468
  }
1440
1469
 
1470
+ /**
1471
+ * **이름이 있는가** — 심링크를 따라가지 않는다.
1472
+ *
1473
+ * 고정 이름 파일(`AGENTS.md`·`llms.txt`)의 존재를 `statSync` 로 물으면 **심링크 표적**을 따라가고,
1474
+ * 그 표적이 있느냐 없느냐로 회신이 갈린다 — 표적이 신뢰 밖 소스의 통제 아래라 그 차이가 오라클이다.
1475
+ * 이름의 존재만 묻고, 읽을 수 있는지는 `readSafe` 의 봉쇄가 판단하게 둔다(표적과 무관하게 한 줄).
1476
+ */
1477
+ function nameExists(path) {
1478
+ try {
1479
+ lstatSync(path);
1480
+ return true;
1481
+ } catch {
1482
+ return false;
1483
+ }
1484
+ }
1485
+
1441
1486
  /**
1442
1487
  * D 규칙의 심각도 — 좌표의 실재는 **사실 판정**이라 어느 레포에서도 참이지만, 남의 레포 문서에
1443
1488
  * 에러를 던지지는 않는다(전제 1 "강제할 수 없다"). 우리 계약을 하나라도 선언한 레포에서만 error 다.
@@ -1458,10 +1503,12 @@ function checkDocCoordinates() {
1458
1503
  const sink = docsSink();
1459
1504
 
1460
1505
  const agents = join(repoRoot, "AGENTS.md");
1461
- if (fileExists(agents)) {
1506
+ if (nameExists(agents)) {
1462
1507
  const agentsText = readSafe(agents, "D1 문서 좌표 검사(AGENTS.md)");
1463
1508
  for (const tok of docPathTokens(agentsText ?? "")) {
1464
- if (!fileExists(join(repoRoot, tok))) {
1509
+ const target = containedPath(repoRoot, tok);
1510
+ if (target === null) continue; // 레포 밖 좌표 — 남의 파일시스템을 탐침하지 않는다
1511
+ if (!fileExists(target)) {
1465
1512
  sink.push(
1466
1513
  `[D1] AGENTS.md 가 없는 파일을 가리킵니다: \`${tok}\` — codegen 이 이 문서를 가장 먼저 ` +
1467
1514
  `읽습니다. 실물 경로로 고치거나, 이제 없는 파일이면 경로 표기를 지우세요.`,
@@ -1482,10 +1529,12 @@ function checkDocCoordinates() {
1482
1529
  if (!isExemplar) return;
1483
1530
 
1484
1531
  const llms = join(repoRoot, "node_modules", "@zalkera", "client", "llms.txt");
1485
- if (!fileExists(llms)) return; // 미설치 — 없는 것을 센 척하지 않는다.
1532
+ if (!nameExists(llms)) return; // 미설치 — 없는 것을 센 척하지 않는다.
1486
1533
  const llmsText = readSafe(llms, "D2 문서 좌표 검사(llms.txt)");
1487
1534
  for (const tok of docPathTokens(llmsText ?? "", /^src\/(app|components|lib)\//)) {
1488
- if (!fileExists(join(repoRoot, tok))) {
1535
+ const target = containedPath(repoRoot, tok);
1536
+ if (target === null) continue; // D1 과 같은 봉쇄
1537
+ if (!fileExists(target)) {
1489
1538
  sink.push(
1490
1539
  `[D2] llms.txt 가 본보기로 지목한 \`${tok}\` 이 이 레포에 없습니다 — 레시피가 실물을 ` +
1491
1540
  `앞질렀거나(공표 먼저) 본보기가 그 파일을 지웠습니다. 어느 쪽인지 확인해 한쪽을 맞추세요.`,
@@ -1679,6 +1728,53 @@ function within(p, base) {
1679
1728
  return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
1680
1729
  }
1681
1730
 
1731
+ /**
1732
+ * 레포 안으로 **봉쇄된** 경로 해석. 소스 내용(문서 좌표·import 지정자)에서 나온 경로 문자열을
1733
+ * 파일시스템에 대기 **전에** 반드시 통과시킨다.
1734
+ *
1735
+ * 왜 필요한가 — 이 검사기는 고객 자기 레포에서만 도는 물건이 아니다. `build.sh` 가 서빙 샌드박스에서
1736
+ * **테넌트가 올린 소스**에 `--gate` 로 돌리고, 그 출력은 `/_gate-status` logTail 로 테넌트에게 돌아간다.
1737
+ * 즉 입력·탐침·회신이 다 갖춰져 있어, 경계가 없으면 우리 빌드 박스에 대한 **존재 오라클**(D1/D2)과
1738
+ * **임의 파일 읽기**(import 그래프)가 된다. 셋 다 심의가 통제군을 세워 실증했다.
1739
+ *
1740
+ * 접두 문자열로 막으려던 판이 있었다 — `tok.startsWith("..")` 는 선행 `..` 만 막고 `a/../../x` 에
1741
+ * 뚫린다. 경로 경계는 **해석 후 봉쇄**로만 선다. `walkTree` 가 쓰는 규칙과 같은 것이다.
1742
+ *
1743
+ * 경계는 **소스 루트가 아니라 레포 루트**다. `src/app/page.tsx` 가 `../../content` 를 부르는 것은
1744
+ * 정당한 간선이고(본보기 4벌 실물), 소스 루트로 좁히면 그 간선을 끊는다.
1745
+ *
1746
+ * 반환: 봉쇄된 절대경로 · 레포 밖으로 나가면 `null`. `null` 은 "없다"가 아니라 **"우리가 판정할
1747
+ * 자리가 아니다"** 다 — 탐침하지도, 부재를 단정하지도 않는다.
1748
+ */
1749
+ function containedPath(from, spec) {
1750
+ const target = resolve(from, spec);
1751
+ if (!within(target, repoRootDir)) return null; // ⓐ 문자열 해석 단계의 이탈(`..`·절대경로)
1752
+ // ⓑ 심링크 이탈 — `resolve` 는 심링크를 풀지 않으므로 `link/x.ts`(link → /etc)는 ⓐ 를 통과한다.
1753
+ // 성분마다 확인한다. 없는 성분을 만나면 그 경로는 애초에 존재하지 않으니 이탈도 아니다.
1754
+ let cur = repoRootDir;
1755
+ for (const part of relative(repoRootDir, target).split(sep)) {
1756
+ if (part === "") continue;
1757
+ cur = join(cur, part);
1758
+ let st;
1759
+ try {
1760
+ st = lstatSync(cur);
1761
+ } catch {
1762
+ return target; // 없음 — 죽은 좌표인지는 부르는 쪽 규칙이 판단한다
1763
+ }
1764
+ if (st.isSymbolicLink()) {
1765
+ let real;
1766
+ try {
1767
+ real = realpathSync(cur);
1768
+ } catch {
1769
+ return null;
1770
+ }
1771
+ if (!within(real, repoRootDir)) return null;
1772
+ cur = real;
1773
+ }
1774
+ }
1775
+ return cur;
1776
+ }
1777
+
1682
1778
  /** 심링크인가. `lstatSync` 는 따라가지 않는다. 못 물으면 아니라고 답한다(뒤의 `statSync` 가 잡는다). */
1683
1779
  function isSymlink(p) {
1684
1780
  try {
@@ -1840,9 +1936,12 @@ function resolveImport(spec, fromFile) {
1840
1936
  else if (spec.startsWith(".")) base = resolve(dirname(fromFile), spec);
1841
1937
  else return null;
1842
1938
 
1939
+ // ⚠ 후보 **마다** 봉쇄한다 — `base` 만 검사하면 마지막 성분이 심링크인 `src/lib/x.ts` 가 샌다.
1843
1940
  for (const cand of [base, ...[".ts", ".tsx", ".js", ".jsx"].flatMap((e) => [base + e, join(base, "index" + e)])]) {
1941
+ const safe = containedPath(repoRootDir, cand);
1942
+ if (safe === null) continue; // 레포 밖 — 따라가지 않는다
1844
1943
  try {
1845
- if (statSync(cand).isFile()) return cand;
1944
+ if (statSync(safe).isFile()) return safe;
1846
1945
  } catch {
1847
1946
  /* 다음 후보 */
1848
1947
  }
@@ -1904,12 +2003,10 @@ function dynamicApiReachableFrom(entry) {
1904
2003
  if (seen.has(file)) continue;
1905
2004
  seen.add(file);
1906
2005
 
1907
- let raw;
1908
- try {
1909
- raw = readFileSync(file, "utf8");
1910
- } catch {
1911
- continue;
1912
- }
2006
+ // `readSafe` 를 태운다 — 조용한 `catch {}` 는 못 읽은 것을 통과로 만든다(이 파일의 처분).
2007
+ // 크기 상한도 여기서 듣는다(아래 MAX_READ_BYTES).
2008
+ const raw = readSafe(file, `C1 서버 그래프 판독(${relative(repoRootDir, file)})`);
2009
+ if (raw === null) continue;
1913
2010
  if (isClientBoundary(raw)) continue; // 클라이언트 경계 — 여기서 끊는다.
1914
2011
 
1915
2012
  const text = stripComments(raw); // 주석만 제거(문자열 값·import 지정자 보존)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalkera/client",
3
- "version": "0.21.3",
3
+ "version": "0.21.5",
4
4
  "description": "zalkera 헤드리스 CMS 공개 API 클라이언트 (테넌트 사이트용)",
5
5
  "license": "MIT",
6
6
  "author": "Credium Co., Ltd.",