Skip to content

tftp.listing

tftp.listing

Directory listings for the x-list extension (pytftp's own, not an RFC).

TFTP has no way to list a directory. A client that asks for one sends an RRQ for the directory with x-list=1; a server that agrees answers with x-list=1 in its OACK and transfers a listing instead of a file. A server that does not know the option ignores it (RFC 2347), so a directory then reads as "file not found", and a file is transferred without the option acknowledged -- which is how a client tells it was not a directory.

The listing is UTF-8 text, one entry per line::

<type> <size> <mtime> <name>

type is f (file) or d (directory); size is in bytes (0 for a directory); mtime is whole seconds since the epoch, or - when unknown; name runs to the end of the line, with %, CR and LF written %25, %0D and %0A.

DirectoryListing(directory, *, root=None)

Bases: BytesIO

A readable listing of directory, as a handler returns it from open_read.

The server acknowledges x-list only for a stream marked this way (lists_directories). Entries whose resolved path leaves root (symlinks pointing outside what is served) are left out, as are in-progress uploads (.name.*.part). size and mtime let the server answer tsize and x-mtime.

Source code in src/tftp/listing.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
def __init__(self, directory: str, *, root: Optional[str] = None) -> None:
    root = os.path.realpath(root or directory)
    entries = []
    with os.scandir(directory) as scan:
        for entry in scan:
            name = entry.name
            if name.startswith(".") and name.endswith(".part"):
                continue
            try:
                if entry.is_symlink():
                    real = os.path.realpath(entry.path)
                    if os.path.commonpath([real, root]) != root:
                        continue
                info = entry.stat()
                is_dir = entry.is_dir()
            except (OSError, ValueError):
                continue
            entries.append(ListEntry(name, is_dir, info.st_size, int(info.st_mtime)))
    entries.sort(key=lambda e: e.name)
    data = dumps(entries)
    super().__init__(data)
    self.size = len(data)
    self.mtime: Optional[int] = int(os.stat(directory).st_mtime)

ListEntry

Bases: NamedTuple

One directory entry of an x-list listing.

dumps(entries)

The wire form of entries; the inverse of :func:loads.

Source code in src/tftp/listing.py
64
65
66
67
68
69
70
71
72
73
def dumps(entries: Iterable[ListEntry]) -> bytes:
    """The wire form of ``entries``; the inverse of :func:`loads`."""
    lines = []
    for entry in entries:
        mtime = "-" if entry.mtime is None else str(int(entry.mtime))
        lines.append(
            "%s %d %s %s\n"
            % ("d" if entry.is_dir else "f", 0 if entry.is_dir else entry.size, mtime, _escape(entry.name))
        )
    return "".join(lines).encode("utf-8", "surrogateescape")

loads(data)

Entries from a listing; malformed lines are skipped.

A line is malformed when its name is empty, ., .. or holds a /, a backslash or a NUL (a listing never names anything outside its directory), or when its size or time is not ASCII digits.

Source code in src/tftp/listing.py
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def loads(data: bytes) -> List[ListEntry]:
    """Entries from a listing; malformed lines are skipped.

    A line is malformed when its name is empty, ``.``, ``..`` or holds a ``/``,
    a backslash or a NUL (a listing never names anything outside its
    directory), or when its size or time is not ASCII digits.
    """
    entries = []
    for line in data.decode("utf-8", "surrogateescape").split("\n"):
        fields = line.split(" ", 3)
        if len(fields) != 4 or fields[0] not in ("f", "d"):
            continue
        kind, size_text, mtime_text, name = fields
        name = _unescape(name)
        size = read_decimal(size_text)
        mtime = None if mtime_text == "-" else read_decimal(mtime_text)
        if size is None or (mtime is None and mtime_text != "-"):
            continue
        if name in ("", ".", "..") or _NOT_A_NAME.search(name):
            continue
        entries.append(ListEntry(name, kind == "d", size, mtime))
    return entries