@zalkera/client 0.21.3 → 0.21.4

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});
@@ -184,6 +187,19 @@ function markUnmeasured(path, code, what) {
184
187
  */
185
188
  function readSafe(file, what) {
186
189
  try {
190
+ // ⚠ 크기 상한. 이 검사기는 **신뢰 밖 소스**에도 도는데(서빙 샌드박스), 상한이 없으면 import
191
+ // 한 줄로 임의 크기 파일을 읽혀 빌드 박스 메모리를 밀어낼 수 있다 — 실측으로 레포 밖 400MB
192
+ // 파일 하나에 RSS 47MB → 812MB 였다. 넘으면 **못 잰 것으로 적는다**(조용히 건너뛰면 통과로
193
+ // 읽힌다). 소스 한 파일이 이 크기를 넘는 것은 그 자체로 사람이 볼 일이다.
194
+ const {size} = statSync(file);
195
+ if (size > MAX_READ_BYTES) {
196
+ markUnmeasured(
197
+ file,
198
+ "EFBIG",
199
+ `${what} — ${Math.round(size / 1048576)}MB(상한 ${MAX_READ_BYTES / 1048576}MB)`,
200
+ );
201
+ return null;
202
+ }
187
203
  return readFileSync(file, "utf8");
188
204
  } catch (e) {
189
205
  if (e.code === "ENOENT") return null; // 없는 파일은 "못 잰 것"이 아니다 — 규칙이 판단할 사실이다
@@ -1461,7 +1477,9 @@ function checkDocCoordinates() {
1461
1477
  if (fileExists(agents)) {
1462
1478
  const agentsText = readSafe(agents, "D1 문서 좌표 검사(AGENTS.md)");
1463
1479
  for (const tok of docPathTokens(agentsText ?? "")) {
1464
- if (!fileExists(join(repoRoot, tok))) {
1480
+ const target = containedPath(repoRoot, tok);
1481
+ if (target === null) continue; // 레포 밖 좌표 — 남의 파일시스템을 탐침하지 않는다
1482
+ if (!fileExists(target)) {
1465
1483
  sink.push(
1466
1484
  `[D1] AGENTS.md 가 없는 파일을 가리킵니다: \`${tok}\` — codegen 이 이 문서를 가장 먼저 ` +
1467
1485
  `읽습니다. 실물 경로로 고치거나, 이제 없는 파일이면 경로 표기를 지우세요.`,
@@ -1485,7 +1503,9 @@ function checkDocCoordinates() {
1485
1503
  if (!fileExists(llms)) return; // 미설치 — 없는 것을 센 척하지 않는다.
1486
1504
  const llmsText = readSafe(llms, "D2 문서 좌표 검사(llms.txt)");
1487
1505
  for (const tok of docPathTokens(llmsText ?? "", /^src\/(app|components|lib)\//)) {
1488
- if (!fileExists(join(repoRoot, tok))) {
1506
+ const target = containedPath(repoRoot, tok);
1507
+ if (target === null) continue; // D1 과 같은 봉쇄
1508
+ if (!fileExists(target)) {
1489
1509
  sink.push(
1490
1510
  `[D2] llms.txt 가 본보기로 지목한 \`${tok}\` 이 이 레포에 없습니다 — 레시피가 실물을 ` +
1491
1511
  `앞질렀거나(공표 먼저) 본보기가 그 파일을 지웠습니다. 어느 쪽인지 확인해 한쪽을 맞추세요.`,
@@ -1679,6 +1699,53 @@ function within(p, base) {
1679
1699
  return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
1680
1700
  }
1681
1701
 
1702
+ /**
1703
+ * 레포 안으로 **봉쇄된** 경로 해석. 소스 내용(문서 좌표·import 지정자)에서 나온 경로 문자열을
1704
+ * 파일시스템에 대기 **전에** 반드시 통과시킨다.
1705
+ *
1706
+ * 왜 필요한가 — 이 검사기는 고객 자기 레포에서만 도는 물건이 아니다. `build.sh` 가 서빙 샌드박스에서
1707
+ * **테넌트가 올린 소스**에 `--gate` 로 돌리고, 그 출력은 `/_gate-status` logTail 로 테넌트에게 돌아간다.
1708
+ * 즉 입력·탐침·회신이 다 갖춰져 있어, 경계가 없으면 우리 빌드 박스에 대한 **존재 오라클**(D1/D2)과
1709
+ * **임의 파일 읽기**(import 그래프)가 된다. 셋 다 심의가 통제군을 세워 실증했다.
1710
+ *
1711
+ * 접두 문자열로 막으려던 판이 있었다 — `tok.startsWith("..")` 는 선행 `..` 만 막고 `a/../../x` 에
1712
+ * 뚫린다. 경로 경계는 **해석 후 봉쇄**로만 선다. `walkTree` 가 쓰는 규칙과 같은 것이다.
1713
+ *
1714
+ * 경계는 **소스 루트가 아니라 레포 루트**다. `src/app/page.tsx` 가 `../../content` 를 부르는 것은
1715
+ * 정당한 간선이고(본보기 4벌 실물), 소스 루트로 좁히면 그 간선을 끊는다.
1716
+ *
1717
+ * 반환: 봉쇄된 절대경로 · 레포 밖으로 나가면 `null`. `null` 은 "없다"가 아니라 **"우리가 판정할
1718
+ * 자리가 아니다"** 다 — 탐침하지도, 부재를 단정하지도 않는다.
1719
+ */
1720
+ function containedPath(from, spec) {
1721
+ const target = resolve(from, spec);
1722
+ if (!within(target, repoRootDir)) return null; // ⓐ 문자열 해석 단계의 이탈(`..`·절대경로)
1723
+ // ⓑ 심링크 이탈 — `resolve` 는 심링크를 풀지 않으므로 `link/x.ts`(link → /etc)는 ⓐ 를 통과한다.
1724
+ // 성분마다 확인한다. 없는 성분을 만나면 그 경로는 애초에 존재하지 않으니 이탈도 아니다.
1725
+ let cur = repoRootDir;
1726
+ for (const part of relative(repoRootDir, target).split(sep)) {
1727
+ if (part === "") continue;
1728
+ cur = join(cur, part);
1729
+ let st;
1730
+ try {
1731
+ st = lstatSync(cur);
1732
+ } catch {
1733
+ return target; // 없음 — 죽은 좌표인지는 부르는 쪽 규칙이 판단한다
1734
+ }
1735
+ if (st.isSymbolicLink()) {
1736
+ let real;
1737
+ try {
1738
+ real = realpathSync(cur);
1739
+ } catch {
1740
+ return null;
1741
+ }
1742
+ if (!within(real, repoRootDir)) return null;
1743
+ cur = real;
1744
+ }
1745
+ }
1746
+ return cur;
1747
+ }
1748
+
1682
1749
  /** 심링크인가. `lstatSync` 는 따라가지 않는다. 못 물으면 아니라고 답한다(뒤의 `statSync` 가 잡는다). */
1683
1750
  function isSymlink(p) {
1684
1751
  try {
@@ -1840,9 +1907,12 @@ function resolveImport(spec, fromFile) {
1840
1907
  else if (spec.startsWith(".")) base = resolve(dirname(fromFile), spec);
1841
1908
  else return null;
1842
1909
 
1910
+ // ⚠ 후보 **마다** 봉쇄한다 — `base` 만 검사하면 마지막 성분이 심링크인 `src/lib/x.ts` 가 샌다.
1843
1911
  for (const cand of [base, ...[".ts", ".tsx", ".js", ".jsx"].flatMap((e) => [base + e, join(base, "index" + e)])]) {
1912
+ const safe = containedPath(repoRootDir, cand);
1913
+ if (safe === null) continue; // 레포 밖 — 따라가지 않는다
1844
1914
  try {
1845
- if (statSync(cand).isFile()) return cand;
1915
+ if (statSync(safe).isFile()) return safe;
1846
1916
  } catch {
1847
1917
  /* 다음 후보 */
1848
1918
  }
@@ -1904,12 +1974,10 @@ function dynamicApiReachableFrom(entry) {
1904
1974
  if (seen.has(file)) continue;
1905
1975
  seen.add(file);
1906
1976
 
1907
- let raw;
1908
- try {
1909
- raw = readFileSync(file, "utf8");
1910
- } catch {
1911
- continue;
1912
- }
1977
+ // `readSafe` 를 태운다 — 조용한 `catch {}` 는 못 읽은 것을 통과로 만든다(이 파일의 처분).
1978
+ // 크기 상한도 여기서 듣는다(아래 MAX_READ_BYTES).
1979
+ const raw = readSafe(file, `C1 서버 그래프 판독(${relative(repoRootDir, file)})`);
1980
+ if (raw === null) continue;
1913
1981
  if (isClientBoundary(raw)) continue; // 클라이언트 경계 — 여기서 끊는다.
1914
1982
 
1915
1983
  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.4",
4
4
  "description": "zalkera 헤드리스 CMS 공개 API 클라이언트 (테넌트 사이트용)",
5
5
  "license": "MIT",
6
6
  "author": "Credium Co., Ltd.",