@probelabs/probe 0.6.0-rc225 → 0.6.0-rc226

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.
@@ -4,9 +4,48 @@
4
4
  */
5
5
 
6
6
  import path from 'path';
7
- import { promises as fs } from 'fs';
7
+ import { promises as fs, realpathSync } from 'fs';
8
8
  import { PathError } from './error-types.js';
9
9
 
10
+ /**
11
+ * Safely resolve symlinks for a path.
12
+ * Returns the real path if it exists, otherwise finds the nearest existing
13
+ * ancestor directory and resolves it, then appends the remaining path components.
14
+ * This is important for security to prevent symlink bypass attacks.
15
+ *
16
+ * @param {string} inputPath - Path to resolve
17
+ * @returns {string} Resolved real path or best-effort resolved path
18
+ */
19
+ export function safeRealpath(inputPath) {
20
+ try {
21
+ return realpathSync(inputPath);
22
+ } catch (error) {
23
+ // If path doesn't exist, find the nearest existing ancestor
24
+ // and resolve it, then append the remaining components.
25
+ // This handles cases like non-existent nested paths where an ancestor
26
+ // may be a symlink (e.g., /var -> /private/var on macOS)
27
+ const normalized = path.normalize(inputPath);
28
+ const parts = normalized.split(path.sep);
29
+
30
+ // Try progressively shorter paths until we find one that exists
31
+ for (let i = parts.length - 1; i >= 0; i--) {
32
+ const ancestorPath = parts.slice(0, i).join(path.sep) || path.sep;
33
+ try {
34
+ const resolvedAncestor = realpathSync(ancestorPath);
35
+ // Found an existing ancestor - append remaining components
36
+ const remainingParts = parts.slice(i);
37
+ return path.join(resolvedAncestor, ...remainingParts);
38
+ } catch (ancestorError) {
39
+ // This ancestor doesn't exist either, try the next one up
40
+ continue;
41
+ }
42
+ }
43
+
44
+ // No existing ancestor found, return normalized path
45
+ return normalized;
46
+ }
47
+ }
48
+
10
49
  /**
11
50
  * Validates and normalizes a path to be used as working directory (cwd).
12
51
  *
@@ -74,3 +113,111 @@ export function normalizePath(inputPath, defaultPath = process.cwd()) {
74
113
  const targetPath = inputPath || defaultPath;
75
114
  return path.normalize(path.resolve(targetPath));
76
115
  }
116
+
117
+ /**
118
+ * Compute the common prefix (workspace root) from an array of folder paths.
119
+ * This is useful for finding a single workspace root from multiple allowed folders.
120
+ *
121
+ * IMPORTANT: This function returns a value for DISPLAY and CWD purposes only.
122
+ * It is NOT a security boundary. All security checks should be performed against
123
+ * the original allowedFolders array, not against workspaceRoot.
124
+ *
125
+ * When no common prefix exists (e.g., unrelated paths), returns the first folder.
126
+ * This is intentional - the caller should use allowedFolders for security validation.
127
+ *
128
+ * Examples:
129
+ * - ['/tmp/ws/tyk', '/tmp/ws/tyk-docs'] -> '/tmp/ws'
130
+ * - ['/tmp/ws/tyk'] -> '/tmp/ws/tyk'
131
+ * - ['/a/b', '/c/d'] -> '/a/b' (no common prefix, returns first folder for cwd)
132
+ * - ['C:\\Users\\ws\\tyk', 'C:\\Users\\ws\\docs'] -> 'C:\\Users\\ws' (Windows)
133
+ *
134
+ * @param {string[]} folders - Array of absolute folder paths
135
+ * @returns {string} Common prefix path (for display/cwd, NOT security boundary)
136
+ */
137
+ export function getCommonPrefix(folders) {
138
+ if (!folders || folders.length === 0) {
139
+ return process.cwd();
140
+ }
141
+
142
+ if (folders.length === 1) {
143
+ // Resolve symlinks for security
144
+ return safeRealpath(folders[0]);
145
+ }
146
+
147
+ // Resolve symlinks and normalize all paths to handle mixed separators
148
+ // This prevents symlink bypass attacks where a symlink could point outside the workspace
149
+ const normalized = folders.map(f => safeRealpath(f));
150
+
151
+ // Split into segments
152
+ const segments = normalized.map(f => f.split(path.sep));
153
+
154
+ // Find minimum length
155
+ const minLen = Math.min(...segments.map(s => s.length));
156
+
157
+ // Find common prefix segments
158
+ const commonSegments = [];
159
+ for (let i = 0; i < minLen; i++) {
160
+ const segment = segments[0][i];
161
+ if (segments.every(s => s[i] === segment)) {
162
+ commonSegments.push(segment);
163
+ } else {
164
+ break;
165
+ }
166
+ }
167
+
168
+ // Handle edge cases
169
+ if (commonSegments.length === 0) {
170
+ // No common prefix at all, return first folder
171
+ return normalized[0];
172
+ }
173
+
174
+ // Handle Windows drive letters (e.g., 'C:')
175
+ // If only the drive letter is common, return first folder for more useful context
176
+ if (commonSegments.length === 1 && /^[a-zA-Z]:$/.test(commonSegments[0])) {
177
+ return normalized[0];
178
+ }
179
+
180
+ // Handle Unix root (empty string from split)
181
+ // If only the root '/' is common, return first folder for more useful context
182
+ if (commonSegments.length === 1 && commonSegments[0] === '') {
183
+ return normalized[0];
184
+ }
185
+
186
+ return commonSegments.join(path.sep);
187
+ }
188
+
189
+ /**
190
+ * Convert an absolute path to a relative path from the workspace root.
191
+ * Returns the original path if it cannot be made relative (outside workspace).
192
+ *
193
+ * @param {string} absolutePath - Absolute path to convert
194
+ * @param {string} workspaceRoot - Workspace root to compute relative path from
195
+ * @returns {string} Relative path or original if outside workspace
196
+ */
197
+ export function toRelativePath(absolutePath, workspaceRoot) {
198
+ if (!absolutePath || !workspaceRoot) {
199
+ return absolutePath;
200
+ }
201
+
202
+ // Resolve symlinks for security to prevent bypass attacks
203
+ // Use safeRealpath which falls back to normalized path if resolution fails
204
+ let normalized = safeRealpath(absolutePath);
205
+ let normalizedRoot = safeRealpath(workspaceRoot);
206
+
207
+ // Remove trailing separators (path.normalize doesn't always do this)
208
+ while (normalizedRoot.length > 1 && normalizedRoot.endsWith(path.sep)) {
209
+ normalizedRoot = normalizedRoot.slice(0, -1);
210
+ }
211
+
212
+ // Check if path is within workspace (exact match or starts with root + separator)
213
+ if (normalized === normalizedRoot) {
214
+ return '.';
215
+ }
216
+
217
+ if (normalized.startsWith(normalizedRoot + path.sep)) {
218
+ return path.relative(normalizedRoot, normalized);
219
+ }
220
+
221
+ // Path is outside workspace, return as-is
222
+ return absolutePath;
223
+ }