All files runJs.js

0% Statements 0/21
0% Branches 0/2
0% Functions 0/6
0% Lines 0/18

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126                                                                                                                                                                                                                                                           
/**
 * 이 파일이 하는 일:
 * 학생이 작성한 JavaScript 코드를 "안전하게" 실행하고 테스트케이스로 채점한다.
 *
 * 핵심 아이디어 — Web Worker.
 * 학습 포인트: 브라우저에서 남의 코드를 그냥 eval()로 돌리면 두 가지가 위험하다.
 *   1) while(true) 같은 무한루프가 들어오면 화면이 통째로 얼어붙는다(멈출 방법이 없다).
 *   2) 그 코드가 document를 만져 화면을 부수거나 쿠키를 훔쳐볼 수 있다.
 * Web Worker는 "다른 스레드에서 도는 별도의 방"이다.
 *   1) 오래 걸리면 terminate()로 방을 통째로 없애 버릴 수 있다 → 무한루프 방어
 *   2) Worker 안에는 document도 window도 없다 → 화면을 만질 수 없다
 * 그래서 서버에 Docker 샌드박스를 짓지 않고도, 브라우저가 이미 가진 격리 장치를 빌려 쓴다.
 *
 * ⚠️ 이 안전함이 성립하는 조건 — 반드시 지킬 것.
 * Worker는 완벽한 감옥이 아니다. document는 없지만 fetch는 되고, 같은 출처 API를
 * "그 브라우저의 세션 쿠키를 달고" 호출할 수 있다.
 * 지금 안전한 이유는 단 하나 — "자기 코드를 자기 브라우저에서 자기 권한으로만" 돌리기 때문이다.
 * (실행 지점은 CodingProblemPage 한 곳뿐이고, 코드는 서버가 본인 것만 내려준다.)
 *
 * 그러므로 절대 하지 말 것:
 *   ❌ 멘토 화면에 "학생 코드 실행해 보기" 버튼 붙이기
 *      → 학생이 fetch('/api/mentor/...')를 심어두면 멘토 세션으로 실행된다 = 권한 탈취.
 * 남의 코드를 남의 브라우저에서 돌려야 한다면, 그때는 서버 샌드박스가 필요하다.
 */
 
/** 코드 한 번 실행에 허용할 시간(ms). 넘으면 무한루프로 보고 Worker를 없앤다. */
const TIMEOUT_MS = 2000;
 
/**
 * Worker 안에서 돌 코드.
 * 학습 포인트: Worker는 별도 파일이 필요한데, 파일을 따로 두는 대신
 * 문자열을 Blob으로 만들어 URL처럼 쓴다(createObjectURL).
 * "파일 하나 늘리지 않고 Worker 쓰기" 관용구다.
 */
const workerSource = `
self.onmessage = function (event) {
  var code = event.data.code;
  var functionName = event.data.functionName;
  var tests = event.data.tests;
  var results = [];
 
  // 학생 코드를 평가해서 함수를 꺼낸다.
  var fn;
  try {
    // 학습 포인트: new Function(코드문자열)은 그 코드를 담은 함수를 만들어 준다.
    // 뒤에 return을 덧붙여, 학생이 정의한 함수를 밖으로 꺼내오게 했다.
    // eval()과 달리 바깥 지역 변수에 접근하지 못해(전역 스코프에서 평가된다)
    // 우리 채점 코드의 변수를 학생 코드가 건드릴 수 없다.
    var factory = new Function(code + '\\nreturn typeof ' + functionName + ' === "function" ? ' + functionName + ' : null;');
    fn = factory();
  } catch (e) {
    self.postMessage({ error: '코드에 문법 오류가 있습니다: ' + e.message });
    return;
  }
  if (!fn) {
    self.postMessage({ error: '"' + functionName + '" 함수를 찾을 수 없습니다. 함수 이름을 바꾸지 마세요.' });
    return;
  }
 
  for (var i = 0; i < tests.length; i++) {
    var test = tests[i];
    var actual;
    var errorMessage = null;
    try {
      // 인자를 복사해서 넘긴다 — 학생 코드가 배열을 바꿔도 다음 테스트에 영향이 없도록.
      var args = JSON.parse(JSON.stringify(test.args));
      actual = fn.apply(null, args);
    } catch (e) {
      errorMessage = e.message;
    }
 
    var expectedText = JSON.stringify(test.expected);
    var actualText = errorMessage ? null : JSON.stringify(actual);
    // 학습 포인트: 배열/객체는 ===로 비교하면 "내용이 같아도" false다(주소를 비교하니까).
    // JSON 문자열로 바꿔 비교하면 내용 비교가 된다. 키 순서까지 같아야 하는 한계는 있지만
    // 우리 문제 범위에서는 충분하다.
    var passed = !errorMessage && actualText === expectedText;
 
    results.push({
      name: test.name,
      passed: passed,
      expected: expectedText,
      actual: errorMessage ? ('에러: ' + errorMessage) : (actualText === undefined ? 'undefined' : actualText)
    });
  }
 
  self.postMessage({ results: results });
};
`;
 
/**
 * 학생 코드를 테스트케이스로 채점한다.
 *
 * @param {string} code 학생이 작성한 코드
 * @param {string} functionName 호출할 함수 이름
 * @param {Array} tests [{name, args, expected}]
 * @returns {Promise<{results?: Array, error?: string}>}
 */
export function runJs(code, functionName, tests) {
  return new Promise((resolve) => {
    const blob = new Blob([workerSource], { type: 'application/javascript' });
    const url = URL.createObjectURL(blob);
    const worker = new Worker(url);
 
    // 끝났든 시간이 지났든, 뒷정리는 한 곳에서 한다.
    let settled = false;
    function finish(value) {
      if (settled) return;
      settled = true;
      clearTimeout(timer);
      worker.terminate();
      URL.revokeObjectURL(url);
      resolve(value);
    }
 
    const timer = setTimeout(() => {
      finish({ error: `실행이 ${TIMEOUT_MS / 1000}초를 넘겼습니다. 무한루프가 아닌지 확인해 보세요.` });
    }, TIMEOUT_MS);
 
    worker.onmessage = (event) => finish(event.data);
    worker.onerror = (event) => finish({ error: '실행 중 오류: ' + event.message });
 
    worker.postMessage({ code, functionName, tests });
  });
}