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

bylaws.gno

4.72 Kb · 149 lines
  1// Package bylaws stores a DAO's governing documents — bylaws and
  2// mandates — as named plaintext files and amends them with verifiable
  3// diff patches.
  4//
  5// Documents are keyed by a slash-separated path ("mandates/treasury.md");
  6// folders are a naming convention over the path, not stored objects — a
  7// folder exists exactly when a document path has it as a prefix. The only
  8// mutation is Apply: a Patch carries the sha256 of the document text it
  9// was diffed against plus the edit script that transforms that text into
 10// the proposed one. Apply rejects the patch when the document has changed
 11// since (optimistic concurrency, no clobbering) and otherwise replays the
 12// script. An amendment whose result is empty removes the document, so a
 13// stored document is never empty.
 14//
 15// The package is governance-agnostic: it decides nothing about WHO may
 16// amend. A consuming realm (e.g. a DAO) gates Apply behind its own vote
 17// and keeps the *Bylaws handle private — Apply mutates, so the handle
 18// must never be exposed to untrusted callers.
 19package bylaws
 20
 21import (
 22	"crypto/sha256"
 23	"encoding/hex"
 24	"errors"
 25
 26	"gno.land/p/nt/bptree/v0"
 27)
 28
 29const (
 30	// MaxPathLen bounds a document path's byte length.
 31	MaxPathLen = 200
 32
 33	// MaxDocLen bounds a document's byte length. Bylaws are human-written
 34	// prose; the cap keeps documents renderable and patch replay bounded.
 35	MaxDocLen = 64 * 1024
 36)
 37
 38var (
 39	ErrInvalidPath  = errors.New("bylaws: invalid document path")
 40	ErrInvalidPatch = errors.New("bylaws: invalid patch")
 41	ErrInvalidText  = errors.New("bylaws: text is not valid UTF-8")
 42	ErrStalePatch   = errors.New("bylaws: document changed since the patch base")
 43	ErrDocTooLarge  = errors.New("bylaws: document exceeds the maximum size")
 44)
 45
 46// Bylaws is one DAO's set of governing documents, keyed by path.
 47type Bylaws struct {
 48	docs *bptree.BPTree // path (string) -> document text (string, never empty)
 49}
 50
 51// New creates an empty document set.
 52func New() *Bylaws {
 53	return &Bylaws{docs: bptree.NewBPTree32()}
 54}
 55
 56// Get returns a document's text and whether it exists.
 57func (b *Bylaws) Get(path string) (string, bool) {
 58	if v := b.docs.Get(path); v != nil {
 59		return v.(string), true
 60	}
 61	return "", false
 62}
 63
 64// Has reports whether a document exists.
 65func (b *Bylaws) Has(path string) bool {
 66	return b.docs.Has(path)
 67}
 68
 69// Size returns the number of documents.
 70func (b *Bylaws) Size() int {
 71	return b.docs.Size()
 72}
 73
 74// Hash returns the hex sha256 of a document's text, or an empty string
 75// when the document does not exist. It is the base a Patch must pin to
 76// amend the document (an empty hash pins "the document must not exist").
 77func (b *Bylaws) Hash(path string) string {
 78	if text, ok := b.Get(path); ok {
 79		return HashText(text)
 80	}
 81	return ""
 82}
 83
 84// List returns the sorted document paths under a prefix. An empty prefix
 85// lists every document. The prefix is a raw path prefix: include the
 86// trailing slash to scope to a folder (e.g. "mandates/"), or "mandates"
 87// also matches a sibling file like "mandates-old.md".
 88func (b *Bylaws) List(prefix string) []string {
 89	paths := []string{}
 90	b.Iterate(prefix, func(path, _ string) bool {
 91		paths = append(paths, path)
 92		return false
 93	})
 94	return paths
 95}
 96
 97// Iterate walks the documents under a prefix in sorted path order until
 98// fn returns true. It returns true when the walk was stopped by fn. The
 99// set must not be amended during iteration (no Apply from fn).
100func (b *Bylaws) Iterate(prefix string, fn func(path, text string) bool) bool {
101	end := ""
102	if prefix != "" {
103		// Path bytes are all < 0x7f (see IsValidPath), so every key with
104		// the prefix sorts before prefix+"\x7f". The tree iterates the
105		// half-open range [start, end) in sorted key order.
106		end = prefix + "\x7f"
107	}
108	return b.docs.Iterate(prefix, end, func(key string, value any) bool {
109		return fn(key, value.(string))
110	})
111}
112
113// HashText returns the hex sha256 of a text.
114func HashText(text string) string {
115	sum := sha256.Sum256([]byte(text))
116	return hex.EncodeToString(sum[:])
117}
118
119// IsValidPath reports whether a path names a document: one or more
120// non-empty "/"-separated segments of [a-zA-Z0-9._-] characters, where no
121// segment is "." or "..". The restricted charset keeps paths render- and
122// link-safe and the patch encoding delimiter-free.
123func IsValidPath(path string) bool {
124	if path == "" || len(path) > MaxPathLen {
125		return false
126	}
127	segStart := 0
128	for i := 0; i <= len(path); i++ {
129		if i == len(path) || path[i] == '/' {
130			seg := path[segStart:i]
131			if seg == "" || seg == "." || seg == ".." {
132				return false
133			}
134			segStart = i + 1
135			continue
136		}
137		if !isPathChar(path[i]) {
138			return false
139		}
140	}
141	return true
142}
143
144func isPathChar(c byte) bool {
145	return c >= 'a' && c <= 'z' ||
146		c >= 'A' && c <= 'Z' ||
147		c >= '0' && c <= '9' ||
148		c == '.' || c == '_' || c == '-'
149}