mirror of
https://github.com/Tencent/teamai-cli.git
synced 2026-10-02 03:14:40 +08:00
fix(env): only trust verifiable && and || conditions, ignore if bodies (review)
Two more P1s from the bot's round-11 review of aaa1142, both about
referencesCandidate() trusting shell control flow it can't actually
evaluate:
- Any `&&` was treated as making its right side reachable, without
checking what the left side's condition even was. A guard like
`[ "$TERM_PROGRAM" = vscode ] && source ~/.bashrc` would mark
.bashrc reachable unconditionally, even though it only runs inside
VS Code. Also flagged: a source sitting inside a multiline `if`
body looks, line by line, identical to a top-level one.
- Folding `||`'s right side into its left statement (the round-9 fix)
went too conservative the other way: `source ~/.profile ||
source ~/.bashrc` DOES guarantee .bashrc runs when ~/.profile
doesn't exist, and the resolver was never even trying it.
Rather than growing another ad hoc regex tweak, rewrote
referencesCandidate() around what it can actually verify without a
real shell parser:
- Unconditional: a bare `. REF` / `source REF` — but nothing inside
an `if` block counts, conditional or not. An `if`'s condition is
opaque to a line scanner; trusting some conditions and not others
would just be guessing.
- Existence-gated `&&`: only the self-referential idiom
`test -f REF && . REF` / `[ -f REF ] && . REF`, where the tested
path and the sourced path are the same candidate — the one `&&`
condition this code can independently verify, by visiting that
candidate itself later in the search.
- `||` fallback: the left side always counts (always attempted);
the right side counts only when the left side's own target file
does not exist on disk — the one case an `||` fallback is
actually guaranteed to run.
Anything this can't resolve either way is never trusted: the search
just doesn't queue that candidate, and the caller falls back to the
order-based pick — at worst a harmless duplicate block (the
pre-#693-fix behavior), never a false "reachable" that would
reintroduce #682.
Verified end-to-end on a real Windows host: re-ran the core
Git-for-Windows two-pull scenario from #693 (self-referential &&,
still recognized) with no regression. Added 3 unit tests for the new
boundaries: || recognized when the left target is missing, a
non-existence && condition rejected, and a source nested inside an
if block rejected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
aaa1142729
commit
60a2da0252
@@ -248,6 +248,49 @@ describe('resolveActiveShellProfile', () => {
|
||||
await fse.writeFile(path.join(homeDir, '.bashrc'), teamaiBlock());
|
||||
expect(await resolveActiveShellProfile(envShPath, 'win32')).toBe(path.join(homeDir, '.profile'));
|
||||
});
|
||||
|
||||
// Regression (#693 review round 11): the right side of `||` genuinely is
|
||||
// guaranteed to run when the left side's own target file does not exist —
|
||||
// the one case this scanner can verify without a real shell. Failing to
|
||||
// recognize it falls back to injecting a duplicate, which round 10's fix
|
||||
// was meant to avoid for exactly this shape of line.
|
||||
it('does treat the right side of || as reachable when the left side\'s target is missing', async () => {
|
||||
await fse.writeFile(
|
||||
path.join(homeDir, '.bash_profile'),
|
||||
'source ~/.profile || source ~/.bashrc\n',
|
||||
);
|
||||
// .profile is deliberately absent.
|
||||
await fse.writeFile(path.join(homeDir, '.bashrc'), teamaiBlock());
|
||||
expect(await resolveActiveShellProfile(envShPath, 'win32')).toBe(path.join(homeDir, '.bashrc'));
|
||||
});
|
||||
|
||||
// Regression (#693 review round 11): `&&` only establishes reachability
|
||||
// when this scanner can independently verify the guarding condition — the
|
||||
// self-referential existence test. A condition testing anything else
|
||||
// (here, an environment variable) is not verifiable, so a stale block
|
||||
// behind it must not outrank a genuinely unwritten, currently-read file.
|
||||
it('does not treat a non-existence && condition as reachable', async () => {
|
||||
await fse.writeFile(
|
||||
path.join(homeDir, '.bash_profile'),
|
||||
'[ "$TERM_PROGRAM" = vscode ] && source ~/.bashrc\n',
|
||||
);
|
||||
await fse.writeFile(path.join(homeDir, '.bashrc'), teamaiBlock());
|
||||
expect(await resolveActiveShellProfile(envShPath, 'win32')).toBe(path.join(homeDir, '.bash_profile'));
|
||||
});
|
||||
|
||||
// Regression (#693 review round 11): a source line's own text looks
|
||||
// identical whether it sits at top level or three lines inside an `if`
|
||||
// block this scanner cannot evaluate. Nothing inside an `if` is trusted,
|
||||
// conditional or not, so a block only reachable through one is not
|
||||
// preferred over the order-based pick.
|
||||
it('does not treat a source nested inside an if block as reachable', async () => {
|
||||
await fse.writeFile(
|
||||
path.join(homeDir, '.bash_profile'),
|
||||
'if [ -n "$BASH_VERSION" ]; then\n . ~/.bashrc\nfi\n',
|
||||
);
|
||||
await fse.writeFile(path.join(homeDir, '.bashrc'), teamaiBlock());
|
||||
expect(await resolveActiveShellProfile(envShPath, 'win32')).toBe(path.join(homeDir, '.bash_profile'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('envBlockSourcesPath', () => {
|
||||
|
||||
+74
-30
@@ -202,38 +202,82 @@ export function envBlockReferencesDataHome(block: string, envShPath: string): bo
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `content` runs a `source`/`.` command on a home-relative reference
|
||||
* to `name` (`~/.bashrc`, `$HOME/.bashrc`, `${HOME}/.bashrc`) — the shape a
|
||||
* real forwarding line takes, e.g. Git for Windows' generated
|
||||
* `test -f ~/.bashrc && . ~/.bashrc`.
|
||||
*
|
||||
* Deliberately narrower than a substring search (#693 review round 9): that
|
||||
* matched a comment mentioning the filename (inert, never executed) and a
|
||||
* same-prefixed but different file (`~/.bashrc.local` contains `~/.bashrc`
|
||||
* as a substring). Comment lines are dropped outright; each remaining line
|
||||
* is split on `&&`/`||`/`;` into statements, and a statement only counts
|
||||
* when its first word is literally `.` or `source` and its second word is
|
||||
* exactly the home-relative reference — anchored, so a longer filename
|
||||
* cannot satisfy it by prefix.
|
||||
*/
|
||||
function referencesCandidate(content: string, name: string): boolean {
|
||||
/** `~/name`, `$HOME/name` or `${HOME}/name`, optionally quoted, as a token this scanner accepts as a reference to `name`. */
|
||||
function homeRelativeRef(name: string): string {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const target = new RegExp(`^["']?(?:~|\\$\\{?HOME\\}?)/${escaped}(?![\\w.-])["']?$`);
|
||||
return `["']?(?:~|\\$\\{?HOME\\}?)/${escaped}(?![\\w.-])["']?`;
|
||||
}
|
||||
|
||||
/** If `token` is a home-relative reference (`~/name`, `$HOME/name`, `${HOME}/name`), the real path it names. */
|
||||
function homeRelativePath(token: string, home: string): string | null {
|
||||
const stripped = token.replace(/^["']/, '').replace(/["']$/, '');
|
||||
const m = stripped.match(/^(?:~|\$\{?HOME\}?)\/(.+)$/);
|
||||
return m ? path.join(home, m[1]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `content` runs a `source`/`.` command reaching `name`
|
||||
* (`~/.bashrc`, `$HOME/.bashrc`, `${HOME}/.bashrc`), restricted to the forms
|
||||
* this scanner can reason about without a real shell parser (#693 review
|
||||
* round 11 named two more gaps a bare substring/`&&`/`;` split left open):
|
||||
*
|
||||
* - **Unconditional**: a bare `. REF` / `source REF`, as its own `;`-joined
|
||||
* statement. Nothing inside an `if` block counts, conditional or not —
|
||||
* the condition is opaque to a line scanner, so a source sitting three
|
||||
* lines under `if [ -n "$BASH_VERSION" ]; then` is no more verifiable
|
||||
* than one under `if [ "$TERM_PROGRAM" = vscode ]; then`, and trusting
|
||||
* either would risk the same false "reachable" #682 regression the
|
||||
* sticky resolver exists to prevent.
|
||||
* - **Existence-gated**: `test -f REF && . REF` / `[ -f REF ] && . REF`,
|
||||
* self-referential only — the tested path and the sourced path must both
|
||||
* be `name`, the one condition this scanner can independently verify (by
|
||||
* visiting that candidate itself later in the search). A condition
|
||||
* testing anything else grants nothing.
|
||||
* - **`||` fallback**: `A || B`, where `A` is a source of some other
|
||||
* candidate. The left side of `||` is always attempted, so it counts
|
||||
* unconditionally; the right side runs only if the left one fails, which
|
||||
* is verifiable in exactly one case — `A`'s own target does not exist on
|
||||
* disk — so `B` counts only then.
|
||||
*
|
||||
* A file, or a shell construct, this cannot resolve one way or the other is
|
||||
* never trusted either way: the caller falls back to the order-based pick,
|
||||
* which is safe (at worst a harmless duplicate block, the pre-#693-fix
|
||||
* behavior) — never a false "reachable" that would silently reintroduce
|
||||
* #682.
|
||||
*/
|
||||
async function referencesCandidate(content: string, name: string, home: string): Promise<boolean> {
|
||||
const ref = homeRelativeRef(name);
|
||||
const refOnly = new RegExp(`^${ref}$`);
|
||||
const existenceGated = new RegExp(
|
||||
`^(?:test\\s+-f\\s+${ref}|\\[\\s+-f\\s+${ref}\\s*\\])\\s*&&\\s*(?:\\.|source)\\s+${ref}$`,
|
||||
);
|
||||
const sourceOf = /^(?:\.|source)\s+(\S+)$/;
|
||||
|
||||
let ifDepth = 0;
|
||||
for (const rawLine of content.split('\n')) {
|
||||
if (rawLine.trimStart().startsWith('#')) continue;
|
||||
// Only `&&`/`;` split into statements that are still unconditionally
|
||||
// attempted (or gated on the referenced candidate's own existence, which
|
||||
// is independently re-checked by reading that candidate). `||`'s right
|
||||
// side runs only if its left side fails — something not established
|
||||
// here — so it is left folded into the same statement as its left side:
|
||||
// that statement's first `.`/`source` command (the unconditional one)
|
||||
// still matches, but a `source` sitting only after `||` never does.
|
||||
for (const statement of rawLine.split(/&&|;/)) {
|
||||
const tokens = statement.trim().split(/\s+/);
|
||||
if (tokens.length >= 2 && (tokens[0] === '.' || tokens[0] === 'source') && target.test(tokens[1])) {
|
||||
return true;
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
if (/^if\b/.test(line)) { ifDepth += 1; continue; }
|
||||
if (/^fi\b/.test(line)) { ifDepth = Math.max(0, ifDepth - 1); continue; }
|
||||
if (ifDepth > 0) continue;
|
||||
|
||||
for (const statement of line.split(';').map((s) => s.trim()).filter(Boolean)) {
|
||||
if (existenceGated.test(statement)) return true;
|
||||
|
||||
const orParts = statement.split('||').map((s) => s.trim());
|
||||
if (orParts.length === 2) {
|
||||
const left = orParts[0].match(sourceOf);
|
||||
const right = orParts[1].match(sourceOf);
|
||||
if (left && refOnly.test(left[1])) return true;
|
||||
if (left && right && refOnly.test(right[1])) {
|
||||
const leftPath = homeRelativePath(left[1], home);
|
||||
if (leftPath && !(await pathExists(leftPath))) return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const plain = statement.match(sourceOf);
|
||||
if (plain && refOnly.test(plain[1])) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -291,7 +335,7 @@ export async function resolveActiveShellProfile(
|
||||
|
||||
for (const name of SHELL_PROFILE_CANDIDATE_NAMES) {
|
||||
const candidate = path.join(home, name);
|
||||
if (candidate !== current && !visited.has(candidate) && referencesCandidate(content, name)) {
|
||||
if (candidate !== current && !visited.has(candidate) && await referencesCandidate(content, name, home)) {
|
||||
queue.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user