File size: 6,658 Bytes
7a1ad33 | 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 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | /**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { validatePath } from './path-validator.js';
import { type Config } from '../config/config.js';
import { isNodeError, getErrorMessage } from './errors.js';
export interface ResolvedAtCommandPath {
absolutePath: string;
relativePath: string;
stats: {
isDirectory(): boolean;
isFile(): boolean;
};
}
/**
* Result of a path resolution attempt.
*/
export type ResolveAtCommandPathResult =
| { status: 'resolved'; resolved: ResolvedAtCommandPath }
| { status: 'unauthorized'; absolutePath: string; error: string }
| { status: 'invalid'; error: string }
| { status: 'not_found' };
/**
* Resolves a path from an @-command, ensuring it is valid and within workspace boundaries.
* Performs best-effort extraction if the input appears to be a misinterpreted log fragment.
*/
export async function resolveAtCommandPath(
pathName: string,
config: Config,
onDebugMessage: (msg: string) => void = () => {},
): Promise<ResolveAtCommandPathResult> {
const pathValidation = validatePath(pathName);
if (!pathValidation.isValid) {
// Attempt to extract a real path from the invalid fragment
const extractedPath = tryExtractPath(pathName);
if (extractedPath && extractedPath !== pathName) {
onDebugMessage(
`Identified invalid path fragment, attempting to extract path: "${extractedPath}" from "${pathName}"`,
);
// Recurse once with the extracted path.
return resolveAtCommandPath(extractedPath, config, onDebugMessage);
}
onDebugMessage(
`Skipping invalid path in @-command: ${pathName}. Reason: ${pathValidation.error}`,
);
return { status: 'invalid', error: pathValidation.error! };
}
const workspaceDirs = config.getWorkspaceContext().getDirectories();
// If it's an absolute path, we only need to check it against authorization once.
if (path.isAbsolute(pathName)) {
const validationError = config.validatePathAccess(pathName, 'read');
if (validationError) {
onDebugMessage(
`Skipping unauthorized absolute path: ${pathName}. Reason: ${validationError}`,
);
return {
status: 'unauthorized',
absolutePath: pathName,
error: validationError,
};
}
try {
const stats = await fs.stat(pathName);
// Try to find if it's within one of the workspace directories to provide a nice relative path
let relativePath = pathName;
for (const dir of workspaceDirs) {
const rel = path.relative(dir, pathName);
if (!rel.startsWith('..') && !path.isAbsolute(rel)) {
relativePath = rel;
break;
}
}
return {
status: 'resolved',
resolved: {
absolutePath: pathName,
relativePath,
stats,
},
};
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return { status: 'not_found' };
}
onDebugMessage(
`Unexpected error stating path ${pathName}: ${getErrorMessage(error)}`,
);
return { status: 'not_found' };
}
}
// For relative paths, try each workspace directory.
let lastUnauthorized: { absolutePath: string; error: string } | null = null;
for (const dir of workspaceDirs) {
const absolutePath = path.resolve(dir, pathName);
// Final workspace boundary check using centralized logic
const validationError = config.validatePathAccess(absolutePath, 'read');
if (validationError) {
onDebugMessage(
`Skipping unauthorized path: ${absolutePath}. Reason: ${validationError}`,
);
// We only care about unauthorized paths if we can't find a valid authorized one.
lastUnauthorized = { absolutePath, error: validationError };
continue;
}
try {
const stats = await fs.stat(absolutePath);
return {
status: 'resolved',
resolved: {
absolutePath,
relativePath: pathName,
stats,
},
};
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
// Expected if path is not in this directory, continue to next
continue;
}
onDebugMessage(
`Unexpected error stating path ${absolutePath}: ${getErrorMessage(error)}`,
);
}
}
if (lastUnauthorized) {
return { status: 'unauthorized', ...lastUnauthorized };
}
return { status: 'not_found' };
}
/**
* Attempts to extract a valid-looking path from a noisy string (like a log fragment).
*/
function tryExtractPath(noisyString: string): string | null {
// Split by whitespace to find individual segments
const segments = noisyString.split(/\s+/);
for (const segment of segments) {
// 1. Strip leading/trailing punctuation and quotes commonly found in logs
// We handle nested wrappers like ("path/to/file.txt") or (at src/index.ts)
let segmentToClean = segment;
const wrappers = [
'(',
')',
'[',
']',
'{',
'}',
'"',
"'",
',',
';',
'!',
'.',
];
let wasStripped = true;
while (wasStripped && segmentToClean.length > 0) {
wasStripped = false;
const firstChar = segmentToClean[0];
const lastChar = segmentToClean[segmentToClean.length - 1];
// Strip known punctuation from the start or end
if (wrappers.includes(firstChar)) {
segmentToClean = segmentToClean.slice(1);
wasStripped = true;
} else if (wrappers.includes(lastChar)) {
segmentToClean = segmentToClean.slice(0, -1);
wasStripped = true;
}
}
if (segmentToClean.length === 0) continue;
// 2. Strip trailing line/column numbers (e.g. src/main.ts:10:5)
// We handle the case where it might be wrapped in more text, e.g. at (src/index.ts:123)
const lineMatch = segmentToClean.match(/^(.+?):(\d+)(?::\d+)?/);
const pathOnly = lineMatch ? lineMatch[1] : segmentToClean;
// 3. Validate the extracted segment using centralized heuristics.
// We rely on validatePath and Config.validatePathAccess for robust checking
// rather than naive string stripping which can be bypassed or corrupt valid names.
if (validatePath(pathOnly).isValid) {
// Prioritize segments that actually look like paths (have slashes or dots)
if (
pathOnly.includes('/') ||
pathOnly.includes('\\') ||
pathOnly.includes('.')
) {
return pathOnly;
}
}
}
return null;
}
|