Skip to content
hollow

Engineering

Zero dependencies

Not “almost”, and not “one small helper”. The go.mod is a module line and a go line: no require block, no go.sum, no vendor/. Building it fetches nothing, resolves nothing, and trusts nothing.

The commands a reader can rerun

A standard library import path never has a dot in its first element, so filtering out this module leaves third-party imports as the only lines that could have one. There are none.

deps-proof.txt
$ cat go.modmodule github.com/DevInIndia/hollowgo 1.25$ ls go.sum vendorls: cannot access 'go.sum': No such file or directoryls: cannot access 'vendor': No such file or directory$ go list -deps ./... | grep '^[^/]*\.'(no output: nothing outside the standard library)

One line of the full output needs a word: vendor/golang.org/x/net/dns/dnsmessage. That is not a dependency of this project — it is part of the Go distribution, vendored inside the standard library, and package net imports it for the pure-Go resolver. It carries the vendor/ prefix precisely because it ships with the toolchain rather than being fetched.

make verify fails the build if any of this stops being true, so the claim is a gate rather than a promise.

What that costs, and what it buys

17

modules avoided

9,984

lines of Go

11,033

lines of test

38.4M

fuzz executions

The nine packages with published importer counts below are seven modules. Their own go.mod files, fetched from proxy.golang.org, pull in ten more, and bubbletea alone declares another seventeen. That is 17 third-party modules before the terminal UI, against 0 here — and the figure is a floor, since the manifests were expanded one level rather than to a full transitive closure.

All 41 substitutions

Written when they were earned rather than assembled at the end, which is why each row records a cost rather than restating a substitution.

Standard library log

41 substitutions, each with what it cost

