Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

appstore.gno

22.63 Kb · 658 lines
  1// Package memba_appstore_v3 is a curated App Store for gno.land dApps: publishers
  2// pay a flat listing fee to register an app; a curator flips it live (or rejects it).
  3//
  4// MONEY PATH (the only one): RegisterApp collects a flat `registrationFee` in ugnot
  5// and forwards 100% to the treasury in the SAME call — nothing is ever custodied.
  6// The safety contract mirrors memba_token_otc_v1 + the O-13 lesson:
  7//   1. IsUserCall() guard BEFORE reading OriginSend — an ephemeral `maketx run`
  8//      realm can never attach unrecoverable coins (the guard agent_registry missed).
  9//   2. exact-coin via unsafe.OriginSend() (the coins on THIS call, not the wallet
 10//      balance) — closes the overpay-trap and the wallet-balance bypass.
 11//   3. treasury-misconfig is fail-closed: an unset treasury panics (→ tx reverts →
 12//      coins refunded), never silent custody.
 13//   4. CEI: state is written before the banker moves funds. All attacker-controlled
 14//      input (screenshots, appURL scheme) is validated BEFORE OriginSend is read.
 15//   5. NewBanker(RealmSend, cur) sends the fee from the realm's own address to the
 16//      treasury — no custody, no escrow (which is why no escrow is needed to be safe).
 17//
 18// v3 over v2: a `rejected` state + RejectApp/EditListing lifecycle, ≤6 screenshots,
 19// an on-chain appURL scheme allowlist, FlagApp extended to pending listings (the
 20// public Unverified tab's safety valve), composite-key status/publisher indexes with
 21// O(1) per-status counters (so status/publisher reads are bounded, not full scans),
 22// and a sealed SeedListing migration primitive (FinalizeSeed closes the backdoor).
 23//
 24// TREASURY: stored LOCALLY (admin-settable, 2-step handoff, defaults to the samcrew
 25// multisig) rather than read from memba_market_config — keeps the money path locally
 26// unit-testable. Keep it in sync with memba_market_config.GetTreasury().
 27package memba_appstore_v3
 28
 29import (
 30	"strings"
 31
 32	"chain"
 33	"chain/banker"
 34	"chain/runtime"
 35	"chain/runtime/unsafe"
 36
 37	"gno.land/p/samcrew/avl"
 38	"gno.land/p/nt/ufmt/v0"
 39)
 40
 41// No authority is compiled in. The publishing transaction's signer (on
 42// gnoland-1 the samcrew namespace multisig, the stamped creator at enable time)
 43// is seeded as owner, treasury and first curator at package load, and every
 44// later change goes through the owner-gated admin surface: SetTreasury repoints
 45// the fee recipient, and TransferOwnership/AcceptOwnership hand admin to the
 46// memba_dao executor in two steps.
 47
 48const (
 49	// DefaultRegistrationFee is the launch listing fee: 1 GNOT (1_000_000 ugnot).
 50	DefaultRegistrationFee = int64(1_000_000)
 51	// MaxRegistrationFee caps a fat-finger / compromised-proposal fee at 100 GNOT.
 52	MaxRegistrationFee = int64(100_000_000)
 53
 54	MaxNameLen     = 80
 55	MaxTaglineLen  = 140
 56	MaxDescrLen    = 2000
 57	MaxCategoryLen = 40
 58	MaxURLLen      = 400
 59	MaxPkgPathLen  = 200
 60	MaxCIDLen      = 100
 61	MaxReasonLen   = 500
 62	// MaxScreenshots bounds the per-listing screenshot gallery.
 63	MaxScreenshots = 6
 64	// MaxResubmits bounds how many times a publisher can edit/resubmit a listing, so a
 65	// reject→edit→pending loop can't grief the curator queue indefinitely.
 66	MaxResubmits = 5
 67	// FlagHideThreshold auto-hides a listing (live OR pending) from the public lists once
 68	// this many distinct addresses have flagged it.
 69	FlagHideThreshold = 5
 70)
 71
 72// Listing lifecycle states.
 73const (
 74	StatusPending  = "pending"
 75	StatusLive     = "live"
 76	StatusRejected = "rejected"
 77	StatusDelisted = "delisted"
 78)
 79
 80// Listing is one app. PkgPath (the realm/package path) is the unique key.
 81type Listing struct {
 82	Id                 uint64
 83	PkgPath            string
 84	Name               string
 85	Tagline            string
 86	Descr              string
 87	Category           string
 88	IconCID            string
 89	ScreenshotCIDs     []string
 90	AppURL             string
 91	Publisher          address
 92	Status             string
 93	RejectReason       string
 94	PaidResubmitCredit bool
 95	ResubmitCount      int
 96	FlagCount          int
 97	CreatedAt          int64
 98}
 99
