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}