Handle failures¶
Match with errors.Is rather than on message text. There are ten sentinels, and
the distinctions between them are the ones worth acting on.
path, err := client.Resolve(ctx, ref, file)
switch {
case errors.Is(err, artifacts.ErrWithdrawn):
// the channel has withdrawn this version — STOP, and escalate
case errors.Is(err, artifacts.ErrNotListed):
// not in the approved index; never approved, or the index moved on
case errors.Is(err, artifacts.ErrIndexUnavailable):
// no approval decision could be made — nothing was checked; RETRY
case errors.Is(err, artifacts.ErrStaleIndex):
// the index verified but is too old to act on — RETRY
case errors.Is(err, artifacts.ErrMalformedIndex):
// correctly signed, but not an index — the channel is broken
case errors.Is(err, artifacts.ErrTrustUnavailable):
// the anchors could not be established — nothing was checked; RETRY
case errors.Is(err, artifacts.ErrUnverified):
// the bytes arrived and could not be trusted — do NOT retry
case errors.Is(err, artifacts.ErrIdentityMismatch):
// correctly signed, for something else — substitution or replay
case errors.Is(err, artifacts.ErrNotFound):
// no such artefact-version, or no such file in it
case errors.Is(err, artifacts.ErrUnsigned):
// the version exists and its publication did not finish
case errors.Is(err, artifacts.ErrMalformedManifest):
// correctly signed, but not a manifest — the channel is broken
case err != nil:
// transport, cache, or configuration
}
What each one means for your next move¶
ErrWithdrawn — the channel has withdrawn this artefact-version. Its
signature and digest are still perfectly valid; what changed is its standing.
This is the one to escalate. A withdrawal means somebody decided the artefact should not be used — a compromised upstream release, a serious defect — and the right response is to find out why, not to retry or pin around it.
It is terminal. A withdrawn version never returns, because a correction ships as a new version. Retrying will not help and neither will waiting.
ErrNotListed — the index does not mention this artefact-version. The index
is a complete statement of what is approved, so absence means not approved
rather than not mentioned. Either it was never approved, or you are asking for
something the channel has moved past.
ErrIndexUnavailable — the index could not be fetched or verified, so no
approval decision could be made. Retry, bounded, under a context. Nothing is
known about the artefact because nothing was consulted.
Note what this makes true: the index host is a hard dependency of every resolution. That is deliberate — a check that is skipped when unreachable is skipped in exactly the conditions an attacker can create — but it is a real availability cost rather than a footnote.
ErrStaleIndex — the index verified and is too old to act on: past its
expiry, or older than one this client has already accepted.
A signature proves an index was issued, not that it is current. This is the error that stops a replayed index restoring a withdrawn version, so it is security-relevant even though it usually means the publisher's schedule slipped. Retryable — a fresh index will be along.
ErrMalformedIndex — the signature was good and the contents are not an
index. Like ErrMalformedManifest, an operational fault at the publisher rather
than an attack.
ErrTrustUnavailable — the embedded key and the WKD-served key could not be
brought into agreement, so the signature was never actually checked. Retry
this. Nothing is known about the artefact, because nothing about it was
examined. A captive portal, an intercepting proxy or a DNS failure all land
here.
Bounded retries under a context, not a loop: if the anchors stay unreachable the answer will not change, and the channel is fail-closed on purpose.
ErrUnverified — either the manifest's signature did not verify, or the
artefact's digest or length did not match the signed manifest. These are
deliberately the same error. A failed signature and mismatched bytes lead to
the same response — do not use this — and a caller branching on the difference
would be making a decision it has no business making.
Do not retry. Nothing about a second attempt makes untrusted bytes trustworthy, and a retry loop against a hostile channel is a request amplifier.
ErrIdentityMismatch — the manifest is genuine and describes a different
artefact-version. That is the signature of substitution or replay, and it is
neither a bad signature nor a missing file. Treat it as a security event: the
channel served something the publisher signed for another purpose.
ErrNotFound — the channel has no such artefact-version, or the file is not
in that version's manifest. Usually a typo or a platform that was never
published. Not retryable; the answer will not change until someone approves a
version.
ErrUnsigned — the manifest is there and the signature is not. The version
exists; the publication did not finish. That is the publisher's problem rather
than the caller's, and it is worth reporting rather than retrying.
ErrMalformedManifest — the signature was good and the contents are not
a manifest. Parsing happens only after the signature is accepted, so this means
the publisher signed something malformed. An operational fault in the channel,
not an attack, and it wants a different response: report it, don't treat it as a
security event.
Anything else — a transport error, an unexpected HTTP status, a cache that could not be written. Retryable in the ordinary way.
Failures you will only see once¶
Two errors are configuration mistakes rather than conditions, and both surface immediately:
- Building a client without a verifier —
New(nil, …)errors. - Resolving without a cache — refused before anything is fetched. There is nowhere to put the result, and that is knowable before the work starts.
- A
Refor filename that breaks the identifier grammar —ErrInvalidIdentifier, raised before the network is touched.
Testing your handling¶
You do not need a key or the network. Serve a channel from httptest and stub
the verifier:
type stub struct{ ok bool }
func (s stub) Verify(context.Context, []byte, []byte) error {
if !s.ok {
return errors.New("not a trusted key")
}
return nil
}
The interesting case is a channel that serves a correctly signed manifest beside bytes it does not describe — signature good, digest wrong. That is the gap between "who published this" and "is this what they published", and it is the one a resolver that only checked signatures would walk straight into:
// manifest built from one set of bytes...
served := contents(map[string][]byte{"lib.tgz": []byte("described")}, true)
// ...different bytes served under the same name
served["lib.tgz"] = []byte("delivered")
_, err := client.Resolve(ctx, ref, "lib.tgz")
// errors.Is(err, artifacts.ErrUnverified)
Withdrawal reaches a warm cache¶
A withdrawn version is refused even if you already have it. The index is consulted before the cache is, so holding the artefact locally does not outlive the decision to withdraw it — which matters, because the population holding an artefact is exactly the population a withdrawal is aimed at.
The latency is bounded rather than instant: a client with a still-valid index keeps using it until it expires. With a 24-hour lifetime that is the worst case, plus propagation. Shortening it means republishing more often.
Ordering you can rely on¶
The signature over the manifest is checked before the artefact is fetched. An artefact whose publisher cannot be established is never downloaded at all, so a caller cannot end up holding unverified bytes because a later step failed.
A cache hit is checked too. Bytes are verified on the way out as well as on the way in, against a digest from a manifest verified in the same call — so a resolution never returns an entry on the strength of it having been verified once, at some point, by something.
This is a contract, not an implementation detail — if you are reasoning about what your tool has touched at the point an error is returned, you can rely on it.