All files / server/services/compiler include-guard.ts

95.12% Statements 78/82
86.53% Branches 45/52
100% Functions 16/16
98.57% Lines 69/70

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 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149                                        15x 15x 15x 15x                       135x 135x 135x 135x 135x 743x 743x 1x 1x   742x 742x   135x 135x         851x 851x 1672x 855x 4x 4x 4x 4x   851x                 742x       45x 45x 45x 43x 43x 43x       26x 26x       43x 43x 29x 4x   26x 26x       743x 743x 59x 59x 743x 1x   58x 43x 43x 1x   42x 15x   27x       742x 2x 2x     2x 2x 2x     740x         110x 742x         742x 742x 742x 742x 743x   1485x    
import { normalizeSourcePath, type SourceProject } from "@shared/source-project";
 
/**
 * Include boundary for the REST compiler, which runs arduino-cli inside the
 * backend instead of a sandbox. The preprocessor reads every included file and
 * echoes its lines in diagnostics, so an include must not name a file outside
 * the submitted project or the toolchain's own include directories.
 *
 * The scan is deliberately conservative: it treats every `#`/`%:` that is
 * preceded on its logical line only by whitespace or comments as a directive,
 * also inside block comments and raw strings. A false positive rejects a sketch
 * with an explicit message; a false negative would let the compiler read a file.
 */
 
export interface UnsafeInclude {
  readonly file: string;
  readonly line: number;
  readonly message: string;
}
 
const FILE_DIRECTIVES = new Set(["include", "include_next", "import", "embed"]);
const DIRECTIVE_START = /(?:#|%:|\?\?=)[ \t\f\v]*/y;
const HAS_INCLUDE = /__has_include(?:_next)?\b/g;
const HEADER_NAME_DELIMITERS: Readonly<Record<string, { close: string; kind: "quoted" | "angled" }>> = {
  '"': { close: '"', kind: "quoted" },
  "<": { close: ">", kind: "angled" },
};
 
interface LogicalLine {
  readonly text: string;
  readonly line: number;
}
 
/** Translation phase 2: join backslash-continued physical lines. */
function logicalLines(source: string): LogicalLine[] {
  const physical = source.split(/\r?\n/);
  const lines: LogicalLine[] = [];
  let buffer = "";
  let startLine = 1;
  physical.forEach((text, index) => {
    if (buffer === "") startLine = index + 1;
    if (text.endsWith("\\")) {
      buffer += text.slice(0, -1);
      return;
    }
    lines.push({ text: buffer + text, line: startLine });
    buffer = "";
  });
  Iif (buffer !== "") lines.push({ text: buffer, line: startLine });
  return lines;
}
 
/** Skips whitespace and block comments; returns the index of the next token. */
function skipSpaceAndComments(text: string, start: number): number {
  let index = start;
  for (;;) {
    while (index < text.length && /[ \t\f\v]/.test(text[index])) index += 1;
    if (text.startsWith("/*", index)) {
      const end = text.indexOf("*/", index + 2);
      Iif (end < 0) return text.length;
      index = end + 2;
      continue;
    }
    return index;
  }
}
 
/** Index where directive candidates may start: after a leading comment tail or comments. */
function directiveCandidateStart(text: string): number {
  // A block comment opened on an earlier line may end on this one; whatever
  // follows its end can still start a directive. Considering both positions
  // keeps the scan conservative.
  return skipSpaceAndComments(text, 0);
}
 
function parseHeaderName(text: string, start: number): { kind: "quoted" | "angled"; path: string } | null {
  const index = skipSpaceAndComments(text, start);
  const delimiters = HEADER_NAME_DELIMITERS[text[index]];
  if (!delimiters) return null;
  const end = text.indexOf(delimiters.close, index + 1);
  Iif (end < 0) return null;
  return { kind: delimiters.kind, path: text.slice(index + 1, end) };
}
 
function directoryOf(file: string): string {
  const separator = file.lastIndexOf("/");
  return separator < 0 ? "" : file.slice(0, separator);
}
 
function isSafeHeaderName(fromFile: string, header: { kind: "quoted" | "angled"; path: string }): boolean {
  const { path } = header;
  if (!path || path.includes("\\") || path.includes("\0") || path.startsWith("/")) return false;
  if (header.kind === "angled") {
    return path.split("/").every((segment) => segment !== ".." && segment !== "");
  }
  const base = directoryOf(fromFile);
  return normalizeSourcePath(base ? `${base}/${path}` : path) !== undefined;
}
 
function checkDirective(file: string, line: LogicalLine, at: number): UnsafeInclude | null {
  DIRECTIVE_START.lastIndex = at;
  if (!DIRECTIVE_START.exec(line.text)) return null;
  const nameStart = skipSpaceAndComments(line.text, DIRECTIVE_START.lastIndex);
  const name = /^[A-Za-z0-9_\\$]*/.exec(line.text.slice(nameStart))?.[0] ?? "";
  if (name.includes("\\")) {
    return { file, line: line.line, message: "Escaped preprocessor directive names are not allowed" };
  }
  if (!FILE_DIRECTIVES.has(name)) return null;
  const header = parseHeaderName(line.text, nameStart + name.length);
  if (!header) {
    return { file, line: line.line, message: `#${name} must name a literal "file" or <file>` };
  }
  if (!isSafeHeaderName(file, header)) {
    return { file, line: line.line, message: `#${name} path is outside the sketch project: ${header.path}` };
  }
  return null;
}
 
function checkHasInclude(file: string, line: LogicalLine): UnsafeInclude | null {
  for (const match of line.text.matchAll(HAS_INCLUDE)) {
    const open = skipSpaceAndComments(line.text, (match.index ?? 0) + match[0].length);
    Iif (line.text[open] !== "(") {
      return { file, line: line.line, message: `${match[0]} must name a literal "file" or <file>` };
    }
    const header = parseHeaderName(line.text, open + 1);
    Eif (!header || !isSafeHeaderName(file, header)) {
      return { file, line: line.line, message: `${match[0]} path is not allowed` };
    }
  }
  return null;
}
 
/** Returns every include that could make the compiler read a file outside the project. */
export function findUnsafeIncludes(project: SourceProject): UnsafeInclude[] {
  return Object.entries(project.files).flatMap(([file, source]) =>
    logicalLines(source).flatMap((line) => scanLine(file, line)),
  );
}
 
function scanLine(file: string, line: LogicalLine): UnsafeInclude[] {
  const candidates = new Set([directiveCandidateStart(line.text)]);
  const commentTail = line.text.indexOf("*/");
  if (commentTail >= 0) candidates.add(skipSpaceAndComments(line.text, commentTail + 2));
  return [
    ...[...candidates].map((at) => checkDirective(file, line, at)),
    checkHasInclude(file, line),
  ].filter((finding): finding is UnsafeInclude => finding !== null);
}