From 3084650c278df568dfbd6d9f893aaa3b59bbefd9 Mon Sep 17 00:00:00 2001 From: phg Date: Tue, 29 Jul 2025 23:04:29 +0200 Subject: [PATCH] Enhance HostsManager to prevent modification and movement of default system entries; add is_default_entry method to HostEntry and update sorting methods to prioritize default entries. --- src/hosts/core/manager.py | 25 ++++++++++++++++ src/hosts/core/models.py | 63 +++++++++++++++++++++++++++++++++++---- src/hosts/main.py | 42 +++++++++++++------------- tests/test_main.py | 6 ++-- tests/test_manager.py | 16 +++++----- tests/test_models.py | 15 +++++----- 6 files changed, 122 insertions(+), 45 deletions(-) diff --git a/src/hosts/core/manager.py b/src/hosts/core/manager.py index 6c96c35..905e466 100644 --- a/src/hosts/core/manager.py +++ b/src/hosts/core/manager.py @@ -181,6 +181,11 @@ class HostsManager: try: entry = hosts_file.entries[index] + + # Prevent modification of default system entries + if entry.is_default_entry(): + return False, "Cannot modify default system entries" + old_state = "active" if entry.is_active else "inactive" entry.is_active = not entry.is_active new_state = "active" if entry.is_active else "inactive" @@ -207,6 +212,13 @@ class HostsManager: return False, "Cannot move entry up" try: + entry = hosts_file.entries[index] + target_entry = hosts_file.entries[index - 1] + + # Prevent moving default system entries or moving entries above default entries + if entry.is_default_entry() or target_entry.is_default_entry(): + return False, "Cannot move default system entries" + # Swap with previous entry hosts_file.entries[index], hosts_file.entries[index - 1] = \ hosts_file.entries[index - 1], hosts_file.entries[index] @@ -232,6 +244,13 @@ class HostsManager: return False, "Cannot move entry down" try: + entry = hosts_file.entries[index] + target_entry = hosts_file.entries[index + 1] + + # Prevent moving default system entries or moving entries below default entries + if entry.is_default_entry() or target_entry.is_default_entry(): + return False, "Cannot move default system entries" + # Swap with next entry hosts_file.entries[index], hosts_file.entries[index + 1] = \ hosts_file.entries[index + 1], hosts_file.entries[index] @@ -262,6 +281,12 @@ class HostsManager: return False, "Invalid entry index" try: + entry = hosts_file.entries[index] + + # Prevent modification of default system entries + if entry.is_default_entry(): + return False, "Cannot modify default system entries" + # Create new entry to validate new_entry = HostEntry( ip_address=ip_address, diff --git a/src/hosts/core/models.py b/src/hosts/core/models.py index c1ec12c..db06792 100644 --- a/src/hosts/core/models.py +++ b/src/hosts/core/models.py @@ -33,6 +33,28 @@ class HostEntry: """Validate the entry after initialization.""" self.validate() + def is_default_entry(self) -> bool: + """ + Check if this entry is a system default entry. + + Returns: + True if this is a default system entry (localhost, broadcasthost, ::1) + """ + if not self.hostnames: + return False + + canonical_hostname = self.hostnames[0] + default_entries = [ + {"ip": "127.0.0.1", "hostname": "localhost"}, + {"ip": "255.255.255.255", "hostname": "broadcasthost"}, + {"ip": "::1", "hostname": "localhost"}, + ] + + for entry in default_entries: + if entry["ip"] == self.ip_address and entry["hostname"] == canonical_hostname: + return True + return False + def validate(self) -> None: """ Validate the host entry data. @@ -176,13 +198,42 @@ class HostsFile: """Get all inactive entries.""" return [entry for entry in self.entries if not entry.is_active] - def sort_by_ip(self) -> None: - """Sort entries by IP address.""" - self.entries.sort(key=lambda entry: ipaddress.ip_address(entry.ip_address)) + def sort_by_ip(self, ascending: bool = True) -> None: + """ + Sort entries by IP address, keeping default entries on top. + + Args: + ascending: Sort in ascending order if True, descending if False + """ + def sort_key(entry): + try: + ip_str = entry.ip_address.lstrip('# ') + ip_obj = ipaddress.ip_address(ip_str) + # Default entries always come first (priority 0), others get priority 1 + priority = 0 if entry.is_default_entry() else 1 + # Create a tuple for sorting: (priority, version, ip_int) + return (priority, ip_obj.version, int(ip_obj)) + except ValueError: + # If IP parsing fails, use string comparison with high sort priority + priority = 0 if entry.is_default_entry() else 1 + return (priority, 999, entry.ip_address) + + self.entries.sort(key=sort_key, reverse=not ascending) - def sort_by_hostname(self) -> None: - """Sort entries by first hostname.""" - self.entries.sort(key=lambda entry: entry.hostnames[0].lower()) + def sort_by_hostname(self, ascending: bool = True) -> None: + """ + Sort entries by first hostname, keeping default entries on top. + + Args: + ascending: Sort in ascending order if True, descending if False + """ + def sort_key(entry): + # Default entries always come first (priority 0), others get priority 1 + priority = 0 if entry.is_default_entry() else 1 + hostname = (entry.hostnames[0] if entry.hostnames else "").lower() + return (priority, hostname) + + self.entries.sort(key=sort_key, reverse=not ascending) def find_entries_by_hostname(self, hostname: str) -> List[int]: """ diff --git a/src/hosts/main.py b/src/hosts/main.py index a0069bb..c12a678 100644 --- a/src/hosts/main.py +++ b/src/hosts/main.py @@ -240,8 +240,17 @@ class HostsManagerApp(App): # Get the canonical hostname (first hostname) canonical_hostname = entry.hostnames[0] if entry.hostnames else "" - # Add row with styling based on active status - if entry.is_active: + # Check if this is a default system entry + is_default = entry.is_default_entry() + + # Add row with styling based on active status and default entry status + if is_default: + # Default entries are always shown in dim grey regardless of active status + active_text = Text("✓" if entry.is_active else "", style="dim white") + ip_text = Text(entry.ip_address, style="dim white") + hostname_text = Text(canonical_hostname, style="dim white") + table.add_row(active_text, ip_text, hostname_text) + elif entry.is_active: # Active entries in green with checkmark active_text = Text("✓", style="bold green") ip_text = Text(entry.ip_address, style="bold green") @@ -304,6 +313,12 @@ class HostsManagerApp(App): f"Status: {'Active' if entry.is_active else 'Inactive'}", ] + # Add notice for default system entries + if entry.is_default_entry(): + details_lines.append("") + details_lines.append("⚠️ SYSTEM DEFAULT ENTRY") + details_lines.append("This is a default system entry and cannot be modified.") + if entry.comment: details_lines.append(f"Comment: {entry.comment}") @@ -379,20 +394,8 @@ class HostsManagerApp(App): self.sort_column = "ip" self.sort_ascending = True - # Sort the entries - import ipaddress - def ip_sort_key(entry): - try: - ip_str = entry.ip_address.lstrip('# ') - ip_obj = ipaddress.ip_address(ip_str) - # Create a tuple for sorting: (version, ip_int) - # This ensures IPv4 comes before IPv6, and within each version they're sorted numerically - return (ip_obj.version, int(ip_obj)) - except ValueError: - # If IP parsing fails, use string comparison with high sort priority - return (999, entry.ip_address) - - self.hosts_file.entries.sort(key=ip_sort_key, reverse=not self.sort_ascending) + # Sort the entries using the new method that keeps defaults on top + self.hosts_file.sort_by_ip(self.sort_ascending) self.populate_entries_table() direction = "ascending" if self.sort_ascending else "descending" @@ -407,11 +410,8 @@ class HostsManagerApp(App): self.sort_column = "hostname" self.sort_ascending = True - # Sort the entries - self.hosts_file.entries.sort( - key=lambda entry: (entry.hostnames[0] if entry.hostnames else "").lower(), - reverse=not self.sort_ascending - ) + # Sort the entries using the new method that keeps defaults on top + self.hosts_file.sort_by_hostname(self.sort_ascending) self.populate_entries_table() direction = "ascending" if self.sort_ascending else "descending" diff --git a/tests/test_main.py b/tests/test_main.py index 0aa06e1..90bef97 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -326,9 +326,9 @@ class TestHostsManagerApp: app.action_sort_by_ip() - # Check that entries are sorted - assert app.hosts_file.entries[0].ip_address == "10.0.0.1" - assert app.hosts_file.entries[1].ip_address == "127.0.0.1" + # Check that entries are sorted with default entries on top + assert app.hosts_file.entries[0].ip_address == "127.0.0.1" # Default entry first + assert app.hosts_file.entries[1].ip_address == "10.0.0.1" # Then sorted non-defaults assert app.hosts_file.entries[2].ip_address == "192.168.1.1" assert app.sort_column == "ip" diff --git a/tests/test_manager.py b/tests/test_manager.py index 4e479e1..d723dad 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -321,7 +321,7 @@ class TestHostsManager: manager.edit_mode = True hosts_file = HostsFile() - entry = HostEntry("127.0.0.1", ["localhost"], is_active=True) + entry = HostEntry("192.168.1.1", ["router"], is_active=True) # Non-default entry hosts_file.entries.append(entry) success, message = manager.toggle_entry(hosts_file, 0) @@ -363,7 +363,7 @@ class TestHostsManager: manager.edit_mode = True hosts_file = HostsFile() - entry1 = HostEntry("127.0.0.1", ["localhost"]) + entry1 = HostEntry("10.0.0.1", ["test1"]) # Non-default entries entry2 = HostEntry("192.168.1.1", ["router"]) hosts_file.entries.extend([entry1, entry2]) @@ -372,7 +372,7 @@ class TestHostsManager: assert success assert "moved up" in message assert hosts_file.entries[0].hostnames[0] == "router" - assert hosts_file.entries[1].hostnames[0] == "localhost" + assert hosts_file.entries[1].hostnames[0] == "test1" def test_move_entry_up_invalid_index(self): """Test moving entry up with invalid index.""" @@ -396,7 +396,7 @@ class TestHostsManager: manager.edit_mode = True hosts_file = HostsFile() - entry1 = HostEntry("127.0.0.1", ["localhost"]) + entry1 = HostEntry("10.0.0.1", ["test1"]) # Non-default entries entry2 = HostEntry("192.168.1.1", ["router"]) hosts_file.entries.extend([entry1, entry2]) @@ -405,7 +405,7 @@ class TestHostsManager: assert success assert "moved down" in message assert hosts_file.entries[0].hostnames[0] == "router" - assert hosts_file.entries[1].hostnames[0] == "localhost" + assert hosts_file.entries[1].hostnames[0] == "test1" def test_move_entry_down_invalid_index(self): """Test moving entry down with invalid index.""" @@ -429,7 +429,7 @@ class TestHostsManager: manager.edit_mode = True hosts_file = HostsFile() - entry = HostEntry("127.0.0.1", ["localhost"]) + entry = HostEntry("10.0.0.1", ["test"]) # Non-default entry hosts_file.entries.append(entry) success, message = manager.update_entry( @@ -449,7 +449,7 @@ class TestHostsManager: manager.edit_mode = True hosts_file = HostsFile() - entry = HostEntry("127.0.0.1", ["localhost"]) + entry = HostEntry("127.0.0.1", ["localhost"]) # Default entry - cannot be modified hosts_file.entries.append(entry) success, message = manager.update_entry( @@ -457,7 +457,7 @@ class TestHostsManager: ) assert not success - assert "Invalid entry data" in message + assert "Cannot modify default system entries" in message @patch('tempfile.NamedTemporaryFile') @patch('subprocess.run') diff --git a/tests/test_models.py b/tests/test_models.py index fd22f16..d5155eb 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -224,20 +224,21 @@ class TestHostsFile: assert inactive_entries[0] == inactive_entry def test_sort_by_ip(self): - """Test sorting entries by IP address.""" + """Test sorting entries by IP address with default entries on top.""" hosts_file = HostsFile() entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["router"]) - entry2 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"]) + entry2 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"]) # Default entry entry3 = HostEntry(ip_address="10.0.0.1", hostnames=["test"]) - + hosts_file.add_entry(entry1) hosts_file.add_entry(entry2) hosts_file.add_entry(entry3) - + hosts_file.sort_by_ip() - - assert hosts_file.entries[0].ip_address == "10.0.0.1" - assert hosts_file.entries[1].ip_address == "127.0.0.1" + + # Default entries should come first, then sorted non-default entries + assert hosts_file.entries[0].ip_address == "127.0.0.1" # Default entry first + assert hosts_file.entries[1].ip_address == "10.0.0.1" # Then sorted non-defaults assert hosts_file.entries[2].ip_address == "192.168.1.1" def test_sort_by_hostname(self):