pboProperty Ontology
javascript163 lines4.9 KB
RawDownload
1'use strict';
2
3// Repository indexing: builds a tree of the repo on disk, applies an ignore
4// list, and provides safe path resolution so nothing outside the repo (or in
5// the ignore list) can ever be read.
6
7const fs = require('fs');
8const fsp = require('fs/promises');
9const path = require('path');
10
11const IGNORED_NAMES = new Set([
12 '.git',
13 'node_modules',
14 '__pycache__',
15 '.cursor',
16 '.DS_Store',
17 '.dockerignore',
18 '.railwayignore',
19]);
20
21const IGNORED_PATTERNS = [
22 /^\.env(\..*)?$/, // .env, .env.local, ...
23 /\.pyc$/,
24];
25
26const TREE_TTL_MS = 10_000;
27
28function isIgnoredName(name) {
29 if (IGNORED_NAMES.has(name)) return true;
30 return IGNORED_PATTERNS.some((re) => re.test(name));
31}
32
33function isIgnoredPath(relPath) {
34 if (!relPath) return false;
35 return relPath.split('/').some(isIgnoredName);
36}
37
38class Repo {
39 constructor(root) {
40 this.root = path.resolve(root);
41 this._tree = null;
42 this._treeBuiltAt = 0;
43 }
44
45 /**
46 * Normalise a URL path fragment into a safe repo-relative POSIX path.
47 * Returns null if the path escapes the repo or hits the ignore list.
48 */
49 safeRelative(input) {
50 let rel = String(input || '')
51 .replace(/\\/g, '/')
52 .replace(/\/+/g, '/')
53 .replace(/^\/|\/$/g, '');
54 if (rel === '') return '';
55 const parts = rel.split('/');
56 if (parts.some((p) => p === '' || p === '.' || p === '..')) return null;
57 if (isIgnoredPath(rel)) return null;
58 // Ensure the resolved absolute path still lives inside root.
59 const abs = path.resolve(this.root, ...parts);
60 if (abs !== this.root && !abs.startsWith(this.root + path.sep)) return null;
61 return parts.join('/');
62 }
63
64 absolute(rel) {
65 return rel === '' ? this.root : path.join(this.root, ...rel.split('/'));
66 }
67
68 /** stat a repo-relative path, resolving symlinks and re-checking containment. */
69 async stat(rel) {
70 const abs = this.absolute(rel);
71 let real;
72 try {
73 real = await fsp.realpath(abs);
74 } catch {
75 return null;
76 }
77 if (real !== this.root && !real.startsWith(this.root + path.sep)) return null;
78 const st = await fsp.stat(real);
79 return { abs: real, isDir: st.isDirectory(), isFile: st.isFile(), size: st.size, mtime: st.mtime };
80 }
81
82 /** Cached full tree used for the sidebar, search and stats. */
83 async tree() {
84 const now = Date.now();
85 if (this._tree && now - this._treeBuiltAt < TREE_TTL_MS) return this._tree;
86 this._tree = await this._buildTree('', this.root);
87 this._treeBuiltAt = now;
88 return this._tree;
89 }
90
91 async _buildTree(rel, abs) {
92 const entries = await fsp.readdir(abs, { withFileTypes: true });
93 const dirs = [];
94 const files = [];
95 for (const ent of entries) {
96 if (isIgnoredName(ent.name)) continue;
97 const childRel = rel ? `${rel}/${ent.name}` : ent.name;
98 const childAbs = path.join(abs, ent.name);
99 if (ent.isSymbolicLink()) {
100 // Only follow symlinks that stay inside the repo.
101 let real;
102 try {
103 real = await fsp.realpath(childAbs);
104 } catch {
105 continue;
106 }
107 if (!real.startsWith(this.root + path.sep)) continue;
108 const st = await fsp.stat(real);
109 if (st.isDirectory()) dirs.push(await this._buildTree(childRel, real));
110 else if (st.isFile()) files.push({ type: 'file', name: ent.name, path: childRel, size: st.size });
111 continue;
112 }
113 if (ent.isDirectory()) {
114 dirs.push(await this._buildTree(childRel, childAbs));
115 } else if (ent.isFile()) {
116 const st = await fsp.stat(childAbs);
117 files.push({ type: 'file', name: ent.name, path: childRel, size: st.size });
118 }
119 }
120 const cmp = (a, b) => a.name.localeCompare(b.name, 'en', { numeric: true, sensitivity: 'base' });
121 dirs.sort(cmp);
122 files.sort(cmp);
123 const children = [...dirs, ...files];
124 const fileCount = children.reduce((n, c) => n + (c.type === 'dir' ? c.fileCount : 1), 0);
125 const totalSize = children.reduce((n, c) => n + (c.type === 'dir' ? c.totalSize : c.size), 0);
126 return { type: 'dir', name: rel ? path.posix.basename(rel) : '', path: rel, children, fileCount, totalSize };
127 }
128
129 /** Flat list of every file in the tree (for search and stats). */
130 async allFiles() {
131 const out = [];
132 const walk = (node) => {
133 for (const c of node.children) {
134 if (c.type === 'dir') walk(c);
135 else out.push(c);
136 }
137 };
138 walk(await this.tree());
139 return out;
140 }
141
142 /** Find a node by path in the cached tree. */
143 async node(rel) {
144 const root = await this.tree();
145 if (rel === '') return root;
146 let cur = root;
147 for (const part of rel.split('/')) {
148 cur = cur.children.find((c) => c.name === part);
149 if (!cur) return null;
150 }
151 return cur;
152 }
153
154 readFile(rel, encoding) {
155 return fsp.readFile(this.absolute(rel), encoding);
156 }
157
158 createReadStream(rel) {
159 return fs.createReadStream(this.absolute(rel));
160 }
161}
162
163module.exports = { Repo, isIgnoredName, isIgnoredPath };