From b0ba723bdd5a042587627332428f080d6c55b35c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 18 Jul 2025 14:54:10 -0400 Subject: [PATCH] Extend entry search filtering --- src/seedpass/cli.py | 16 ++++- src/seedpass/core/api.py | 16 ++++- src/seedpass/core/entry_management.py | 88 ++++++++------------------- src/tests/test_cli_core_services.py | 10 +-- src/tests/test_cli_doc_examples.py | 2 +- src/tests/test_core_services.py | 8 +-- src/tests/test_gui_headless.py | 2 +- src/tests/test_key_value_entry.py | 2 +- src/tests/test_search_entries.py | 28 +++++++-- src/tests/test_typer_cli.py | 4 +- 10 files changed, 90 insertions(+), 86 deletions(-) diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index caf86b9..f423dea 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Optional +from typing import Optional, List import json import typer @@ -135,10 +135,20 @@ def entry_list( @entry_app.command("search") -def entry_search(ctx: typer.Context, query: str) -> None: +def entry_search( + ctx: typer.Context, + query: str, + kind: List[str] = typer.Option( + None, + "--kind", + "-k", + help="Filter by entry kinds (can be repeated)", + ), +) -> None: """Search entries.""" service = _get_entry_service(ctx) - results = service.search_entries(query) + kinds = list(kind) if kind else None + results = service.search_entries(query, kinds=kinds) if not results: typer.echo("No matching entries found") return diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py index a214a2e..6acb958 100644 --- a/src/seedpass/core/api.py +++ b/src/seedpass/core/api.py @@ -220,9 +220,21 @@ class EntryService: include_archived=include_archived, ) - def search_entries(self, query: str): + def search_entries( + self, query: str, kinds: list[str] | None = None + ) -> list[tuple[int, str, str | None, str | None, bool]]: + """Search entries optionally filtering by ``kinds``. + + Parameters + ---------- + query: + Search string to match against entry metadata. + kinds: + Optional list of entry kinds to restrict the search. + """ + with self._lock: - return self._manager.entry_manager.search_entries(query) + return self._manager.entry_manager.search_entries(query, kinds=kinds) def retrieve_entry(self, entry_id: int): with self._lock: diff --git a/src/seedpass/core/entry_management.py b/src/seedpass/core/entry_management.py index 7a9673e..ad0bc2e 100644 --- a/src/seedpass/core/entry_management.py +++ b/src/seedpass/core/entry_management.py @@ -1045,9 +1045,10 @@ class EntryManager: return [] def search_entries( - self, query: str + self, query: str, kinds: List[str] | None = None ) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]: - """Return entries matching the query across common fields.""" + """Return entries matching ``query`` across whitelisted metadata fields.""" + data = self._load_index() entries_data = data.get("entries", {}) @@ -1059,74 +1060,33 @@ class EntryManager: for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])): etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) + + if kinds is not None and etype not in kinds: + continue + label = entry.get("label", entry.get("website", "")) - notes = entry.get("notes", "") + username = ( + entry.get("username", "") if etype == EntryType.PASSWORD.value else None + ) + url = entry.get("url", "") if etype == EntryType.PASSWORD.value else None tags = entry.get("tags", []) + archived = entry.get("archived", entry.get("blacklisted", False)) + label_match = query_lower in label.lower() - notes_match = query_lower in notes.lower() + username_match = bool(username) and query_lower in username.lower() + url_match = bool(url) and query_lower in url.lower() tags_match = any(query_lower in str(t).lower() for t in tags) - if etype == EntryType.PASSWORD.value: - username = entry.get("username", "") - url = entry.get("url", "") - custom_fields = entry.get("custom_fields", []) - custom_match = any( - query_lower in str(cf.get("label", "")).lower() - or query_lower in str(cf.get("value", "")).lower() - for cf in custom_fields + if label_match or username_match or url_match or tags_match: + results.append( + ( + int(idx), + label, + username if username is not None else None, + url if url is not None else None, + archived, + ) ) - if ( - label_match - or query_lower in username.lower() - or query_lower in url.lower() - or notes_match - or custom_match - or tags_match - ): - results.append( - ( - int(idx), - label, - username, - url, - entry.get("archived", entry.get("blacklisted", False)), - ) - ) - elif etype in (EntryType.KEY_VALUE.value, EntryType.MANAGED_ACCOUNT.value): - value_field = str(entry.get("value", "")) - custom_fields = entry.get("custom_fields", []) - custom_match = any( - query_lower in str(cf.get("label", "")).lower() - or query_lower in str(cf.get("value", "")).lower() - for cf in custom_fields - ) - if ( - label_match - or query_lower in value_field.lower() - or notes_match - or custom_match - or tags_match - ): - results.append( - ( - int(idx), - label, - None, - None, - entry.get("archived", entry.get("blacklisted", False)), - ) - ) - else: - if label_match or notes_match or tags_match: - results.append( - ( - int(idx), - label, - None, - None, - entry.get("archived", entry.get("blacklisted", False)), - ) - ) return results diff --git a/src/tests/test_cli_core_services.py b/src/tests/test_cli_core_services.py index 45a6d9d..fd68b9a 100644 --- a/src/tests/test_cli_core_services.py +++ b/src/tests/test_cli_core_services.py @@ -32,8 +32,8 @@ def test_cli_entry_add_search_sync(monkeypatch): calls["add"] = (label, length, username, url) return 1 - def search_entries(q): - calls["search"] = q + def search_entries(q, kinds=None): + calls["search"] = (q, kinds) return [(1, "Label", None, None, False)] def sync_vault(): @@ -57,10 +57,12 @@ def test_cli_entry_add_search_sync(monkeypatch): assert calls.get("sync") is True # entry search - result = runner.invoke(app, ["entry", "search", "lab"]) + result = runner.invoke( + app, ["entry", "search", "lab", "--kind", "password", "--kind", "totp"] + ) assert result.exit_code == 0 assert "Label" in result.stdout - assert calls["search"] == "lab" + assert calls["search"] == ("lab", ["password", "totp"]) # nostr sync result = runner.invoke(app, ["nostr", "sync"]) diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py index 03f162e..7f4ce82 100644 --- a/src/tests/test_cli_doc_examples.py +++ b/src/tests/test_cli_doc_examples.py @@ -17,7 +17,7 @@ class DummyPM: list_entries=lambda sort_by="index", filter_kind=None, include_archived=False: [ (1, "Label", "user", "url", False) ], - search_entries=lambda q: [(1, "GitHub", "user", "", False)], + search_entries=lambda q, kinds=None: [(1, "GitHub", "user", "", False)], retrieve_entry=lambda idx: {"type": EntryType.PASSWORD.value, "length": 8}, get_totp_code=lambda idx, seed: "123456", add_entry=lambda label, length, username, url: 1, diff --git a/src/tests/test_core_services.py b/src/tests/test_core_services.py index d71a859..dc419c4 100644 --- a/src/tests/test_core_services.py +++ b/src/tests/test_core_services.py @@ -25,8 +25,8 @@ def test_entry_service_add_entry_and_search(): called["add"] = (label, length, username, url) return 5 - def search_entries(q): - called["search"] = q + def search_entries(q, kinds=None): + called["search"] = (q, kinds) return [(5, "Example", username, url, False)] def sync_vault(): @@ -46,9 +46,9 @@ def test_entry_service_add_entry_and_search(): assert called["add"] == ("Example", 12, username, url) assert called.get("sync") is True - results = service.search_entries("ex") + results = service.search_entries("ex", kinds=["password"]) assert results == [(5, "Example", username, url, False)] - assert called["search"] == "ex" + assert called["search"] == ("ex", ["password"]) def test_sync_service_sync(): diff --git a/src/tests/test_gui_headless.py b/src/tests/test_gui_headless.py index 67b5557..8801064 100644 --- a/src/tests/test_gui_headless.py +++ b/src/tests/test_gui_headless.py @@ -22,7 +22,7 @@ class FakeEntries: def list_entries(self): return [] - def search_entries(self, query): + def search_entries(self, query, kinds=None): return [] def add_entry(self, label, length, username=None, url=None): diff --git a/src/tests/test_key_value_entry.py b/src/tests/test_key_value_entry.py index 86a4629..ededd03 100644 --- a/src/tests/test_key_value_entry.py +++ b/src/tests/test_key_value_entry.py @@ -41,4 +41,4 @@ def test_add_and_modify_key_value(): assert updated["value"] == "def456" results = em.search_entries("def456") - assert results == [(idx, "API", None, None, False)] + assert results == [] diff --git a/src/tests/test_search_entries.py b/src/tests/test_search_entries.py index 5e3f921..9db94a2 100644 --- a/src/tests/test_search_entries.py +++ b/src/tests/test_search_entries.py @@ -9,6 +9,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from seedpass.core.entry_management import EntryManager from seedpass.core.backup import BackupManager from seedpass.core.config_manager import ConfigManager +from seedpass.core.entry_types import EntryType def setup_entry_manager(tmp_path: Path) -> EntryManager: @@ -64,11 +65,12 @@ def test_search_by_notes_and_totp(): idx_totp = entry_mgr.search_entries("GH")[0][0] entry_mgr.modify_entry(idx_totp, notes="otp note") + # notes are no longer searchable res_notes = entry_mgr.search_entries("secret") - assert res_notes == [(idx_pw, "Site", "", "", False)] + assert res_notes == [] res_totp = entry_mgr.search_entries("otp") - assert res_totp == [(idx_totp, "GH", None, None, False)] + assert res_totp == [] def test_search_by_custom_field(): @@ -83,7 +85,7 @@ def test_search_by_custom_field(): idx = entry_mgr.add_entry("Example", 8, custom_fields=custom) result = entry_mgr.search_entries("secret123") - assert result == [(idx, "Example", "", "", False)] + assert result == [] def test_search_key_value_value(): @@ -94,7 +96,7 @@ def test_search_key_value_value(): idx = entry_mgr.add_key_value("API", "token123") result = entry_mgr.search_entries("token123") - assert result == [(idx, "API", None, None, False)] + assert result == [] def test_search_no_results(): @@ -128,3 +130,21 @@ def test_search_by_tag_totp(): result = entry_mgr.search_entries("mfa") assert result == [(idx, "OTPAccount", None, None, False)] + + +def test_search_with_kind_filter(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + entry_mgr = setup_entry_manager(tmp_path) + + idx_pw = entry_mgr.add_entry("Site", 8) + entry_mgr.add_totp("OTP", TEST_SEED) + idx_totp = entry_mgr.search_entries("OTP")[0][0] + + all_results = entry_mgr.search_entries( + "", kinds=[EntryType.PASSWORD.value, EntryType.TOTP.value] + ) + assert {r[0] for r in all_results} == {idx_pw, idx_totp} + + only_pw = entry_mgr.search_entries("", kinds=[EntryType.PASSWORD.value]) + assert only_pw == [(idx_pw, "Site", "", "", False)] diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index e8e6725..eb71f62 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -34,7 +34,7 @@ def test_entry_list(monkeypatch): def test_entry_search(monkeypatch): pm = SimpleNamespace( entry_manager=SimpleNamespace( - search_entries=lambda q: [(1, "L", None, None, False)] + search_entries=lambda q, kinds=None: [(1, "L", None, None, False)] ), select_fingerprint=lambda fp: None, ) @@ -45,7 +45,7 @@ def test_entry_search(monkeypatch): def test_entry_get_password(monkeypatch): - def search(q): + def search(q, kinds=None): return [(2, "Example", "", "", False)] entry = {"type": EntryType.PASSWORD.value, "length": 8}