Repository: twisted/twisted

Issue:
IKnownHostEntry and PlainHost/HashedHost string type confusion
|[<img alt="twm's avatar" src="https://avatars.githubusercontent.com/u/43662?s=50" width="50" height="50">](https://github.com/twm)| @twm reported|
|-|-|
|Trac ID|trac#9713|
|Type|enhancement|
|Created|2019-10-14 04:31:25Z|

`twisted.conch.interfaces.IKnownHostEntry.matchesHost()`'s `hostname` argument is documented as type `str`, but the implementations disagree:

`twisted.conch.client.knownhosts.PlainHost` and `twisted.conch.client.knownhosts.HashedHost` both document the parameter as type `bytes`. The implementations accept both `str` _and_ bytes (`str` is encoded to `utf-8`j).

What string type does the actual client implementation pass? That is, if someone implements `IKnownHostEntry` according to the interface, will it work with the Conch client? The interface should document the type that promotes this type of compatibility. (If the interface is wrong and the type should be `bytes`, that's not a backwards-incompatible change, it's just a bug in the docs that probably occurred when porting to Python 3.)

<details><summary>Searchable metadata</summary>

```
trac-id__9713 9713
type__enhancement enhancement
reporter__twm twm
priority__normal normal
milestone__None None
branch__ 
branch_author__ 
status__new new
resolution__None None
component__core core
keywords__None None
time__1571027485492856 1571027485492856
changetime__1571027485492856 1571027485492856
version__None None
owner__None None

```
</details>



Discussion:

Resolving patch:
diff --git a/src/twisted/conch/client/knownhosts.py b/src/twisted/conch/client/knownhosts.py
--- a/src/twisted/conch/client/knownhosts.py
+++ b/src/twisted/conch/client/knownhosts.py
@@ -8,12 +8,14 @@
 @since: 8.2
 """
 
+from __future__ import annotations
 
 import hmac
 import sys
 from binascii import Error as DecodeError, a2b_base64, b2a_base64
 from contextlib import closing
 from hashlib import sha1
+from typing import IO, Callable, Literal
 
 from zope.interface import implementer
 
@@ -21,8 +23,10 @@
 from twisted.conch.interfaces import IKnownHostEntry
 from twisted.conch.ssh.keys import BadKeyError, FingerprintFormats, Key
 from twisted.internet import defer
+from twisted.internet.defer import Deferred
 from twisted.logger import Logger
 from twisted.python.compat import nativeString
+from twisted.python.filepath import FilePath
 from twisted.python.randbytes import secureRandom
 from twisted.python.util import FancyEqMixin
 
@@ -111,34 +115,33 @@ class PlainEntry(_BaseEntry):
     file.
 
     @ivar _hostnames: the list of all host-names associated with this entry.
-    @type _hostnames: L{list} of L{bytes}
     """
 
-    def __init__(self, hostnames, keyType, publicKey, comment):
-        self._hostnames = hostnames
+    def __init__(
+        self, hostnames: list[bytes], keyType: bytes, publicKey: Key, comment: bytes
+    ):
+        self._hostnames: list[bytes] = hostnames
         super().__init__(keyType, publicKey, comment)
 
     @classmethod
-    def fromString(cls, string):
+    def fromString(cls, string: bytes) -> PlainEntry:
         """
         Parse a plain-text entry in a known_hosts file, and return a
         corresponding L{PlainEntry}.
 
         @param string: a space-separated string formatted like "hostname
-        key-type base64-key-data comment".
-
-        @type string: L{bytes}
+            key-type base64-key-data comment".
 
         @raise DecodeError: if the key is not valid encoded as valid base64.
 
         @raise InvalidEntry: if the entry does not have the right number of
-        elements and is therefore invalid.
+            elements and is therefore invalid.
 
         @raise BadKeyError: if the key, once decoded from base64, is not
-        actually an SSH key.
+            actually an SSH key.
 
         @return: an IKnownHostEntry representing the hostname and key in the
-        input line.
+            input line.
 
         @rtype: L{PlainEntry}
         """
@@ -146,30 +149,27 @@ def fromString(cls, string):
         self = cls(hostnames.split(b","), keyType, key, comment)
         return self
 
-    def matchesHost(self, hostname):
+    def matchesHost(self, hostname: bytes | str) -> bool:
         """
         Check to see if this entry matches a given hostname.
 
         @param hostname: A hostname or IP address literal to check against this
             entry.
-        @type hostname: L{bytes}
 
         @return: C{True} if this entry is for the given hostname or IP address,
             C{False} otherwise.
-        @rtype: L{bool}
         """
         if isinstance(hostname, str):
             hostname = hostname.encode("utf-8")
         return hostname in self._hostnames
 
-    def toString(self):
+    def toString(self) -> bytes:
         """
         Implement L{IKnownHostEntry.toString} by recording the comma-separated
         hostnames, key type, and base-64 encoded key.
 
         @return: The string representation of this entry, with unhashed hostname
             information.
-        @rtype: L{bytes}
         """
         fields = [
             b",".join(self._hostnames),
@@ -256,33 +256,39 @@ class HashedEntry(_BaseEntry, FancyEqMixin):
 
     compareAttributes = ("_hostSalt", "_hostHash", "keyType", "publicKey", "comment")
 
-    def __init__(self, hostSalt, hostHash, keyType, publicKey, comment):
+    def __init__(
+        self,
+        hostSalt: bytes,
+        hostHash: bytes,
+        keyType: bytes,
+        publicKey: Key,
+        comment: bytes | None,
+    ) -> None:
         self._hostSalt = hostSalt
         self._hostHash = hostHash
         super().__init__(keyType, publicKey, comment)
 
     @classmethod
-    def fromString(cls, string):
+    def fromString(cls, string: bytes) -> HashedEntry:
         """
         Load a hashed entry from a string representing a line in a known_hosts
         file.
 
         @param string: A complete single line from a I{known_hosts} file,
             formatted as defined by OpenSSH.
-        @type string: L{bytes}
 
         @raise DecodeError: if the key, the hostname, or the is not valid
             encoded as valid base64
 
         @raise InvalidEntry: if the entry does not have the right number of
-            elements and is therefore invalid, or the host/hash portion contains
-            more items than just the host and hash.
+            elements and is therefore invalid, or the host/hash portion
+            contains more items than just the host and hash.
 
         @raise BadKeyError: if the key, once decoded from base64, is not
             actually an SSH key.
 
-        @return: The newly created L{HashedEntry} instance, initialized with the
-            information from C{string}.
+        @return: The newly created L{HashedEntry} instance, initialized with
+            the information from C{string}.
         """
         stuff, keyType, key, comment = _extractCommon(string)
         saltAndHash = stuff[len(cls.MAGIC) :].split(b"|")
@@ -346,7 +352,7 @@ class KnownHostsFile:
     @ivar _savePath: See C{savePath} parameter of L{__init__}.
     """
 
-    def __init__(self, savePath):
+    def __init__(self, savePath: FilePath[str]) -> None:
         """
         Create a new, empty KnownHostsFile.
 
@@ -356,12 +362,12 @@ def __init__(self, savePath):
         @param savePath: The L{FilePath} to which to save new entries.
         @type savePath: L{FilePath}
         """
-        self._added = []
+        self._added: list[IKnownHostEntry] = []
         self._savePath = savePath
         self._clobber = True
 
     @property
-    def savePath(self):
+    def savePath(self) -> FilePath[str]:
         """
         @see: C{savePath} parameter of L{__init__}
         """
@@ -431,7 +437,9 @@ def hasHostKey(self, hostname, key):
                     raise HostKeyChanged(entry, path, line)
         return False
 
-    def verifyHostKey(self, ui, hostname, ip, key):
+    def verifyHostKey(
+        self, ui: ConsoleUI, hostname: bytes, ip: bytes, key: Key
+    ) -> Deferred[bool]:
         """
         Verify the given host key for the given IP and host, asking for
         confirmation from, and notifying, the given UI about changes to this
@@ -453,20 +461,21 @@ def verifyHostKey(self, ui, hostname, ip, key):
         """
         hhk = defer.execute(self.hasHostKey, hostname, key)
 
-        def gotHasKey(result):
+        def gotHasKey(result: bool) -> bool | Deferred[bool]:
             if result:
                 if not self.hasHostKey(ip, key):
-                    ui.warn(
-                        "Warning: Permanently added the %s host key for "
-                        "IP address '%s' to the list of known hosts."
-                        % (key.type(), nativeString(ip))
+                    addMessage = (
+                        f"Warning: Permanently added the {key.type()} host key"
+                        f" for IP address '{ip.decode()}' to the list of known"
+                        " hosts.\n"
                     )
+                    ui.warn(addMessage.encode("utf-8"))
                     self.addHostKey(ip, key)
                     self.save()
                 return result
             else:
 
-                def promptResponse(response):
+                def promptResponse(response: bool) -> bool:
                     if response:
                         self.addHostKey(hostname, key)
                         self.addHostKey(ip, key)
@@ -475,7 +484,7 @@ def promptResponse(response):
                     else:
                         raise UserRejectedKey()
 
-                keytype = key.type()
+                keytype: str = key.type()
 
                 if keytype == "EC":
                     keytype = "ECDSA"
@@ -497,7 +506,7 @@ def promptResponse(response):
 
         return hhk.addCallback(gotHasKey)
 
-    def addHostKey(self, hostname, key):
+    def addHostKey(self, hostname: bytes, key: Key) -> HashedEntry:
         """
         Add a new L{HashedEntry} to the key database.
 
@@ -520,7 +529,7 @@ def addHostKey(self, hostname, key):
         self._added.append(entry)
         return entry
 
-    def save(self):
+    def save(self) -> None:
         """
         Save this L{KnownHostsFile} to the path it was loaded from.
         """
@@ -528,11 +537,7 @@ def save(self):
         if not p.isdir():
             p.makedirs()
 
-        if self._clobber:
-            mode = "wb"
-        else:
-            mode = "ab"
-
+        mode: Literal["a", "w"] = "w" if self._clobber else "a"
         with self._savePath.open(mode) as hostsFileObj:
             if self._added:
                 hostsFileObj.write(
@@ -542,7 +547,7 @@ def save(self):
         self._clobber = False
 
     @classmethod
-    def fromPath(cls, path):
+    def fromPath(cls, path: FilePath[str]) -> KnownHostsFile:
         """
         Create a new L{KnownHostsFile}, potentially reading existing known
         hosts information from the given file.
@@ -550,10 +555,8 @@ def fromPath(cls, path):
         @param path: A path object to use for both reading contents from and
             later saving to.  If no file exists at this path, it is not an
             error; a L{KnownHostsFile} with no entries is returned.
-        @type path: L{FilePath}
 
         @return: A L{KnownHostsFile} initialized with entries from C{path}.
-        @rtype: L{KnownHostsFile}
         """
         knownHosts = cls(path)
         knownHosts._clobber = False
@@ -566,7 +569,7 @@ class ConsoleUI:
     console, to be used during key verification.
     """
 
-    def __init__(self, opener):
+    def __init__(self, opener: Callable[[], IO[bytes]]):
         """
         @param opener: A no-argument callable which should open a console
             binary-mode file-like object to be used for reading and writing.
@@ -576,7 +579,7 @@ def __init__(self, opener):
         """
         self.opener = opener
 
-    def prompt(self, text):
+    def prompt(self, text: bytes) -> Deferred[bool]:
         """
         Write the given text as a prompt to the console output, then read a
         result from the console input.
@@ -598,20 +601,19 @@ def body(ignored):
                     answer = f.readline().strip().lower()
                     if answer == b"yes":
                         return True
-                    elif answer == b"no":
+                    elif answer in {b"no", b""}:
                         return False
                     else:
                         f.write(b"Please type 'yes' or 'no': ")
 
         return d.addCallback(body)
 
-    def warn(self, text):
+    def warn(self, text: bytes) -> None:
         """
         Notify the user (non-interactively) of the provided text, by writing it
         to the console.
 
         @param text: Some information the user is to be made aware of.
-        @type text: L{bytes}
         """
         try:
             with closing(self.opener()) as f:
diff --git a/src/twisted/conch/endpoints.py b/src/twisted/conch/endpoints.py
--- a/src/twisted/conch/endpoints.py
+++ b/src/twisted/conch/endpoints.py
@@ -6,11 +6,19 @@
 Endpoint implementations of various SSH interactions.
 """
 
-__all__ = ["AuthenticationFailed", "SSHCommandAddress", "SSHCommandClientEndpoint"]
+from __future__ import annotations
+
+__all__ = [
+    "AuthenticationFailed",
+    "SSHCommandAddress",
+    "SSHCommandClientEndpoint",
+]
 
 import signal
+from io import BytesIO
 from os.path import expanduser
 from struct import unpack
+from typing import IO, Any
 
 from zope.interface import Interface, implementer
 
@@ -689,44 +697,6 @@ def disconnectOnFailure(passthrough):
         return commandConnected
 
 
-class _ReadFile:
-    """
-    A weakly file-like object which can be used with L{KnownHostsFile} to
-    respond in the negative to all prompts for decisions.
-    """
-
-    def __init__(self, contents):
-        """
-        @param contents: L{bytes} which will be returned from every C{readline}
-            call.
-        """
-        self._contents = contents
-
-    def write(self, data):
-        """
-        No-op.
-
-        @param data: ignored
-        """
-
-    def readline(self, count=-1):
-        """
-        Always give back the byte string that this L{_ReadFile} was initialized
-        with.
-
-        @param count: ignored
-
-        @return: A fixed byte-string.
-        @rtype: L{bytes}
-        """
-        return self._contents
-
-    def close(self):
-        """
-        No-op.
-        """
-
-
 @implementer(_ISSHConnectionCreator)
 class _NewConnectionHelper:
     """
@@ -739,17 +709,17 @@ class _NewConnectionHelper:
 
     def __init__(
         self,
-        reactor,
-        hostname,
-        port,
-        command,
-        username,
-        keys,
-        password,
-        agentEndpoint,
-        knownHosts,
-        ui,
-        tty=FilePath(b"/dev/tty"),
+        reactor: Any,
+        hostname: str,
+        port: int,
+        command: str,
+        username: str,
+        keys: str,
+        password: str,
+        agentEndpoint: str,
+        knownHosts: str | None,
+        ui: ConsoleUI | None,
+        tty: FilePath[bytes] | FilePath[str] = FilePath(b"/dev/tty"),
     ):
         """
         @param tty: The path of the tty device to use in case C{ui} is L{None}.
@@ -773,9 +743,9 @@ def __init__(
         if ui is None:
             ui = ConsoleUI(self._opener)
         self.ui = ui
-        self.tty = tty
+        self.tty: FilePath[bytes] | FilePath[str] = tty
 
-    def _opener(self):
+    def _opener(self) -> IO[bytes]:
         """
         Open the tty if possible, otherwise give back a file-like object from
         which C{b"no"} can be read.
@@ -783,11 +753,11 @@ def _opener(self):
         For use as the opener argument to L{ConsoleUI}.
         """
         try:
-            return self.tty.open("rb+")
+            return self.tty.open("r+")
         except BaseException:
             # Give back a file-like object from which can be read a byte string
             # that KnownHostsFile recognizes as rejecting some option (b"no").
-            return _ReadFile(b"no")
+            return BytesIO(b"no")
 
     @classmethod
     def _knownHosts(cls):
diff --git a/src/twisted/conch/interfaces.py b/src/twisted/conch/interfaces.py
--- a/src/twisted/conch/interfaces.py
+++ b/src/twisted/conch/interfaces.py
@@ -5,8 +5,15 @@
 This module contains interfaces defined for the L{twisted.conch} package.
 """
 
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
 from zope.interface import Attribute, Interface
 
+if TYPE_CHECKING:
+    from twisted.conch.ssh.keys import Key
+
 
 class IConchUser(Interface):
     """
@@ -363,16 +370,15 @@ class IKnownHostEntry(Interface):
     @since: 8.2
     """
 
-    def matchesKey(key):
+    def matchesKey(key: Key) -> bool:
         """
         Return True if this entry matches the given Key object, False
         otherwise.
 
         @param key: The key object to match against.
-        @type key: L{twisted.conch.ssh.keys.Key}
         """
 
-    def matchesHost(hostname):
+    def matchesHost(hostname: bytes) -> bool:
         """
         Return True if this entry matches the given hostname, False otherwise.
 
@@ -381,16 +387,12 @@ def matchesHost(hostname):
         quad string.
 
         @param hostname: The hostname to match against.
-        @type hostname: L{str}
         """
 
-    def toString():
+    def toString() -> bytes:
         """
-
         @return: a serialized string representation of this entry, suitable for
-        inclusion in a known_hosts file.  (Newline not included.)
-
-        @rtype: L{str}
+            inclusion in a known_hosts file.  (Newline not included.)
         """
 
 


Test patch:
diff --git a/src/twisted/conch/test/test_endpoints.py b/src/twisted/conch/test/test_endpoints.py
--- a/src/twisted/conch/test/test_endpoints.py
+++ b/src/twisted/conch/test/test_endpoints.py
@@ -6,8 +6,9 @@
 """
 
 import os.path
-from errno import ENOSYS
+from io import BytesIO
 from struct import pack
+from typing import IO
 
 from zope.interface import implementer
 from zope.interface.verify import verifyClass, verifyObject
@@ -52,7 +53,6 @@
         _ExistingConnectionHelper,
         _ISSHConnectionCreator,
         _NewConnectionHelper,
-        _ReadFile,
     )
     from twisted.conch.ssh import common
     from twisted.conch.ssh.agent import SSHAgentServer
@@ -1435,31 +1435,37 @@ def test_cleanupConnectionImmediately(self):
         helper.cleanupConnection(object(), True)
 
 
+class _WriteDiscarder(BytesIO):
+    def write(self, data: object, /) -> int:
+        """
+        Discard writes because we are emulating a console object, where they'd
+        go to the screen, not back into the buffer to be read like an a+ file.
+        """
+        return 0
+
+
 class _PTYPath:
     """
     A L{FilePath}-like object which can be opened to create a L{_ReadFile} with
     certain contents.
     """
 
-    def __init__(self, contents):
+    def __init__(self, contents: bytes) -> None:
         """
         @param contents: L{bytes} which will be the contents of the
             L{_ReadFile} this path can open.
         """
         self.contents = contents
 
-    def open(self, mode):
+    def open(self, mode: str) -> IO[bytes]:
         """
-        If the mode is r+, return a L{_ReadFile} with the contents given to
-        this path's initializer.
-
-        @raise OSError: If the mode is unsupported.
+        If the mode is r+, return a file with the given contents as a line.
 
-        @return: A L{_ReadFile} instance
+        @return: a buffer of the given contents, which will discard any writes
+            given to it.
         """
-        if mode == "rb+":
-            return _ReadFile(self.contents)
-        raise OSError(ENOSYS, "Function not implemented")
+        assert mode == "r+"
+        return _WriteDiscarder(self.contents)
 
 
 class NewConnectionHelperTests(TestCase):
diff --git a/src/twisted/conch/test/test_knownhosts.py b/src/twisted/conch/test/test_knownhosts.py
--- a/src/twisted/conch/test/test_knownhosts.py
+++ b/src/twisted/conch/test/test_knownhosts.py
@@ -5,6 +5,7 @@
 Tests for L{twisted.conch.client.knownhosts}.
 """
 
+from __future__ import annotations
 
 import os
 from binascii import Error as BinasciiError, a2b_base64, b2a_base64
@@ -33,6 +34,7 @@
     from twisted.conch.ssh.keys import BadKeyError, Key
     from twisted.conch.test import keydata
 else:
+    ConsoleUI = object  # type:ignore
     skip = "cryptography required for twisted.conch.knownhosts."
 
 
@@ -95,7 +97,9 @@ class EntryTestsMixin:
     www.twistedmatrix.com and an RSA key of sampleKey.
     """
 
-    def test_providesInterface(self):
+    entry: IKnownHostEntry
+
+    def test_providesInterface(self) -> None:
         """
         The given entry should provide IKnownHostEntry.
         """
@@ -141,13 +145,13 @@ class PlainEntryTests(EntryTestsMixin, TestCase):
     plaintextLine = samplePlaintextLine
     hostIPLine = sampleHostIPLine
 
-    def setUp(self):
+    def setUp(self) -> None:
         """
         Set 'entry' to a sample plain-text entry with sampleKey as its key.
         """
         self.entry = PlainEntry.fromString(self.plaintextLine)
 
-    def test_matchesHostIP(self):
+    def test_matchesHostIP(self) -> None:
         """
         A "hostname,ip" formatted line will match both the host and the IP.
         """
@@ -155,7 +159,7 @@ def test_matchesHostIP(self):
         self.assertTrue(self.entry.matchesHost(b"198.49.126.131"))
         self.test_matchesHost()
 
-    def test_toString(self):
+    def test_toString(self) -> None:
         """
         L{PlainEntry.toString} generates the serialized OpenSSL format string
         for the entry, sans newline.
@@ -186,21 +190,21 @@ class HashedEntryTests(EntryTestsMixin, ComparisonTestsMixin, TestCase):
 
     hashedLine = sampleHashedLine
 
-    def setUp(self):
+    def setUp(self) -> None:
         """
         Set 'entry' to a sample hashed entry for twistedmatrix.com with
         sampleKey as its key.
         """
         self.entry = HashedEntry.fromString(self.hashedLine)
 
-    def test_toString(self):
+    def test_toString(self) -> None:
         """
         L{HashedEntry.toString} generates the serialized OpenSSL format string
         for the entry, sans the newline.
         """
         self.assertEqual(self.entry.toString(), self.hashedLine.rstrip(b"\n"))
 
-    def test_equality(self):
+    def test_equality(self) -> None:
         """
         Two L{HashedEntry} instances compare equal if and only if they represent
         the same host and key in exactly the same way: the host salt, host hash,
@@ -266,32 +270,32 @@ class UnparsedEntryTests(TestCase, EntryTestsMixin):
     Tests for L{UnparsedEntry}
     """
 
-    def setUp(self):
+    def setUp(self) -> None:
         """
         Set up the 'entry' to be an unparsed entry for some random text.
         """
         self.entry = UnparsedEntry(b"    This is a bogus entry.  \n")
 
-    def test_fromString(self):
+    def test_fromString(self) -> None:
         """
         Creating an L{UnparsedEntry} should simply record the string it was
         passed.
         """
-        self.assertEqual(b"    This is a bogus entry.  \n", self.entry._string)
+        self.assertEqual(b"    This is a bogus entry.  ", self.entry.toString())
 
-    def test_matchesHost(self):
+    def test_matchesHost(self) -> None:
         """
         An unparsed entry can't match any hosts.
         """
         self.assertFalse(self.entry.matchesHost(b"www.twistedmatrix.com"))
 
-    def test_matchesKey(self):
+    def test_matchesKey(self) -> None:
         """
         An unparsed entry can't match any keys.
         """
         self.assertFalse(self.entry.matchesKey(Key.fromString(sampleKey)))
 
-    def test_toString(self):
+    def test_toString(self) -> None:
         """
         L{UnparsedEntry.toString} returns its input string, sans trailing
         newline.
@@ -309,20 +313,20 @@ class ParseErrorTests(TestCase):
     L{twisted.conch.ssh.keys} modules.
     """
 
-    def invalidEntryTest(self, cls):
+    def invalidEntryTest(self, cls: type[HashedEntry | PlainEntry]) -> None:
         """
         If there are fewer than three elements, C{fromString} should raise
         L{InvalidEntry}.
         """
         self.assertRaises(InvalidEntry, cls.fromString, b"invalid")
 
-    def notBase64Test(self, cls):
+    def notBase64Test(self, cls: type[HashedEntry | PlainEntry]) -> None:
         """
         If the key is not base64, C{fromString} should raise L{BinasciiError}.
         """
         self.assertRaises(BinasciiError, cls.fromString, b"x x x")
 
-    def badKeyTest(self, cls, prefix):
+    def badKeyTest(self, cls: type[HashedEntry | PlainEntry], prefix: bytes) -> None:
         """
         If the key portion of the entry is valid base64, but is not actually an
         SSH key, C{fromString} should raise L{BadKeyError}.
@@ -335,14 +339,14 @@ def badKeyTest(self, cls, prefix):
             ),
         )
 
-    def test_invalidPlainEntry(self):
+    def test_invalidPlainEntry(self) -> None:
         """
         If there are fewer than three whitespace-separated elements in an
         entry, L{PlainEntry.fromString} should raise L{InvalidEntry}.
         """
         self.invalidEntryTest(PlainEntry)
 
-    def test_invalidHashedEntry(self):
+    def test_invalidHashedEntry(self) -> None:
         """
         If there are fewer than three whitespace-separated elements in an
         entry, or the hostname salt/hash portion has more than two elements,
@@ -354,14 +358,14 @@ def test_invalidHashedEntry(self):
             InvalidEntry, HashedEntry.fromString, b" ".join([a + b"||", b, c])
         )
 
-    def test_plainNotBase64(self):
+    def test_plainNotBase64(self) -> None:
         """
         If the key portion of a plain entry is not decodable as base64,
         C{fromString} should raise L{BinasciiError}.
         """
         self.notBase64Test(PlainEntry)
 
-    def test_hashedNotBase64(self):
+    def test_hashedNotBase64(self) -> None:
         """
         If the key, host salt, or host hash portion of a hashed entry is not
         encoded, it will raise L{BinasciiError}.
@@ -385,7 +389,7 @@ def test_hashedNotBase64(self):
             BinasciiError, HashedEntry.fromString, b" ".join([b"|1|x|x", b, c])
         )
 
-    def test_hashedBadKey(self):
+    def test_hashedBadKey(self) -> None:
         """
         If the key portion of the entry is valid base64, but is not actually an
         SSH key, C{HashedEntry.fromString} should raise L{BadKeyError}.
@@ -393,7 +397,7 @@ def test_hashedBadKey(self):
         a, b, c = sampleHashedLine.split()
         self.badKeyTest(HashedEntry, a)
 
-    def test_plainBadKey(self):
+    def test_plainBadKey(self) -> None:
         """
         If the key portion of the entry is valid base64, but is not actually an
         SSH key, C{PlainEntry.fromString} should raise L{BadKeyError}.
@@ -406,7 +410,7 @@ class KnownHostsDatabaseTests(TestCase):
     Tests for L{KnownHostsFile}.
     """
 
-    def pathWithContent(self, content):
+    def pathWithContent(self, content: bytes) -> FilePath[str]:
         """
         Return a FilePath with the given initial content.
         """
@@ -416,14 +420,14 @@ def pathWithContent(self, content):
 
     def loadSampleHostsFile(
         self,
-        content=(
+        content: bytes = (
             sampleHashedLine
             + otherSamplePlaintextLine
             + b"\n# That was a blank line.\n"
             b"This is just unparseable.\n"
             b"|1|This also unparseable.\n"
         ),
-    ):
+    ) -> KnownHostsFile:
         """
         Return a sample hosts file, with keys for www.twistedmatrix.com and
         divmod.com present.
@@ -930,7 +934,7 @@ def test_verifyHostIPMismatch(self):
         d = hostsFile.verifyHostKey(ui, b"www.twistedmatrix.com", b"4.3.2.1", wrongKey)
         return self.assertFailure(d, HostKeyChanged)
 
-    def test_verifyKeyForHostAndIP(self):
+    def test_verifyKeyForHostAndIP(self) -> None:
         """
         Verifying a key where the hostname is present but the IP is not should
         result in the key being added for the IP and the user being warned
@@ -948,13 +952,13 @@ def test_verifyKeyForHostAndIP(self):
         )
         self.assertEqual(
             [
-                "Warning: Permanently added the RSA host key for IP address "
-                "'5.4.3.2' to the list of known hosts."
+                b"Warning: Permanently added the RSA host key for IP address "
+                b"'5.4.3.2' to the list of known hosts.\n"
             ],
             ui.userWarnings,
         )
 
-    def test_getHostKeyAlgorithms(self):
+    def test_getHostKeyAlgorithms(self) -> None:
         """
         For a given host, get the host key algorithms for that
         host in the known_hosts file.
@@ -1117,7 +1121,7 @@ def raiseIt():
         self.assertEqual(len(self.flushLoggedErrors(ZeroDivisionError)), 1)
 
 
-class FakeUI:
+class FakeUI(ConsoleUI):
     """
     A fake UI object, adhering to the interface expected by
     L{KnownHostsFile.verifyHostKey}
@@ -1129,12 +1133,12 @@ class FakeUI:
     @ivar promptText: the last input provided to 'prompt'.
     """
 
-    def __init__(self):
-        self.userWarnings = []
-        self.promptDeferred = None
-        self.promptText = None
+    def __init__(self) -> None:
+        self.userWarnings: list[bytes] = []
+        self.promptDeferred: Deferred[bool] | None = None
+        self.promptText: bytes | None = None
 
-    def prompt(self, text):
+    def prompt(self, text: bytes) -> Deferred[bool]:
         """
         Issue the user an interactive prompt, which they can accept or deny.
         """
@@ -1142,7 +1146,7 @@ def prompt(self, text):
         self.promptDeferred = Deferred()
         return self.promptDeferred
 
-    def warn(self, text):
+    def warn(self, text: bytes) -> None:
         """
         Issue a non-interactive warning to the user.
         """
@@ -1258,8 +1262,8 @@ def test_verifyHostButNotIP(self):
         ).addCallback(l.append)
         self.assertEqual(
             [
-                "Warning: Permanently added the RSA host key for IP address "
-                "'8.7.6.5' to the list of known hosts."
+                b"Warning: Permanently added the RSA host key for IP address "
+                b"'8.7.6.5' to the list of known hosts.\n"
             ],
             self.fakeFile.outchunks,
         )