Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,21 +145,39 @@ def save_config(config: dict):
json.dump(config, f, indent=2)


def wordlist_path(name: str) -> Path:
valid_characters = string.ascii_letters + string.digits + "_-"
if (
not isinstance(name, str)
or not 1 <= len(name) <= 64
or any(character not in valid_characters for character in name)
):
raise ValueError(
"Invalid wordlist name: use 1-64 ASCII letters, numbers, hyphens, or underscores"
)
if WORDLIST_DIR.is_symlink():
raise ValueError("Wordlist directory must not be a symlink")
path = WORDLIST_DIR / f"{name}.txt"
if path.resolve(strict=False).parent != WORDLIST_DIR.resolve():
raise ValueError("Wordlist path resolves outside the wordlist directory")
return path


def load_custom_wordlist(name: str) -> list[str]:
wordlist_path = WORDLIST_DIR / f"{name}.txt"
if not wordlist_path.exists():
path = wordlist_path(name)
if not path.exists():
raise ValueError(f"Wordlist '{name}' not found")
with open(wordlist_path) as f:
with open(path) as f:
words = [w.strip().lower() for w in f if w.strip()]
if not words:
raise ValueError(f"Wordlist '{name}' is empty")
return words


def save_wordlist(name: str, words: list[str]):
path = wordlist_path(name)
ensure_config_dir()
wordlist_path = WORDLIST_DIR / f"{name}.txt"
with open(wordlist_path, "w") as f:
with open(path, "w") as f:
f.write("\n".join(words))


Expand Down
71 changes: 66 additions & 5 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,9 @@ def test_detect_keyboard_sequence(self):
warnings = check_password_patterns("qwe")
assert any("Keyboard" in w for w in warnings)

def test_random_password_no_warnings(self):
pwd, _ = generate_password(20, True, True, True, True)
warnings = check_password_patterns(pwd)
assert len(warnings) == 0
def test_password_without_patterns_has_no_warnings(self):
warnings = check_password_patterns("gT7!pL2@vN9#rK4$")
assert warnings == []


class TestSecureStorage:
Expand Down Expand Up @@ -280,6 +279,68 @@ def record_close(descriptor):
assert len(closed_descriptors) == 1


class TestWordlistPaths:
@staticmethod
def configure_paths(monkeypatch, tmp_path):
config_dir = tmp_path / ".passgen"
monkeypatch.setattr(main_module, "CONFIG_DIR", config_dir)
monkeypatch.setattr(main_module, "WORDLIST_DIR", config_dir / "wordlists")

def test_save_wordlist_rejects_path_traversal(self, monkeypatch, tmp_path):
self.configure_paths(monkeypatch, tmp_path)
outside_path = tmp_path / "outside.txt"

with pytest.raises(ValueError, match="Invalid wordlist name"):
main_module.save_wordlist("../outside", ["secret"])

assert not outside_path.exists()

def test_load_wordlist_rejects_symlink_escape(self, monkeypatch, tmp_path):
self.configure_paths(monkeypatch, tmp_path)
main_module.WORDLIST_DIR.mkdir(parents=True)
outside_path = tmp_path / "outside.txt"
outside_path.write_text("secret\n")
(main_module.WORDLIST_DIR / "safe.txt").symlink_to(outside_path)

with pytest.raises(ValueError, match="outside the wordlist directory"):
main_module.load_custom_wordlist("safe")

def test_valid_wordlist_round_trip(self, monkeypatch, tmp_path):
self.configure_paths(monkeypatch, tmp_path)

main_module.save_wordlist("dice-words_2026", ["Alpha", "Beta"])

assert main_module.load_custom_wordlist("dice-words_2026") == ["alpha", "beta"]

@pytest.mark.parametrize("name", ["a", "_words", "-words", "a" * 64])
def test_valid_name_boundaries(self, monkeypatch, tmp_path, name):
self.configure_paths(monkeypatch, tmp_path)

main_module.save_wordlist(name, ["Alpha"])

assert main_module.load_custom_wordlist(name) == ["alpha"]

@pytest.mark.parametrize("name", ["", "a" * 65, "two words", "café", "a.b", "a/b"])
def test_invalid_names_are_rejected(self, monkeypatch, tmp_path, name):
self.configure_paths(monkeypatch, tmp_path)

with pytest.raises(ValueError, match="Invalid wordlist name"):
main_module.save_wordlist(name, ["Alpha"])

def test_symlinked_wordlist_directory_is_rejected(self, monkeypatch, tmp_path):
self.configure_paths(monkeypatch, tmp_path)
outside_dir = tmp_path / "outside"
outside_dir.mkdir()
main_module.CONFIG_DIR.mkdir()
main_module.WORDLIST_DIR.symlink_to(outside_dir, target_is_directory=True)

with pytest.raises(ValueError, match="must not be a symlink"):
main_module.save_wordlist("safe", ["secret"])

assert not (outside_dir / "safe.txt").exists()



class TestConfigManagement:
def test_save_and_load_config(self, tmp_path):
config = {"length": 24, "upper": True, "lower": False}
Expand All @@ -305,4 +366,4 @@ def test_wordlist_all_lowercase(self):


if __name__ == "__main__":
pytest.main([__file__, "-v"])
pytest.main([__file__, "-v"])
Loading