Every package a conventional Go build of this project would have installed, what stands in for it, and the price. An entry that only restates the substitution is not worth reading, so none of them do.

  • github.com/miekg/dns16,234 importers

    encoding/binary, net/netip, hand-written codec in internal/wire

    1,341 lines of codec and 1,925 lines of test to reach the starting line miekg gives you for free. Two name-compression bugs shipped before the fuzz target caught them: keys that folded case, then keys that joined labels with a dot, which collided the one-label name a.b with the two-label pair a, b and made the encoder silently rewrite the second name into the first. Both were reachable from the network.

  • github.com/spf13/cobra, urfave/cli, spf13/pflag195,884 importers

    flag and a verb switch in cmd/hollow

    No shell completion, no nested subcommands, and the usage text is written by hand. flag.ContinueOnError with an explicit SetOutput was needed so a bad flag returns an exit code instead of calling os.Exit, which is the only reason the resolve verb is testable at all.

  • github.com/stretchr/testify34,886 importers

    testing, testing/synctest, reflect.DeepEqual

    10,960 lines of test for 9,912 lines of code, every assertion spelled out as an if and an Errorf. testing/synctest removed the tolerance windows around deadlines: inside its bubble the clock is fake, so a read that should unblock after exactly three seconds is asserted at exactly three seconds. Its limit is sharp — a goroutine parked on a real socket is never durably blocked, so the fake clock never advances and the test hangs rather than fails.

  • github.com/davecgh/go-spew

    fmt with %+v and % x

    Comparing two 509-octet messages by eye during the compression bug, with no structural diff to lean on. `% x` on the encoder's buffer is what exposed the c0 0c pointer that should not have been there.

  • github.com/olekukonko/tablewriter

    text/tabwriter

    tabwriter aligns only runs of lines with the same cell count, so the dig-style output had to be ordered so that comment lines break the runs deliberately, and the padding was chosen by looking at real output rather than configured.

  • github.com/google/uuid, math/rand seeding for query IDs

    crypto/rand

    Almost nothing: crypto/rand.Read cannot fail as of Go 1.24, so a transaction ID is three lines. What it exposed is that the ID is only half the defence against a forged answer. The other half is the source port, which is why the UDP socket is dialled rather than opened with ListenPacket, so the kernel drops datagrams from any other address before this process sees them.

  • net.IP as a map key

    net/netip.Addr

    net.IP is a byte slice, slices are not comparable, and it therefore cannot be a map key without stringifying it first. netip.Addr is comparable and brings RFC 5952 IPv6 formatting along. The cost is cosmetic and real: it pulls unique and weak into deps-proof.txt, both of which read like third-party packages at a glance.

  • vendor/golang.org/x/net/dns/dnsmessage

    internal/wire

    This is the DNS parser the Go team vendored into the standard library for net's own resolver, and it is not importable: the vendor/ prefix scopes it to the toolchain. Writing our own was not a choice about dependencies, it was the only route.

  • github.com/pkg/errors

    errors, fmt.Errorf with %w

    No stack traces on a failed decode. Bought in exchange: eight typed sentinels that errors.Is matches, which is what lets the fuzz target assert that every rejection is a failure class the package names rather than a bare string it cannot act on.

  • github.com/json-iterator/go, mailru/easyjson

    encoding/json

    The wire types cannot be marshalled directly. RData is an interface with an unexported method, so --json needed a parallel set of view structs, about 45 lines. The upside was not planned: the JSON shape is now a deliberate contract instead of whatever the wire structs happen to look like this week.

  • github.com/google/go-cmp

    reflect.DeepEqual

    No diff on failure, so a round-trip mismatch prints two whole messages and the reader finds the differing field. Acceptable because it fires rarely. The one time it did fire, it pointed straight at a rewritten name.

  • github.com/dvyukov/go-fuzz

    testing.F

    Nothing. Native fuzzing needs no build tag, no separate corpus repository and no second toolchain. 38.4 million executions clean, and it found the compression key collision that code review and twenty hand-written malformed cases had both missed.

  • github.com/cenkalti/backoff, retry wrappers generally

    context.WithTimeout, net.Conn deadlines

    A race that took a -count run to surface. The context's timer and the socket deadline derived from it fire at the same instant, so a read can report a timeout a moment before ctx.Err() admits the context is done. Deciding which happened from the socket's error rather than from ctx.Err() is not obvious until it flakes.

  • github.com/golang/mock, testify/mock

    Real loopback listeners in the tests

    The test server has to hold the same port on UDP and TCP, which the kernel will not promise, so it binds UDP first and retries the pair on collision. Worth it: truncation and the TCP fallback run over real sockets, and it caught that closing a TCP socket holding unread data sends a reset rather than a FIN.

  • cgo and the libc resolver

    CGO_ENABLED=0 and Go's pure-Go resolver

    go test -race still needs cgo, so CGO_ENABLED=0 cannot be hoisted out of the Makefile and has to be set on each command. It also caught a defect in our own evidence: deps-proof.txt had been generated with cgo on and listed runtime/cgo for a binary nobody ships that way.

  • GOTOOLCHAIN=auto, the dependency nobody notices they have

    GOTOOLCHAIN=local

    One Makefile line, plus a caveat that has to be documented because the pin covers make and not a judge running go build ./cmd/hollow directly. Verified rather than assumed: with go 1.99 in go.mod, auto attempts a download from proxy.golang.org in the middle of the build and local refuses. A network fetch during a build that claims to need no network.

  • github.com/spf13/viper, mitchellh/go-homedir

    flag with relative defaults

    No config file, so every setting is a flag and there is nowhere to keep a long-lived preference. Deliberate rather than reluctant: no hardcoded /etc or /var path is one of the reasons the same source builds and behaves the same on Windows.

  • github.com/miekg/dns zone file parser (dns.NewZoneParser)

    bufio.Scanner, strings.Fields

    About 70 lines to read named.root for --hints, and it is deliberately not a zone parser: it reads A and AAAA records and skips everything else. Writing it surfaced two things a naive split misses — the class field is optional, so the record type is not at a fixed index, and the A and AAAA lines for one server are not adjacent, so entries have to be joined by owner name rather than by position.

  • strings.HasSuffix for bailiwick checks

    wire.Name.Within, comparing decoded labels

    The obvious implementation is one line and it is a security hole. A DNS label may contain a dot, escaped as \. in presentation form, so evil\.com is a single label that is a sibling of com, yet its bytes end in com. A suffix test accepts glue for ns1.evil\.com inside a com referral, which is precisely the cache-poisoning input the check exists to reject.

  • math/rand with manual seeding, or a shuffle helper

    math/rand/v2

    Nothing at runtime: rand.Shuffle needs no seed as of v2. The cost was testability. Nameserver order is randomised so that one slow root does not dominate, which makes any assertion about which server answered non-deterministic, so Resolver carries a Shuffle field that tests set to a no-op. That knob exists only because the randomness is real.

  • github.com/patrickmn/go-cache, hashicorp/golang-lru1,261 importers

    container/list, sync.Mutex, hash/maphash

    A general cache is a map with expiry. A DNS cache is not: entries must store absolute expiry and rewrite every record's TTL to the remaining seconds on the way out, or two dig calls one second apart show the same countdown and the illusion collapses. maphash with a per-cache random seed is what stops an attacker choosing names that all land in one shard. Measured: example.com 268 ms cold, 0 ms warm with the TTL counting down.

  • golang.org/x/sync/singleflight3,802 importers

    internal/single, about 130 lines

    Generic over the key as well as the value, which the upstream is not: the recursor keys on the wire.Question it already holds instead of building a string per query. The map entry must be removed before the channel is closed, or a caller arriving in the window between the two attaches to a finished call and is handed a result computed before it asked.

  • github.com/prometheus/client_golang, rcrowley/go-metrics

    sync/atomic.Uint64, hash/maphash.Comparable, math/rand/v2

    The counters are four lines. What a metrics library would have decided quietly is now a section of the README: the event broadcast drops rather than blocks, the top-N lists are sorted at snapshot rather than kept sorted per query, and the name counters refuse new keys at a cap so a random subdomain flood cannot grow them without bound. Percentiles are Algorithm R over 1024 samples, so memory does not track uptime.

  • github.com/AdguardTeam/urlfilter, github.com/kevinburke/hostsfile

    bufio.Reader, strings.Fields, net/netip.ParseAddr, two maps

    About 150 lines of parser for the three formats published lists actually use. The hosts preamble is skipped by name, because localhost, localhost.localdomain, local and broadcasthost all sit in field two of a real list and a resolver that blocks localhost breaks its own machine. bufio.Scanner was the obvious reader and is wrong here: past its buffer limit one absurd line would truncate the rest of the file while the load reported success. Measured: 79,746 entries and 5.5 MB.

  • github.com/miekg/dns server side (dns.Server, dns.ServeMux)

    net.ListenPacket, net.Listen, and 747 lines in internal/server

    The framing and the pool are the easy part. What has to be decided by hand is everything the library decides for you: that a full queue drops rather than blocks, that a reply to a message with QR set is never sent, that the additional section from upstream is not forwarded, and that a partial bind is fatal.

  • github.com/sirupsen/logrus, go.uber.org/zap239,958 importers

    log/slog

    Almost nothing to adopt: a TextHandler over stderr with a level is four lines. The cost was a design decision it forced into the open. A dropped UDP packet is exactly the event you want logged and exactly the event that arrives ten thousand at a time, so logging every drop converts a packet flood into a disk flood. Only the first is logged, the rest are counted, and the total is reported at shutdown.

  • golang.org/x/sync/errgroup, github.com/hashicorp/go-multierror

    sync.WaitGroup, a channel, and errors.Join

    errgroup returns the first error, which is the wrong shape here: both transports can fail and which one is reported should not depend on goroutine scheduling. errors.Join returns both and drops nils, so a clean shutdown still returns nil without a special case.

  • golang.org/x/sync/semaphore

    a buffered channel of empty structs

    A select with a default over a buffered channel is a non-blocking acquire, which is what the TCP connection cap needs: over the cap the connection is closed at once rather than queued, so the client learns instead of waiting. semaphore.Weighted has TryAcquire for this and nothing else to offer at weight one.

  • github.com/valyala/bytebufferpool, hand-rolled buffer rings

    sync.Pool

    One subtlety that costs an allocation if missed: a []byte put into a pool is boxed into an interface and escapes, which allocates on every Put and defeats the pool. Pooling *[]byte avoids it. The other cost is ownership discipline that no type can enforce, since the buffer is handed from the reader to a worker over a channel and exactly one of them must return it, on every path including the dropped one.

  • github.com/coredns/coredns forward plugin, miekg/dns client

    internal/resolver.Forwarder, about 100 lines over Transport

    The exchange itself is the transport that was already there with RecursionDesired set, so the code is small and the decisions are not. Forwarders are tried in the order written rather than shuffled; SERVFAIL, REFUSED and silence move to the next server but NXDOMAIN does not, since that is an answer; and netip.ParseAddrPort has to be tried before netip.ParseAddr, because ParseAddr accepts a bare IPv6 literal.

  • github.com/tsenart/vegeta, golang.org/x/perf/cmd/benchstat

    testing.B with b.RunParallel and b.ReportAllocs

    No benchstat, so runs are compared by eye and no confidence interval is claimed anywhere. The parallel form is not decoration: the cache exists to be hit by 64 workers at once, and a sequential benchmark measures the one case the sharding was not written for. A cache hit allocates three times and a miss allocates nothing, and the three are the TTL rewrite, which is the feature and not overhead to remove.

  • github.com/xlab/treeprint, github.com/disiqueira/gotree

    a recursive renderer over a flat slice of steps, about 120 lines

    A tree library wants a tree. What the resolver produces is a stream of exchanges, and turning one into the other is the whole job. A CNAME hop returns to the level this walk started at rather than nesting further — the case a generic renderer would have got wrong quietly, because a chain of three links would have marched off the right edge of the terminal looking like a delegation forty levels deep.

  • github.com/mattn/go-isatty, golang.org/x/term.IsTerminal

    os.File.Stat and a ModeCharDevice check

    Six lines, and one of the few places where the standard library answer is also the shorter one. It decides the character set: box drawing to a terminal, ASCII to a pipe, a file, or a bytes.Buffer in a test. Piping box-drawing characters into a file that a judge then opens in a console without the font is how a working tool looks broken.

  • github.com/hexops/valast, hexdump helpers generally

    wire.Annotate plus a hand-written annotation column

    encoding/hex prints octets; what makes a DNS dump worth reading is the column beside them, and that has to come from the parser or it is fiction. Annotate walks the message with the same decoder the resolver uses. The property that keeps it honest is coverage: the spans are contiguous and cover every octet, so a region nobody can name fails the test rather than being skipped over.

  • golang.org/x/time/rate14,348 importers

    internal/rrl, a token bucket per client network over container/list

    The wrong shape anyway: rate.Limiter is per limiter, and what is needed is per client network with a bounded table. Over the limit the response is dropped rather than refused, because an error is a response and a response is what an amplification attack wants. Every second one over is answered truncated instead, so a real client retries over TCP and succeeds while a spoofed source cannot complete the handshake.

  • math/rand for the 0x20 nonce and the transaction ID

    crypto/rand

    The substitution is trivial and the reason is the entire feature: a nonce an attacker can predict is not a nonce. Writing it surfaced a bug a weaker test would have shipped. Flipping the case bit with XOR looks right and is not: against a name that is already lowercase, both branches produce uppercase, so 2000 draws yielded exactly one pattern. Setting or clearing the bit rather than flipping it is the fix.

  • google.golang.org/grpc, gorilla/websocket, or net/http

    net.Listen on loopback, a four-octet length prefix, encoding/json

    A control plane is one request and a stream of records, and HTTP would bring a server, a router and a set of status-code decisions to it. The framing is the shape DNS over TCP already has two packages away, so the trap was familiar too: a length prefix is a promise about an allocation, and a reader that believes one can be told to allocate four gigabytes by four octets.

  • github.com/charmbracelet/bubbletea, gdamore/tcell, rivo/tview11,682 importers

    hand-rolled ANSI on os.Stdout, and no raw mode at all

    The libraries exist mostly to abstract raw mode, which is TCGETS and TCSETS on Linux, TIOCGETA and TIOCSETA with different constants on macOS, and SetConsoleMode on Windows. Declining to read a keypress removes all three. Colour applied before padding computes the width from the escape sequence as well and pads short, so the layout drifts on exactly the rows that are coloured; the test that caught it renders the frame twice and compares the coloured one with its sequences stripped.

  • golang.org/x/sys/windows for SetConsoleMode

    syscall.NewLazyDLL("kernel32.dll") and syscall.GetConsoleMode

    Confirmed by cross-compiling rather than assumed: syscall.GetConsoleMode exists in the standard library for Windows and syscall.SetConsoleMode does not, so the getter is a plain call and only the setter goes through the DLL. LazyDLL resolves at first call, so a Windows build that never draws a frame never touches kernel32. This is the only build tag in the repository.

  • golang.org/x/term.GetSize, github.com/nsf/termbox-go

    os.Getenv for COLUMNS and LINES, then flags, then a default

    The one place the standard library genuinely has no answer, and the README says so rather than presenting the workaround as a design. Reading a terminal's size means TIOCGWINSZ or GetConsoleScreenBufferInfo, which is the platform-specific surface this project is built without. The cost is disclosed: a terminal resized while the dashboard runs keeps the size it started with, because noticing would mean SIGWINCH, which does not exist on Windows.

  • context.WithCancel alone for shutdown

    context.WithoutCancel plus a timeout

    Not a package substitution, but the standard library grew the answer and it is worth recording. Cancelling the server context on SIGINT also cancels every resolution in flight, so a client that waited 400 ms gets nothing at the very end. Deriving each query's context with WithoutCancel lets work already begun finish while work not yet begun is discarded, and shutdown stays bounded by one query timeout rather than by the queue.

Importer counts are pkg.go.dev's “Known importers”, read 2026-08-30, and appear on the 8 packages the repository README publishes a figure for. The other 33 rows carry no number rather than one nobody checked.

Where the standard library ran out

Terminal size is the one real gap. There is no portable way to ask a terminal how large it is: on Unix it is the TIOCGWINSZ ioctl, on Windows GetConsoleScreenBufferInfo, and the standard library exposes neither. The size comes from COLUMNS and LINES, then flags, then a default. This is the one place the answer is a workaround rather than a substitution, and it is listed under limitations rather than dressed up as a design.

Setting the Windows console mode is a small one. syscall carries GetConsoleMode and not SetConsoleMode, which is an odd place to stop. The standard library provides its own way out through syscall.NewLazyDLL, so the cost was two files and one function rather than a dependency.

DNSSEC was not a stdlib problem. crypto/rsa, crypto/ecdsa, crypto/ed25519 and crypto/sha256 are all present and are all the signature arithmetic needs. Validation is absent because a chain of trust from the root, NSEC and NSEC3 denial of existence, and several algorithms with their rollovers is a multi-week build — not because anything was missing. Recording it as a gap here would be convenient and untrue.