The domain model of a source registry for gno packages: a map from a
deployed package path back to the repository, commit and directory that produced
it.
The realm half is
gno.land/r/moul/gnopm/registry/v0. This
package is pure: no chain imports, no realm state, and the one piece of chain
knowledge it needs (who holds a namespace) arrives as a function parameter.
Why this has to exist at all
A Go import path is a repository URL, so pkg.go.dev needs no registry. A gno
import path is a chain address, so nothing anywhere says which source produced
the bytes running at gno.land/p/nt/tinyavl/v0. gno gave up that link
deliberately, and this is the part that has to be rebuilt by hand.
What it can and cannot promise
It cannot promise anything, and the whole design is built around saying so
rather than around hiding it. Two claims look alike and are not the same:
claim
provable
this package came from that repository
no. Not here and not anywhere: anyone may deploy any bytes and claim any repository
the deployed bytes equal the addpkg payload of that directory at that commit
yes, by hashing both sides. Off chain, by anything that can clone
the claimant owns the path's namespace
yes, from chain data, recomputed on every read
So a Claim is testimony under a signature, and it is stored in exactly the
shape the second row needs: path, repository, commit and directory are the four
inputs to "hash the payload of that directory and compare it with what the chain
hands back". This package does not answer the question. It makes the question
answerable by something that can clone, which
gnopm already does against a local tree
(gnopm verify -deployed).
A mismatch, when a verifier does find one, is not evidence of malice. The
likeliest cause by far is a repository that moved on after a deploy, which is
the normal state of most repositories most of the time.
Open, and tagged
Anyone may claim any path, including one they had nothing to do with.
Claim.OwnedBy reports whether a claim comes from the party that controls the
path, so the two can be told apart at render time.
Gating registration on namespace ownership was the alternative and it cannot
bootstrap: on day one almost nothing has been registered by its own deployer, so
the gated registry is empty and teaches nobody anything. Open-and-labelled keeps
the map fillable and moves the defence to the renderer, which is why
OwnedBy exists and why the realm ranks on it rather than hiding anything.
OwnedBy takes the name resolver as a parameter and the answer is never stored:
a name can be transferred, and a stored answer would rot into exactly the kind of
stale claim this package exists to distinguish from a live one.
API
1r:=gnopm.New() 2 3// A claimant states where a package came from. Calling it again replaces that 4// claimant's own claim and nobody else's. 5c,err:=r.Register(claimant,height,pkgPath,repo,commit,dir,ref) 6err=r.Withdraw(claimant,pkgPath)// your own claim, never anyone else's 7 8p:=r.Package(pkgPath)// every claim about one path, or nil 9p.Claim(claimant)// one of them10p.IterateClaims(func(c*gnopm.Claim)bool{...})1112c.OwnedBy(resolve)// does this come from the namespace holder13c.SourceURL()// a browsable link to the claimed source
Height dates the claim and UpdatedAt dates the last edit. They are separate
so a reader can tell a claim that has been kept current from one made once at
deploy time and abandoned.
Validation is an allowlist, on purpose
Every stored field is charset-validated at write time: ValidPkgPath,
ValidRepo, ValidCommit, ValidDir, ValidRef. Each is an allowlist, so a
stored field cannot hold a backtick, a pipe, a bracket, an ASCII control
character or a bidi override.
That is not belt-and-braces, it is the escaping strategy. One claim is rendered
in several places (a listing row, a table cell, an inline-code span, a link
title) and a consumer that forgets to escape at any one of them has a hole.
A field that can only hold safe bytes needs no escaping anywhere.
Two consequences worth knowing before they surprise you:
ValidRepo accepts https:// and nothing else. Not a taste judgement
about git transports: the set of URL schemes that are safe to hand a browser
is exactly one, and javascript: and data: are the attack. A host with a
port is also refused, because the userinfo check (https://[email protected]/x
fetches from evil.example while reading as GitHub) needs the host to contain
no @ or :.
ValidRef is narrower than git's own rule. git rejects a handful of
metacharacters and permits the rest, which would leave a ref free to carry a
pipe or a backtick and undo the allowlist for every other field. What stays
expressible is every ref anyone actually has.
What is deliberately not here
No verification. A realm cannot clone a repository. See the realm README
for where that half is meant to live.
No network of trust, no voting, no curation. Whose claim to believe is a
judgement, and the place to make it is a renderer that can see who deployed
the package, not a data structure.
No version resolution.Version reads a trailing vN and that is all.
An unversioned path is legal and is not an error: gnoweb serves
gno.land/u/<name> by calling the realm at exactly /r/<name>/home, so that
one path can never carry a version.
Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.
Dependency graph:
⚠️ Disclaimer: provided as-is, without warranty; not security-audited. Full disclaimer: DISCLAIMER.
Overview
Package gnopm is the domain model of a source registry for gno packages: a map from a deployed package path back to the repository, commit and directory that produced it.
It exists because gno deliberately broke the link Go gets for free. A Go import path IS a repository URL, so pkg.go.dev needs no registry; a gno import path is a chain address, so nothing anywhere says which source produced the bytes running at gno.land/p/nt/tinyavl/v0.
What this can and cannot promise
It cannot promise anything, and the design is built around saying so rather than around hiding it. Two claims look alike and are not the same:
"this package came from that repository". UNPROVABLE, here or anywhere. Anyone may deploy any bytes and anyone may claim any repository. No registry design fixes this, and one that implies otherwise is worse than none.
"the bytes live at path P equal the addpkg payload of directory D in repository R at commit C". Mechanically provable, by hashing both sides. It is NOT provable here: a realm cannot clone a repository. It is provable off chain by anything that can, and gnopm already does exactly this against a local tree (`gnopm verify -deployed`).
So this package stores (1), labelled as the claim it is, in the form (2) needs to be checkable. A Claim is testimony under a signature. It becomes evidence only once something outside the chain has gone and looked.
What a signature does buy
The chain knows who signed a claim, and that is not nothing. A claim whose claimant owns the package path's namespace comes from the party that controls the path, which is the closest thing to a promise available without a chain-level feature. OwnedBy reports that relationship; it is deliberately computed on demand from a caller-supplied resolver and never stored, because a name can be transferred and a stored answer would be a claim of its own.
Anyone may still claim any path. That is the "open but tagged" choice, and it is made on purpose: gating registration on namespace ownership would mean that on day one, when almost nothing is registered by its own deployer, the registry is empty and teaches nobody anything. The defence against a misleading claim is that it renders as a stranger's claim, ranked below, and never as a fact.
1const( 2MaxPkgPathLen=256 3MaxRepoLen=512 4MaxDirLen=256 5MaxRefLen=255// git's own limit for a single ref name 6 7// MaxClaimants bounds how many addresses may claim one package path. 8// Unbounded, it is a spam surface: one path with ten thousand claims is a 9// Render that never returns and a listing nobody can read.10MaxClaimants=1611)
Size caps. Every string the chain stores is bounded: an unbounded field is an unbounded storage deposit, and on gno.land the deposit is paid per byte by whoever writes it.
1var(2ErrInvalidPkgPath=errors.New("gnopm: invalid package path")3ErrInvalidRepo=errors.New("gnopm: invalid repository URL")4ErrInvalidCommit=errors.New("gnopm: invalid commit id")5ErrInvalidDir=errors.New("gnopm: invalid directory")6ErrInvalidRef=errors.New("gnopm: invalid ref name")7ErrClaimNotFound=errors.New("gnopm: no such claim")8ErrTooManyClaimants=errors.New("gnopm: too many claimants for this package")9)
Stable, machine-readable error values. Callers (realms, clients, indexers) switch on these rather than on message text: a realm turns them into panics, and the panic string is the only thing a user ever sees.
AddressNamespace reports whether ns is shaped like a gno bech32 address, the namespace every account owns without registering anything. A caller still has to check it is the claimant's own address; this only says which of the two ownership rules applies.
SplitPkgPath breaks a package path into its domain, kind ("p" or "r"), namespace and the remainder. ok is false for anything that is not shaped like a publishable gno path.
ValidCommit reports whether s is a git object id: 40 (SHA-1) or 64 (SHA-256) lowercase hex characters. Case is fixed so the same object always produces the same stored bytes, and so a verifier comparing two claims compares two comparable strings.
ValidDir reports whether s names the subdirectory of the repository holding the package. Empty means the repository root, which is the common case for a single-package repo and must stay expressible.
ValidPkgPath reports whether s is shaped like a publishable gno package path.
It does NOT say the path exists on any chain, and cannot: a realm has no way to ask whether some other path was ever deployed. That gap is the registry's central honesty problem and is documented on Registry.
ValidRef reports whether s is a fully-qualified git ref, or empty.
The ref is optional and is never the thing that is verified: a ref moves, a commit does not. It is recorded so a reader can tell a claim pinned to a released tag from one pinned to a commit on nobody's branch, and so a verifier can check the claimed commit is still reachable from it rather than dangling behind a force-push.
ValidRepo reports whether s is a repository URL this registry accepts.
Deliberately narrow: "https://" only, a host, and a path. Not a taste judgement about git transports, a safety one. Whatever is stored here is eventually rendered as a link by this realm, by gnoweb and by every explorer that reads the registry, and the set of schemes that are safe to hand a browser is exactly one. "javascript:", "data:" and "file:" are the attack; "git://" and "ssh://" are merely unreachable from a web page, and a claimant who needs one can point at the https mirror every forge already serves.
Version returns the trailing "vN" element of a package path, or "" when the path carries none. Absent is legal and is not an error: gnoweb serves gno.land/u/<name> by calling the realm at exactly /r/<name>/home, so that one path can never carry a version.
1typeClaimstruct{ 2PkgPathstring 3Repostring// https:// URL of the repository 4Commitstring// 40 or 64 lowercase hex 5Dirstring// subdirectory holding the package, "" for the repo root 6Refstring// fully-qualified ref the commit was on, optional 7Claimantaddress 8Heightint64// block height of the first claim by this claimant 9// UpdatedAt is the height of the most recent write. Equal to Height until10// the claimant re-registers, which is how a reader tells a claim that has11// been kept current from one made once and abandoned.12UpdatedAtint6413}
Claim is one address's statement about where a package came from.
Every field except Claimant, Height and UpdatedAt is attacker-controlled. They are charset-validated at write time rather than escaped at read time, so that a consumer which forgets to escape is still safe: the fields cannot hold a byte that means anything to markdown, to a terminal or to a URL parser.
OwnedBy reports whether c comes from the party that controls the package path's namespace: either the address whose own namespace it is, or whoever resolve says holds the registered name.
resolve maps a namespace name to the address holding it and whether that name is held at all. It is a parameter rather than an import because this package stays pure: the realm supplies r/sys/users, and a test supplies a table.
This is the one label in the whole design that is derived rather than declared, so it is computed here and never stored. A name can be transferred; a stored answer would age into a lie of exactly the kind this package exists to avoid.
SourceURL builds a browsable link to the claimed source: the commit, plus the directory when the package is not at the repository root.
GitHub's "/tree/<commit>/<dir>" layout is also GitLab's, Gitea's, Forgejo's and Codeberg's, so one construction covers every forge this registry is likely to meet. It is a convenience, not a promise the link resolves: the repository may be gone, private or renamed since the claim was made, which is itself something a verifier reports rather than something a realm can know.
IterateClaims walks the claims in claimant-address order. Order is not significance: a caller that wants the owner's claim first must ask OwnedBy, because the registry has no opinion about which claim is true.
Registry maps a package path to the claims made about its source.
One path may carry several claims, at most one per claimant. That is not a flaw to be resolved: a fork is a legitimate second answer, and so is a third party filling in the map for a package whose author never will.
IteratePackages walks paths in lexical order, which groups a namespace's packages together without needing a second index. The callback returns true to STOP, following avl's own convention rather than inverting it here.
Register records or replaces claimant's claim about pkgPath.
Re-registering the same path overwrites that claimant's own previous claim and nobody else's, so the ordinary "I deployed a new commit" flow is one call with no read first. Height is preserved across an update: it dates the claim, not the edit.