100var (
101	owner          address
102	pendingOwner   address
103	treasury       address
104	registrationFee int64
105	paused         bool
106	seedingSealed  bool
107	curators       = avl.NewTree() // address string -> bool
108	listings       = avl.NewTree() // pkgPath -> *Listing
109	flaggedBy      = avl.NewTree() // pkgPath + "\x00" + addr -> bool (one flag per addr)
110	// statusIndex/publisherIndex: composite-key indexes for bounded status/publisher reads.
111	// key = status|pub + "\x00" + zeroPad(id) -> pkgPath, so a prefix range-iterate yields a
112	// true O(offset+limit) window ordered by submission id, never a full-catalog scan.
113	statusIndex    = avl.NewTree()
114	publisherIndex = avl.NewTree()
115	// O(1) per-status counters, maintained on every status transition.
116	liveCount     int
117	pendingCount  int
118	rejectedCount int
119	delistedCount int
120	nextId uint64 = 1
121)
122
123func init() {
124	registrationFee = DefaultRegistrationFee
125	seedAuthority(unsafe.OriginCaller())
126}
127
128// seedAuthority makes the publisher the owner, the fee recipient and the first
129// curator. It runs once at package load; there is no other way to obtain owner.
130func seedAuthority(publisher address) {
131	owner = publisher
132	pendingOwner = ""
133	treasury = publisher
134	curators.Set(publisher.String(), true)
135}
136
137func caller() address { return unsafe.PreviousRealm().Address() }
138
139func assertOwner() {
140	if caller() != owner {
141		panic("unauthorized: owner only")
142	}
143}
144
145func assertNotPaused() {
146	if paused {
147		panic("appstore is paused")
148	}
149}
150
151// ── Money path ────────────────────────────────────────────────────────────────
152
153// RegisterApp lists a new app. The caller pays EXACTLY registrationFee ugnot with the call;
154// the whole fee is forwarded to the treasury (no custody). The listing starts `pending`.
155func RegisterApp(
156	cur realm,
157	pkgPath, name, tagline, descr, category, iconCID, screenshotsCSV, appURL string,
158) uint64 {
159	assertNotPaused()
160
161	// O-13 guard: a payable entrypoint MUST be a direct user call.
162	if !unsafe.PreviousRealm().IsUserCall() {
163		panic("RegisterApp must be a direct user call")
164	}
165
166	// Validate ALL attacker-controlled input BEFORE reading the attached coins.
167	pkgPath = validatePkgPath(pkgPath)
168	if _, dup := listings.Get(pkgPath); dup {
169		panic("app already registered for this package path")
170	}
171	validateListingFields(name, tagline, descr, category, iconCID, appURL)
172	shots := parseScreenshots(screenshotsCSV)
173
174	// Exact-coin: read the coins attached to THIS call (not the wallet balance).
175	sent := unsafe.OriginSend()
176	if sent.AmountOf("ugnot") != registrationFee {
177		panic(ufmt.Sprintf("must send exactly %d ugnot (sent %d)", registrationFee, sent.AmountOf("ugnot")))
178	}
179	// Fail-closed on a misconfigured treasury — reverting refunds the coins.
180	if treasury == "" {
181		panic("treasury unset — registration disabled")
182	}
183
184	// CEI: write state before moving funds.
185	id := nextId
186	nextId++
187	l := &Listing{
188		Id:             id,
189		PkgPath:        pkgPath,
190		Name:           name,
191		Tagline:        tagline,
192		Descr:          descr,
193		Category:       category,
194		IconCID:        iconCID,
195		ScreenshotCIDs: shots,
196		AppURL:         appURL,
197		Publisher:      caller(),
198		Status:         StatusPending,
199		CreatedAt:      runtime.ChainHeight(),
200	}
201	listings.Set(pkgPath, l)
202	indexInsert(l)
203
204	// Forward the whole fee to the treasury from the realm's own account.
205	if registrationFee > 0 {
206		bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
207		bnk.SendCoins(
208			unsafe.CurrentRealm().Address(),
209			treasury,
210			chain.Coins{chain.NewCoin("ugnot", registrationFee)},
211		)
212	}
213
214	chain.Emit("AppRegistered", "pkgPath", pkgPath, "id", itoa64(id), "publisher", caller().String())
215	return id
216}
217
218// ── Curation & moderation ───────────────────────────────────────────────────
219
220// ApproveApp flips a pending (or previously-live) listing live. Curator-only.
221func ApproveApp(cur realm, pkgPath string) {
222	if !isCurator(caller()) {
223		panic("unauthorized: curator only")
224	}
225	l := mustGet(pkgPath)
226	if l.Status == StatusDelisted {
227		panic("cannot approve a delisted app")
228	}
229	if l.Status != StatusLive {
230		changeStatus(l, StatusLive)
231	}
232	chain.Emit("AppApproved", "pkgPath", pkgPath)
233}
234
235// RejectApp declines a pending submission, recording a reason and granting a one-time free
236// resubmit credit. Curator-only; only a pending app can be rejected.
237func RejectApp(cur realm, pkgPath, reason string) {
238	if !isCurator(caller()) {
239		panic("unauthorized: curator only")
240	}
241	if len(reason) > MaxReasonLen {
242		panic("reason too long")
243	}
244	l := mustGet(pkgPath)
245	if l.Status != StatusPending {
246		panic("only a pending app can be rejected")
247	}
248	changeStatus(l, StatusRejected)
249	l.RejectReason = reason
250	l.PaidResubmitCredit = true
251	listings.Set(pkgPath, l)
252	chain.Emit("AppRejected", "pkgPath", pkgPath)
253}
254
255// EditListing lets the publisher update a listing that is NOT live/delisted — i.e. a pending
256// or rejected one — and resets it to `pending` for (re-)review. Editing a live listing is
257// structurally forbidden so a Verified badge can never be bait-and-switched. Bounded by
258// MaxResubmits so a reject→edit loop can't grief the queue.
259func EditListing(
260	cur realm,
261	pkgPath, name, tagline, descr, category, iconCID, screenshotsCSV, appURL string,
262) {
263	l := mustGet(pkgPath)
264	if caller() != l.Publisher {
265		panic("unauthorized: publisher only")
266	}
267	if l.Status != StatusPending && l.Status != StatusRejected {
268		panic("cannot edit a live or delisted listing")
269	}
270	if l.ResubmitCount >= MaxResubmits {
271		panic("resubmit limit reached")
272	}
273	validateListingFields(name, tagline, descr, category, iconCID, appURL)
274	shots := parseScreenshots(screenshotsCSV)
275
276	l.Name = name
277	l.Tagline = tagline
278	l.Descr = descr
279	l.Category = category
280	l.IconCID = iconCID
281	l.ScreenshotCIDs = shots
282	l.AppURL = appURL
283	l.RejectReason = ""
284	l.ResubmitCount++
285	if l.Status != StatusPending {
286		changeStatus(l, StatusPending) // rejected → pending (re-review); persists via Set
287	} else {
288		listings.Set(pkgPath, l)
289	}
290	chain.Emit("AppEdited", "pkgPath", pkgPath)
291}
292
293// DelistApp removes a listing from public view. The publisher or a curator may do it.
294func DelistApp(cur realm, pkgPath string) {
295	l := mustGet(pkgPath)
296	if caller() != l.Publisher && !isCurator(caller()) {
297		panic("unauthorized: publisher or curator only")
298	}
299	if l.Status != StatusDelisted {
300		changeStatus(l, StatusDelisted)
301	}
302	chain.Emit("AppDelisted", "pkgPath", pkgPath)
303}
304
305// RestoreApp brings a delisted app back to `pending` (re-curation required). Curator-only.
306func RestoreApp(cur realm, pkgPath string) {
307	if !isCurator(caller()) {
308		panic("unauthorized: curator only")
309	}
310	l := mustGet(pkgPath)
311	if l.Status != StatusDelisted {
312		panic("only a delisted app can be restored")
313	}
314	changeStatus(l, StatusPending)
315	chain.Emit("AppRestored", "pkgPath", pkgPath)
316}
317
318// FlagApp lets any user flag a publicly-listed (live OR pending) listing once. At
319// FlagHideThreshold distinct flags the listing drops from the public lists (isVisible), giving
320// the public Unverified/pending tab a community safety valve; a curator can then Delist/Reject.
321func FlagApp(cur realm, pkgPath string) {
322	l := mustGet(pkgPath)
323	if l.Status != StatusLive && l.Status != StatusPending {
324		panic("can only flag a live or pending app")
325	}
326	key := pkgPath + "\x00" + caller().String()
327	if _, done := flaggedBy.Get(key); done {
328		panic("already flagged")
329	}
330	flaggedBy.Set(key, true)
331	l.FlagCount++
332	listings.Set(pkgPath, l)
333	chain.Emit("AppFlagged", "pkgPath", pkgPath, "count", itoa(l.FlagCount))
334}
335
336// MaxClearBatch bounds how many per-address flag marks one ClearFlags call removes,
337// so a mega-brigade (thousands of sybil flags) cannot gas-lock the reset — the
338// curator just calls ClearFlags repeatedly until the count reaches zero.
339const MaxClearBatch = 200
340
341// ClearFlags resets a listing's community-flag state after curator review. Curator-only.
342// Without it a flag-hidden listing stays hidden FOREVER: FlagCount never decrements and
343// survives every status transition, so FlagHideThreshold (5) sybil addresses could
344// permanently disappear any live app. Clearing also deletes the per-address dedupe marks —
345// the community can re-flag if the concern is real, and every clear is an emitted event,
346// so a curator whitewashing a bad listing is publicly visible on-chain.
347func ClearFlags(cur realm, pkgPath string) {
348	if !isCurator(caller()) {
349		panic("unauthorized: curator only")
350	}
351	l := mustGet(pkgPath)
352	if l.FlagCount == 0 {
353		panic("no flags to clear")
354	}
355	// Collect up to MaxClearBatch marks over the listing's prefix range (the same
356	// [prefix+"\x00", prefix+"\x01") window idiom as reads.gno), then remove them.
357	// Keys are collected BEFORE removal — never mutate a tree mid-Iterate.
358	var keys []string
359	flaggedBy.Iterate(pkgPath+"\x00", pkgPath+"\x01", func(k string, _ any) bool {
360		keys = append(keys, k)
361		return len(keys) >= MaxClearBatch
362	})
363	for _, k := range keys {
364		flaggedBy.Remove(k)
365	}
366	l.FlagCount -= len(keys)
367	if l.FlagCount < 0 {
368		l.FlagCount = 0
369	}
370	listings.Set(pkgPath, l)
371	chain.Emit("AppFlagsCleared", "pkgPath", pkgPath,
372		"cleared", itoa(len(keys)), "remaining", itoa(l.FlagCount))
373}
374
375// ── Migration (owner-only, sealable) ──────────────────────────────────────────
376
377// SeedListing imports a listing verbatim (Id, CreatedAt, FlagCount, Status, Publisher) during a
378// v2→v3 migration. Owner-only, NON-payable (never reads OriginSend / moves funds), dedupe-guarded.
379// After the migration the owner calls FinalizeSeed, permanently sealing this entrypoint — without
380// that latch it would be a standing backdoor to forge fee-free listings with arbitrary publisher.
381func SeedListing(
382	cur realm,
383	id uint64,
384	pkgPath, name, tagline, descr, category, iconCID, screenshotsCSV, appURL, publisherStr, status string,
385	flagCount int,
386	createdAt int64,
387) {
388	assertOwner()
389	if seedingSealed {
390		panic("seeding sealed — migration is finalized")
391	}
392	pkgPath = validatePkgPath(pkgPath)
393	if _, dup := listings.Get(pkgPath); dup {
394		panic("app already registered for this package path")
395	}
396	if !validStatus(status) {
397		panic("invalid status")
398	}
399	// A duplicate id would collide on the composite index key (statusKey/pubKey), silently
400	// overwriting one entry while double-counting — so reject an id already present in ANY status
401	// (covers both a repeat seed and a seeded id that a prior RegisterApp already used).
402	if idInUse(id) {
403		panic("id already in use")
404	}
405	// Seeded data is historical user input — validate it against the SAME field + appURL-scheme
406	// rules as a fresh registration, so an unsafe v2 appURL can't be imported past the allowlist.
407	validateListingFields(name, tagline, descr, category, iconCID, appURL)
408	shots := parseScreenshots(screenshotsCSV)
409	l := &Listing{
410		Id:             id,
411		PkgPath:        pkgPath,
412		Name:           name,
413		Tagline:        tagline,
414		Descr:          descr,
415		Category:       category,
416		IconCID:        iconCID,
417		ScreenshotCIDs: shots,
418		AppURL:         appURL,
419		Publisher:      address(publisherStr),
420		Status:         status,
421		FlagCount:      flagCount,
422		CreatedAt:      createdAt,
423	}
424	listings.Set(pkgPath, l)
425	indexInsert(l)
426	if id >= nextId {
427		nextId = id + 1
428	}
429	chain.Emit("AppSeeded", "pkgPath", pkgPath, "id", itoa64(id))
430}
431
432// FinalizeSeed permanently seals SeedListing (one-way latch). Owner-only.
433func FinalizeSeed(cur realm) {
434	assertOwner()
435	seedingSealed = true
436	chain.Emit("SeedingFinalized")
437}
438
439// ── Read getters (pure, non-failing) ─────────────────────────────────────────
440
441// GetRegistrationFee returns the current flat listing fee in ugnot.
442func GetRegistrationFee() int64 { return registrationFee }
443
444// GetTreasury returns the address that receives listing fees.
445func GetTreasury() address { return treasury }
446
447// GetOwner returns the current owner (multisig, or a DAO executor after handoff).
448func GetOwner() address { return owner }
449
450// AppCount returns the total number of registered listings (any status).
451func AppCount() int { return listings.Size() }
452
453// IsCurator reports whether an address may approve/reject listings (the curator-dashboard gate).
454func IsCurator(a string) bool {
455	_, ok := curators.Get(a)
456	return ok
457}
458
459// GetCuratorsJSON returns a JSON array of curator addresses (small, bounded).
460func GetCuratorsJSON() string {
461	var sb strings.Builder
462	sb.WriteString("[")
463	first := true
464	curators.Iterate("", "", func(k string, _ any) bool {
465		if !first {
466			sb.WriteString(",")
467		}
468		first = false
469		sb.WriteString(`"`)
470		sb.WriteString(k) // a bech32 address — no JSON metachars
471		sb.WriteString(`"`)
472		return false
473	})
474	sb.WriteString("]")
475	return sb.String()
476}
477
478// GetStatsJSON returns per-status counts (served from O(1) counters) for the store header.
479func GetStatsJSON() string {
480	return ufmt.Sprintf(
481		`{"total":%d,"live":%d,"pending":%d,"rejected":%d,"delisted":%d,"registrationFee":%d,"paused":%t}`,
482		listings.Size(), liveCount, pendingCount, rejectedCount, delistedCount, registrationFee, paused)
483}
484
485// ── internal helpers ─────────────────────────────────────────────────────────
486
487func isCurator(a address) bool {
488	_, ok := curators.Get(a.String())
489	return ok
490}
491
492func mustGet(pkgPath string) *Listing {
493	v, ok := listings.Get(pkgPath)
494	if !ok {
495		panic("app not found: " + pkgPath)
496	}
497	return v.(*Listing)
498}
499
500func validStatus(s string) bool {
501	return s == StatusPending || s == StatusLive || s == StatusRejected || s == StatusDelisted
502}
503
504// validateListingFields checks the length + appURL-scheme invariants shared by RegisterApp and
505// EditListing. The appURL scheme allowlist (http/https/leading-slash/empty) is the on-chain
506// defense behind the frontend AppLink — it blocks javascript:/data:/other-scheme phishing URLs.
507func validateListingFields(name, tagline, descr, category, iconCID, appURL string) {
508	if len(name) == 0 || len(name) > MaxNameLen {
509		panic("name must be 1.." + itoa(MaxNameLen) + " chars")
510	}
511	if len(tagline) > MaxTaglineLen {
512		panic("tagline too long")
513	}
514	if len(descr) > MaxDescrLen {
515		panic("description too long")
516	}
517	if len(category) > MaxCategoryLen {
518		panic("category too long")
519	}
520	if len(iconCID) > MaxCIDLen {
521		panic("iconCID too long")
522	}
523	if len(appURL) > MaxURLLen {
524		panic("appURL too long")
525	}
526	validateAppURL(appURL)
527}
528
529// validateAppURL enforces the scheme allowlist: empty, http://, https://, or a leading-slash
530// in-app path. Anything else (javascript:, data:, ftp:, mailto:, …) aborts. A leading-slash path
531// must NOT be protocol-relative (`//host` or `/\host`) — browsers navigate those off-site, which
532// would defeat the allowlist.
533func validateAppURL(u string) {
534	if u == "" {
535		return
536	}
537	if hasPrefix(u, "http://") || hasPrefix(u, "https://") {
538		return
539	}
540	if u[0] == '/' {
541		if len(u) > 1 && (u[1] == '/' || u[1] == '\\') {
542			panic("appURL scheme: protocol-relative //host is not an in-app path")
543		}
544		return
545	}
546	panic("appURL scheme must be http(s):// or a leading-slash path")
547}
548
549// parseScreenshots splits a comma-separated CID list, enforcing ≤MaxScreenshots and per-CID
550// length. Blank entries are dropped. Empty input → nil.
551func parseScreenshots(csv string) []string {
552	if csv == "" {
553		return nil
554	}
555	parts := strings.Split(csv, ",")
556	if len(parts) > MaxScreenshots {
557		panic("too many screenshots (max " + itoa(MaxScreenshots) + ")")
558	}
559	out := make([]string, 0, len(parts))
560	for _, p := range parts {
561		p = strings.TrimSpace(p)
562		if p == "" {
563			continue
564		}
565		if len(p) > MaxCIDLen {
566			panic("screenshot CID too long")
567		}
568		out = append(out, p)
569	}
570	return out
571}
572
573// ── index + counter maintenance ───────────────────────────────────────────────
574
575func statusKey(status string, id uint64) string { return status + "\x00" + zeroPad(id) }
576
577func pubKey(pub address, id uint64) string { return pub.String() + "\x00" + zeroPad(id) }
578
579// idInUse reports whether `id` is already present in the status index under ANY status (every
580// listing — registered or seeded — is in exactly one statusIndex entry). Used to reject a
581// duplicate SeedListing id before it silently collides on the composite key.
582func idInUse(id uint64) bool {
583	for _, s := range []string{StatusPending, StatusLive, StatusRejected, StatusDelisted} {
584		if _, ok := statusIndex.Get(statusKey(s, id)); ok {
585			return true
586		}
587	}
588	return false
589}
590
591// zeroPad renders id as a fixed-width 20-digit string so lexical avl order == numeric order.
592func zeroPad(id uint64) string {
593	s := itoa64(id)
594	for len(s) < 20 {
595		s = "0" + s
596	}
597	return s
598}
599
600func adjustCounter(status string, d int) {
601	switch status {
602	case StatusLive:
603		liveCount += d
604	case StatusPending:
605		pendingCount += d
606	case StatusRejected:
607		rejectedCount += d
608	case StatusDelisted:
609		delistedCount += d
610	}
611}
612
613// indexInsert adds a brand-new listing to the status + publisher indexes and bumps its status
614// counter. (publisherIndex never changes afterward — Publisher + Id are immutable.)
615func indexInsert(l *Listing) {
616	statusIndex.Set(statusKey(l.Status, l.Id), l.PkgPath)
617	publisherIndex.Set(pubKey(l.Publisher, l.Id), l.PkgPath)
618	adjustCounter(l.Status, 1)
619}
620
621// changeStatus moves a listing between statuses, keeping statusIndex + counters exact, and
622// persists the listing. Every status transition MUST go through here.
623func changeStatus(l *Listing, newStatus string) {
624	statusIndex.Remove(statusKey(l.Status, l.Id))
625	adjustCounter(l.Status, -1)
626	l.Status = newStatus
627	statusIndex.Set(statusKey(newStatus, l.Id), l.PkgPath)
628	adjustCounter(newStatus, 1)
629	listings.Set(l.PkgPath, l)
630}
631
632// validatePkgPath normalizes + sanity-checks a realm/package path.
633func validatePkgPath(p string) string {
634	if len(p) == 0 || len(p) > MaxPkgPathLen {
635		panic("pkgPath must be 1.." + itoa(MaxPkgPathLen) + " chars")
636	}
637	// Reject control and space bytes: composite avl keys separate on "\x00" and
638	// ClearFlags range-iterates the pkgPath prefix ([p+"\x00", p+"\x01")), so an
639	// embedded control byte could bleed one listing's key range into another's.
640	// Applies to RegisterApp, EditListing (via mustGet key use) AND SeedListing —
641	// a migration must not import such a path either.
642	for i := 0; i < len(p); i++ {
643		if p[i] <= 0x20 || p[i] == 0x7f {
644			panic("pkgPath contains control or space characters")
645		}
646	}
647	if !hasPrefix(p, "gno.land/r/") && !hasPrefix(p, "gno.land/p/") {
648		panic("pkgPath must be a gno.land/r/... or gno.land/p/... path")
649	}
650	return p
651}
652
653func hasPrefix(s, pre string) bool {
654	return len(s) >= len(pre) && s[:len(pre)] == pre
655}
656
657func itoa(n int) string      { return ufmt.Sprintf("%d", n) }
658func itoa64(n uint64) string { return ufmt.Sprintf("%d", n) }