source.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. /**
  2. * `GET /api/source?file=&from=&to=` — verbatim source, or an honest refusal.
  3. *
  4. * This is the one endpoint that reads the user's repository, so two rules
  5. * govern it and neither is negotiable.
  6. *
  7. * **Every read goes through `resolveProjectFile`.** That is the chokepoint from
  8. * `security.ts` — traversal, in-tree symlinks pointing out of the root,
  9. * absolute paths, sensitive system directories. Without it,
  10. * `?file=../../.ssh/id_rsa` is a credential leak over a port the user opened to
  11. * read their own code.
  12. *
  13. * **A file that changed on disk since it was indexed is never sliced.** The
  14. * viewer asks for line ranges the *index* recorded; if the file moved on since,
  15. * those ranges can point at a different symbol's body, which would be served
  16. * under the requested name and look perfectly plausible. So the bytes are
  17. * hashed and compared against `files.content_hash`, and on a mismatch the slice
  18. * is omitted with `drift: true` — the same call `codegraph_node` makes when it
  19. * says "changed on disk after the last index sync".
  20. *
  21. * Only files that are IN the index are served. That is a tighter boundary than
  22. * the MCP tools take, and it costs the viewer nothing (it only ever renders
  23. * indexed symbols) while making the drift verdict meaningful for every answer:
  24. * there is always a hash to compare against.
  25. */
  26. import { createHash } from 'crypto';
  27. import * as fs from 'fs';
  28. import * as path from 'path';
  29. import type { FileRecord } from '../../types';
  30. import type { CodeGraph } from '../../index';
  31. import { resolveProjectFile } from '../security';
  32. import { highlightLines, type HighlightResult } from '../highlight';
  33. import { ApiError, badRequest, intParam, notFound, textParam } from './respond';
  34. /**
  35. * Largest file we will read to answer a source request.
  36. *
  37. * The whole file has to be read to hash it, so this bounds the work one request
  38. * can cause. Well above the 1 MB ceiling extraction itself applies, so anything
  39. * actually in the index is comfortably inside it.
  40. */
  41. export const MAX_SOURCE_BYTES = 8 * 1024 * 1024;
  42. /** Lines returned in one response. The Symbol view asks for windows, not files. */
  43. export const MAX_SOURCE_LINES = 4000;
  44. /**
  45. * Look up a file record by a viewer-supplied path, WITHOUT validating it.
  46. *
  47. * Indexed paths are normalized to forward slashes at extraction time, so that
  48. * is the form tried first; the platform-separator form is a fallback for an
  49. * index written before that normalization.
  50. *
  51. * Callers that go on to READ the file must use {@link resolveRequestedFile}
  52. * instead — it puts the path through the security chokepoint first. This one is
  53. * for endpoints that only need the record (a drift flag on a path the index
  54. * itself handed us).
  55. */
  56. export function findIndexedFile(
  57. cg: CodeGraph,
  58. requested: string
  59. ): { record: FileRecord; storedPath: string } | null {
  60. const posix = toRequestPath(requested);
  61. const record = cg.getFile(posix);
  62. if (record) return { record, storedPath: posix };
  63. const native = posix.split('/').join(path.sep);
  64. if (native !== posix) {
  65. const legacy = cg.getFile(native);
  66. if (legacy) return { record: legacy, storedPath: native };
  67. }
  68. return null;
  69. }
  70. /**
  71. * Forward slashes and no leading `./` — the form indexed paths are stored in.
  72. *
  73. * A LEADING SLASH IS LEFT ALONE on purpose. Stripping it would quietly turn
  74. * `/etc/passwd` into the project-relative `etc/passwd` and answer "not in this
  75. * index" — reinterpreting the request instead of refusing it, and leaving the
  76. * chokepoint's absolute-path rule with nothing to catch.
  77. */
  78. export function toRequestPath(requested: string): string {
  79. return requested.replace(/\\/g, '/').replace(/^\.\//, '');
  80. }
  81. /**
  82. * Validate a viewer-supplied path, THEN look it up in the index.
  83. *
  84. * The order is the point. `resolveProjectFile` runs first, so a traversal, an
  85. * absolute path or a sensitive system directory is refused as what it is,
  86. * before the index is consulted — a 403 that says "outside the project", not a
  87. * 404 that says "not indexed" and quietly depends on the index lookup missing.
  88. * It also means the absolute path every reader uses has already been through
  89. * the chokepoint by construction, rather than by remembering to call it.
  90. *
  91. * @throws {PathRefusalError} the path is not one we would ever read.
  92. * @throws {ApiError} `not-found` when it is fine but not in the index.
  93. */
  94. export function resolveRequestedFile(
  95. cg: CodeGraph,
  96. projectRoot: string,
  97. requested: string
  98. ): { record: FileRecord; storedPath: string; absolute: string } {
  99. const posix = toRequestPath(requested);
  100. // Refusals happen here, ahead of everything.
  101. const absolute = resolveProjectFile(projectRoot, posix);
  102. const found = findIndexedFile(cg, posix);
  103. if (!found) throw notIndexedError(posix);
  104. return { ...found, absolute };
  105. }
  106. export function notIndexedError(file: string): ApiError {
  107. return notFound(
  108. `${file} is not in this CodeGraph index.`,
  109. 'The viewer only reads files the index knows about. If the file is new, ' +
  110. 'it appears after the next sync; if it is excluded (gitignored, generated, ' +
  111. 'or too large to parse), it will not appear at all.'
  112. );
  113. }
  114. /**
  115. * Split source the way the index counted it.
  116. *
  117. * Rows are `\n`-delimited — that is how tree-sitter numbers them — so a CRLF
  118. * file has the same line numbers here as in the graph. The trailing `\r` is
  119. * dropped per line so it does not render as a stray glyph.
  120. */
  121. export function splitLines(content: string): string[] {
  122. const lines = content.split('\n');
  123. for (let i = 0; i < lines.length; i++) {
  124. const line = lines[i] as string;
  125. if (line.endsWith('\r')) lines[i] = line.slice(0, -1);
  126. }
  127. // A file ending in a newline splits to a final empty string that is not a
  128. // line of source. Every other trailing empty line IS one.
  129. if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
  130. return lines;
  131. }
  132. /**
  133. * Whether an indexed file has changed on disk since it was indexed — the same
  134. * verdict `/api/source` returns, for endpoints that must *flag* drift without
  135. * serving source (a symbol header, a file outline).
  136. *
  137. * Cheap first: size plus floored mtime is the identical freshness test the sync
  138. * fast path uses, so an untouched file costs one `stat`. Only a stat mismatch
  139. * pays for a hash, which is what keeps a `touch` or a checkout that rewrote
  140. * identical bytes from reading as drift.
  141. *
  142. * Any failure answers `false`. A wrong "stale" flag would put a warning banner
  143. * over correct source; the cases that would trip it (missing record, unreadable
  144. * file) have their own handling in the endpoints that actually read.
  145. */
  146. export function hasDriftedOnDisk(
  147. projectRoot: string,
  148. storedPath: string,
  149. record: FileRecord
  150. ): boolean {
  151. try {
  152. const absolute = resolveProjectFile(projectRoot, storedPath);
  153. const stats = fs.statSync(absolute);
  154. if (stats.size === record.size && Math.floor(stats.mtimeMs) === Math.floor(record.modifiedAt)) {
  155. return false;
  156. }
  157. if (stats.size > MAX_SOURCE_BYTES) return true;
  158. const content = fs.readFileSync(absolute, 'utf-8');
  159. return createHash('sha256').update(content).digest('hex') !== record.contentHash;
  160. } catch {
  161. return false;
  162. }
  163. }
  164. export interface SourceResult {
  165. file: string;
  166. language: string;
  167. /** The file on disk differs from what was indexed — no slice is served. */
  168. drift: boolean;
  169. contentHash: string;
  170. indexedAt: number;
  171. generated: boolean;
  172. totalLines: number | null;
  173. from?: number;
  174. to?: number;
  175. lines?: string[];
  176. truncated?: boolean;
  177. reason?: string;
  178. /**
  179. * The same lines, classified for the code block — one entry per line, each a
  180. * list of `[classId, text]` pairs indexed into `highlight.classes`.
  181. *
  182. * It rides with the slice rather than living behind its own endpoint because
  183. * the two are only ever wanted together, and because a second round-trip
  184. * would let the viewer paint unhighlighted source and then reflow it. Absent
  185. * whenever `lines` is — a drifted file is not served at all.
  186. */
  187. highlight?: HighlightResult;
  188. }
  189. export async function buildSource(
  190. cg: CodeGraph,
  191. projectRoot: string,
  192. query: URLSearchParams
  193. ): Promise<SourceResult> {
  194. const requested = textParam(query, 'file');
  195. // Refusal first, index lookup second — see `resolveRequestedFile`.
  196. const { record, storedPath, absolute } = resolveRequestedFile(cg, projectRoot, requested);
  197. const from = intParam(query, 'from', { min: 1, max: 5_000_000, default: 1 });
  198. const to = intParam(query, 'to', { min: 1, max: 5_000_000, default: 0 });
  199. if (to !== 0 && to < from) {
  200. throw badRequest(`Parameter "to" (${to}) must not be before "from" (${from}).`);
  201. }
  202. const base: SourceResult = {
  203. file: storedPath.replace(/\\/g, '/'),
  204. language: record.language,
  205. drift: false,
  206. contentHash: record.contentHash,
  207. indexedAt: record.indexedAt,
  208. generated: record.generated === true,
  209. totalLines: null,
  210. };
  211. let stats: fs.Stats;
  212. try {
  213. stats = fs.statSync(absolute);
  214. } catch {
  215. // Indexed but gone. That IS drift, and the strongest kind: nothing on disk
  216. // corresponds to the ranges the graph holds.
  217. return { ...base, drift: true, reason: 'The file is in the index but no longer on disk.' };
  218. }
  219. if (stats.size > MAX_SOURCE_BYTES) {
  220. throw badRequest(
  221. `${base.file} is ${Math.round(stats.size / 1024 / 1024)} MB — too large to serve as source.`
  222. );
  223. }
  224. let content: string;
  225. try {
  226. content = fs.readFileSync(absolute, 'utf-8');
  227. } catch (err) {
  228. throw new ApiError(
  229. 'internal',
  230. `Could not read ${base.file}: ${err instanceof Error ? err.message : String(err)}`
  231. );
  232. }
  233. // Byte-identical to extraction's `hashContent` (sha256 over the utf-8
  234. // string). A touch or a checkout that rewrote the same bytes must not count
  235. // as drift, which is exactly what hashing content rather than mtime buys.
  236. const hash = createHash('sha256').update(content).digest('hex');
  237. if (hash !== record.contentHash) {
  238. return {
  239. ...base,
  240. drift: true,
  241. reason:
  242. 'This file changed on disk after the last index sync, so the indexed line ' +
  243. 'ranges no longer reliably match. Source is omitted rather than risk showing ' +
  244. "a different symbol's code; it returns after the next sync.",
  245. };
  246. }
  247. const all = splitLines(content);
  248. // Past the end of the file `from` names nothing, which is a caller bug worth
  249. // surfacing rather than answering with the last line as if that were meant.
  250. // `to` past the end is different — "line 30 to the end, whatever that is" is
  251. // an ordinary way to ask, so it clamps.
  252. if (from > all.length) {
  253. throw badRequest(
  254. `Parameter "from" (${from}) is past the end of ${base.file}, which has ${all.length} lines.`
  255. );
  256. }
  257. const start = from;
  258. const requestedEnd = to === 0 ? all.length : Math.min(to, all.length);
  259. const end = Math.min(requestedEnd, start + MAX_SOURCE_LINES - 1);
  260. const slice = all.slice(start - 1, end);
  261. return {
  262. ...base,
  263. totalLines: all.length,
  264. from: start,
  265. to: end,
  266. lines: slice,
  267. truncated: end < requestedEnd,
  268. // Keyed on the content hash, so the cache is invalidated by the file
  269. // changing rather than by a clock, and two viewers looking at the same
  270. // symbol share one tokenisation.
  271. highlight: await highlightLines(slice, {
  272. language: record.language,
  273. cacheKey: `${record.contentHash}:${start}:${end}`,
  274. }),
  275. };
  276. }