devrites 5.12.0 → 5.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +5 -3
- package/docs/architecture.md +2 -2
- package/docs/command-map.md +3 -1
- package/docs/engine/commands.md +21 -0
- package/docs/flow.md +2 -1
- package/docs/skills.md +13 -4
- package/engine/help.go +4 -1
- package/engine/help_test.go +2 -0
- package/engine/internal/install/binary.go +4 -0
- package/engine/internal/install/install_test.go +22 -0
- package/engine/internal/overhaul/admit/admit_test.go +170 -0
- package/engine/internal/overhaul/admit/run.go +416 -0
- package/engine/internal/overhaul/bench/run.go +248 -0
- package/engine/internal/overhaul/bench/run_test.go +139 -0
- package/engine/internal/overhaul/overhaul.go +64 -0
- package/engine/internal/overhaul/ovio/ovio.go +158 -0
- package/engine/internal/overhaul/records/check.go +834 -0
- package/engine/internal/overhaul/records/records_test.go +577 -0
- package/engine/internal/overhaul/records/run.go +392 -0
- package/engine/internal/overhaul/render/run.go +776 -0
- package/engine/internal/overhaul/render/run_test.go +293 -0
- package/engine/internal/overhaul/score/run.go +600 -0
- package/engine/internal/overhaul/score/run_test.go +405 -0
- package/engine/internal/overhaul/snapshot/run.go +563 -0
- package/engine/internal/overhaul/snapshot/run_test.go +360 -0
- package/engine/main.go +4 -0
- package/engine/root_routing_test.go +2 -1
- package/pack/.claude/skills/overhaul/SKILL.md +201 -0
- package/pack/.claude/skills/overhaul/references/audit-domains.md +263 -0
- package/pack/.claude/skills/overhaul/references/lifecycle.md +308 -0
- package/pack/.claude/skills/overhaul/references/orchestration.md +264 -0
- package/pack/.claude/skills/overhaul/references/records.md +137 -0
- package/pack/.claude/skills/overhaul/references/repair-and-measurement.md +207 -0
- package/pack/.claude/skills/overhaul/references/review-and-report.md +137 -0
- package/pack/.claude/skills/overhaul/references/scope-and-safety.md +191 -0
- package/pack/.claude/skills/overhaul/references/scoring.md +222 -0
- package/pack/.claude/skills/overhaul/references/stack-profiles.md +264 -0
- package/pack/.claude/skills/overhaul/roles/backend-engineer.md +61 -0
- package/pack/.claude/skills/overhaul/roles/boundary.md +56 -0
- package/pack/.claude/skills/overhaul/roles/craft.md +77 -0
- package/pack/.claude/skills/overhaul/roles/frontend-engineer.md +63 -0
- package/pack/.claude/skills/overhaul/roles/implementer.md +59 -0
- package/pack/.claude/skills/overhaul/roles/recon.md +60 -0
- package/pack/.claude/skills/overhaul/roles/specialist.md +56 -0
- package/pack/.claude/skills/overhaul/roles/verifier.md +60 -0
- package/pack/generated/claude/skills/overhaul/SKILL.md +201 -0
- package/pack/generated/claude/skills/overhaul/references/audit-domains.md +263 -0
- package/pack/generated/claude/skills/overhaul/references/lifecycle.md +308 -0
- package/pack/generated/claude/skills/overhaul/references/orchestration.md +264 -0
- package/pack/generated/claude/skills/overhaul/references/records.md +137 -0
- package/pack/generated/claude/skills/overhaul/references/repair-and-measurement.md +207 -0
- package/pack/generated/claude/skills/overhaul/references/review-and-report.md +137 -0
- package/pack/generated/claude/skills/overhaul/references/scope-and-safety.md +191 -0
- package/pack/generated/claude/skills/overhaul/references/scoring.md +222 -0
- package/pack/generated/claude/skills/overhaul/references/stack-profiles.md +264 -0
- package/pack/generated/claude/skills/overhaul/roles/backend-engineer.md +61 -0
- package/pack/generated/claude/skills/overhaul/roles/boundary.md +56 -0
- package/pack/generated/claude/skills/overhaul/roles/craft.md +77 -0
- package/pack/generated/claude/skills/overhaul/roles/frontend-engineer.md +63 -0
- package/pack/generated/claude/skills/overhaul/roles/implementer.md +59 -0
- package/pack/generated/claude/skills/overhaul/roles/recon.md +60 -0
- package/pack/generated/claude/skills/overhaul/roles/specialist.md +56 -0
- package/pack/generated/claude/skills/overhaul/roles/verifier.md +60 -0
- package/pack/generated/codex/skills/overhaul/SKILL.md +201 -0
- package/pack/generated/codex/skills/overhaul/agents/openai.yaml +2 -0
- package/pack/generated/codex/skills/overhaul/references/audit-domains.md +263 -0
- package/pack/generated/codex/skills/overhaul/references/lifecycle.md +308 -0
- package/pack/generated/codex/skills/overhaul/references/orchestration.md +264 -0
- package/pack/generated/codex/skills/overhaul/references/records.md +137 -0
- package/pack/generated/codex/skills/overhaul/references/repair-and-measurement.md +207 -0
- package/pack/generated/codex/skills/overhaul/references/review-and-report.md +137 -0
- package/pack/generated/codex/skills/overhaul/references/scope-and-safety.md +191 -0
- package/pack/generated/codex/skills/overhaul/references/scoring.md +222 -0
- package/pack/generated/codex/skills/overhaul/references/stack-profiles.md +264 -0
- package/pack/generated/codex/skills/overhaul/roles/backend-engineer.md +61 -0
- package/pack/generated/codex/skills/overhaul/roles/boundary.md +56 -0
- package/pack/generated/codex/skills/overhaul/roles/craft.md +77 -0
- package/pack/generated/codex/skills/overhaul/roles/frontend-engineer.md +63 -0
- package/pack/generated/codex/skills/overhaul/roles/implementer.md +59 -0
- package/pack/generated/codex/skills/overhaul/roles/recon.md +60 -0
- package/pack/generated/codex/skills/overhaul/roles/specialist.md +56 -0
- package/pack/generated/codex/skills/overhaul/roles/verifier.md +60 -0
- package/pack/generated/devin/skills/overhaul/SKILL.md +201 -0
- package/pack/generated/devin/skills/overhaul/references/audit-domains.md +263 -0
- package/pack/generated/devin/skills/overhaul/references/lifecycle.md +308 -0
- package/pack/generated/devin/skills/overhaul/references/orchestration.md +264 -0
- package/pack/generated/devin/skills/overhaul/references/records.md +137 -0
- package/pack/generated/devin/skills/overhaul/references/repair-and-measurement.md +207 -0
- package/pack/generated/devin/skills/overhaul/references/review-and-report.md +137 -0
- package/pack/generated/devin/skills/overhaul/references/scope-and-safety.md +191 -0
- package/pack/generated/devin/skills/overhaul/references/scoring.md +222 -0
- package/pack/generated/devin/skills/overhaul/references/stack-profiles.md +264 -0
- package/pack/generated/devin/skills/overhaul/roles/backend-engineer.md +61 -0
- package/pack/generated/devin/skills/overhaul/roles/boundary.md +56 -0
- package/pack/generated/devin/skills/overhaul/roles/craft.md +77 -0
- package/pack/generated/devin/skills/overhaul/roles/frontend-engineer.md +63 -0
- package/pack/generated/devin/skills/overhaul/roles/implementer.md +59 -0
- package/pack/generated/devin/skills/overhaul/roles/recon.md +60 -0
- package/pack/generated/devin/skills/overhaul/roles/specialist.md +56 -0
- package/pack/generated/devin/skills/overhaul/roles/verifier.md +60 -0
- package/pack/generated/omp/skills/overhaul/SKILL.md +201 -0
- package/pack/generated/omp/skills/overhaul/references/audit-domains.md +263 -0
- package/pack/generated/omp/skills/overhaul/references/lifecycle.md +308 -0
- package/pack/generated/omp/skills/overhaul/references/orchestration.md +264 -0
- package/pack/generated/omp/skills/overhaul/references/records.md +137 -0
- package/pack/generated/omp/skills/overhaul/references/repair-and-measurement.md +207 -0
- package/pack/generated/omp/skills/overhaul/references/review-and-report.md +137 -0
- package/pack/generated/omp/skills/overhaul/references/scope-and-safety.md +191 -0
- package/pack/generated/omp/skills/overhaul/references/scoring.md +222 -0
- package/pack/generated/omp/skills/overhaul/references/stack-profiles.md +264 -0
- package/pack/generated/omp/skills/overhaul/roles/backend-engineer.md +61 -0
- package/pack/generated/omp/skills/overhaul/roles/boundary.md +56 -0
- package/pack/generated/omp/skills/overhaul/roles/craft.md +77 -0
- package/pack/generated/omp/skills/overhaul/roles/frontend-engineer.md +63 -0
- package/pack/generated/omp/skills/overhaul/roles/implementer.md +59 -0
- package/pack/generated/omp/skills/overhaul/roles/recon.md +60 -0
- package/pack/generated/omp/skills/overhaul/roles/specialist.md +56 -0
- package/pack/generated/omp/skills/overhaul/roles/verifier.md +60 -0
- package/pack/generated/pi/skills/overhaul/SKILL.md +201 -0
- package/pack/generated/pi/skills/overhaul/references/audit-domains.md +263 -0
- package/pack/generated/pi/skills/overhaul/references/lifecycle.md +308 -0
- package/pack/generated/pi/skills/overhaul/references/orchestration.md +264 -0
- package/pack/generated/pi/skills/overhaul/references/records.md +137 -0
- package/pack/generated/pi/skills/overhaul/references/repair-and-measurement.md +207 -0
- package/pack/generated/pi/skills/overhaul/references/review-and-report.md +137 -0
- package/pack/generated/pi/skills/overhaul/references/scope-and-safety.md +191 -0
- package/pack/generated/pi/skills/overhaul/references/scoring.md +222 -0
- package/pack/generated/pi/skills/overhaul/references/stack-profiles.md +264 -0
- package/pack/generated/pi/skills/overhaul/roles/backend-engineer.md +61 -0
- package/pack/generated/pi/skills/overhaul/roles/boundary.md +56 -0
- package/pack/generated/pi/skills/overhaul/roles/craft.md +77 -0
- package/pack/generated/pi/skills/overhaul/roles/frontend-engineer.md +63 -0
- package/pack/generated/pi/skills/overhaul/roles/implementer.md +59 -0
- package/pack/generated/pi/skills/overhaul/roles/recon.md +60 -0
- package/pack/generated/pi/skills/overhaul/roles/specialist.md +56 -0
- package/pack/generated/pi/skills/overhaul/roles/verifier.md +60 -0
- package/package.json +1 -1
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
// Package snapshot implements the /overhaul snapshot tool (snapshot overhaul/1):
|
|
2
|
+
// baseline capture and drift check without touching the repository.
|
|
3
|
+
//
|
|
4
|
+
// Git runs read-only: GIT_OPTIONAL_LOCKS=0, no index refresh, no external diff
|
|
5
|
+
// drivers; no refs, stash or commit. Submodules and nested repositories are
|
|
6
|
+
// recorded as boundaries, never read. Files matching sensitive name patterns or
|
|
7
|
+
// holding private key material are hashed, never copied.
|
|
8
|
+
package snapshot
|
|
9
|
+
|
|
10
|
+
import (
|
|
11
|
+
"bytes"
|
|
12
|
+
"encoding/json"
|
|
13
|
+
"errors"
|
|
14
|
+
"fmt"
|
|
15
|
+
"io"
|
|
16
|
+
"io/fs"
|
|
17
|
+
"os"
|
|
18
|
+
"os/exec"
|
|
19
|
+
"path"
|
|
20
|
+
"path/filepath"
|
|
21
|
+
"reflect"
|
|
22
|
+
"slices"
|
|
23
|
+
"sort"
|
|
24
|
+
"strings"
|
|
25
|
+
"time"
|
|
26
|
+
|
|
27
|
+
"github.com/devrites/devrites/internal/gitenv"
|
|
28
|
+
"github.com/devrites/devrites/internal/overhaul/ovio"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
const usage = `usage: devrites-engine overhaul snapshot <command> ...
|
|
32
|
+
|
|
33
|
+
capture <repo> <out> copy tracked + non-ignored untracked files to <out>/tree, record index/HEAD state
|
|
34
|
+
fingerprint <repo> content fingerprint of the working tree (tracked + non-ignored untracked)
|
|
35
|
+
verify <repo> <out> [--agent-paths <file>] report index changes, user drift, and agent-owned changes
|
|
36
|
+
state <repo> <out.json> write per-file hashes (the before-image of one attempt)
|
|
37
|
+
delta <before.json> <repo> print paths whose content or mode changed since <before.json>, one per line
|
|
38
|
+
`
|
|
39
|
+
|
|
40
|
+
var sensitive = []string{".env", ".env.*", "*.key", "*.p12", "*.pfx", "*.keystore", "*.jks", ".npmrc", ".pypirc", ".netrc",
|
|
41
|
+
"*credentials*", "*secret*", "*.tfstate", "*.tfvars"}
|
|
42
|
+
|
|
43
|
+
// keyMarker flags private key material in any file, whatever its name.
|
|
44
|
+
var keyMarker = []byte("PRIVATE KEY-----")
|
|
45
|
+
|
|
46
|
+
var diffArgs = []string{"-c", "diff.autoRefreshIndex=false", "diff", "--no-ext-diff", "--no-textconv", "--no-color",
|
|
47
|
+
"--src-prefix=a/", "--dst-prefix=b/", "--binary"}
|
|
48
|
+
|
|
49
|
+
// Fields are declared in sorted order so the manifest matches sort_keys output.
|
|
50
|
+
type entry struct {
|
|
51
|
+
Copied bool `json:"copied"`
|
|
52
|
+
Mode int `json:"mode"`
|
|
53
|
+
Sensitive bool `json:"sensitive"`
|
|
54
|
+
SHA256 string `json:"sha256"`
|
|
55
|
+
Source string `json:"source"`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type manifest struct {
|
|
59
|
+
Fingerprint string `json:"fingerprint"`
|
|
60
|
+
Files map[string]*entry `json:"files"`
|
|
61
|
+
Head *string `json:"head"`
|
|
62
|
+
IndexSHA256 string `json:"index_sha256"`
|
|
63
|
+
Root string `json:"root"`
|
|
64
|
+
Schema string `json:"schema"`
|
|
65
|
+
Submodules []string `json:"submodules_not_captured"`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Run executes the tool with its arguments (tool name excluded).
|
|
69
|
+
func Run(args []string, stdout, stderr io.Writer) int {
|
|
70
|
+
code, err := dispatch(args, stdout)
|
|
71
|
+
if err != nil {
|
|
72
|
+
fmt.Fprintln(stderr, err)
|
|
73
|
+
}
|
|
74
|
+
return code
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
var errUsage = errors.New(strings.TrimRight(usage, "\n"))
|
|
78
|
+
|
|
79
|
+
func dispatch(a []string, stdout io.Writer) (int, error) {
|
|
80
|
+
if len(a) == 0 {
|
|
81
|
+
return 2, errUsage
|
|
82
|
+
}
|
|
83
|
+
switch {
|
|
84
|
+
case a[0] == "capture" && len(a) == 3:
|
|
85
|
+
return exit(capture(a[1], a[2], stdout))
|
|
86
|
+
case a[0] == "fingerprint" && len(a) == 2:
|
|
87
|
+
st, err := state(a[1])
|
|
88
|
+
if err != nil {
|
|
89
|
+
return 2, err
|
|
90
|
+
}
|
|
91
|
+
fmt.Fprintln(stdout, fingerprint(st))
|
|
92
|
+
return 0, nil
|
|
93
|
+
case a[0] == "verify" && (len(a) == 3 || len(a) == 5 && a[3] == "--agent-paths"):
|
|
94
|
+
agent := map[string]bool{}
|
|
95
|
+
if len(a) == 5 {
|
|
96
|
+
b, err := os.ReadFile(a[4])
|
|
97
|
+
if err != nil {
|
|
98
|
+
return 2, err
|
|
99
|
+
}
|
|
100
|
+
for _, ln := range strings.Split(string(b), "\n") {
|
|
101
|
+
if ln = strings.TrimSpace(ln); ln != "" {
|
|
102
|
+
agent[ln] = true
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return verify(a[1], a[2], agent, stdout)
|
|
107
|
+
case a[0] == "state" && len(a) == 3:
|
|
108
|
+
st, err := state(a[1])
|
|
109
|
+
if err != nil {
|
|
110
|
+
return 2, err
|
|
111
|
+
}
|
|
112
|
+
out := map[string][]any{}
|
|
113
|
+
for p, v := range st {
|
|
114
|
+
out[p] = []any{v.SHA256, v.Mode}
|
|
115
|
+
}
|
|
116
|
+
return exit(ovio.WriteJSON(a[2], out, 0o644))
|
|
117
|
+
case a[0] == "delta" && len(a) == 3:
|
|
118
|
+
return exit(delta(a[1], a[2], stdout))
|
|
119
|
+
}
|
|
120
|
+
return 2, errUsage
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func exit(err error) (int, error) {
|
|
124
|
+
if err != nil {
|
|
125
|
+
return 2, err
|
|
126
|
+
}
|
|
127
|
+
return 0, nil
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func git(root string, args ...string) ([]byte, error) {
|
|
131
|
+
cmd := exec.Command("git", append([]string{"-C", root}, args...)...) // #nosec G204 -- fixed git binary; arguments passed as argv, no shell
|
|
132
|
+
cmd.Env = append(gitenv.Sanitize(os.Environ()), "GIT_OPTIONAL_LOCKS=0", "GIT_TERMINAL_PROMPT=0")
|
|
133
|
+
out, err := cmd.Output()
|
|
134
|
+
if err != nil {
|
|
135
|
+
var ee *exec.ExitError
|
|
136
|
+
if errors.As(err, &ee) {
|
|
137
|
+
return nil, fmt.Errorf("git %s: %v: %s", strings.Join(args, " "), err, bytes.TrimSpace(ee.Stderr))
|
|
138
|
+
}
|
|
139
|
+
return nil, fmt.Errorf("git %s: %v", strings.Join(args, " "), err)
|
|
140
|
+
}
|
|
141
|
+
return out, nil
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func split0(b []byte) []string {
|
|
145
|
+
var out []string
|
|
146
|
+
for _, s := range strings.Split(string(b), "\x00") {
|
|
147
|
+
if s != "" {
|
|
148
|
+
out = append(out, s)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// files lists tracked paths and non-ignored untracked paths, each sorted and deduplicated.
|
|
155
|
+
func files(root string) (tracked, untracked []string, err error) {
|
|
156
|
+
t, err := git(root, "ls-files", "-z", "--cached")
|
|
157
|
+
if err != nil {
|
|
158
|
+
return nil, nil, err
|
|
159
|
+
}
|
|
160
|
+
u, err := git(root, "ls-files", "-z", "--others", "--exclude-standard")
|
|
161
|
+
if err != nil {
|
|
162
|
+
return nil, nil, err
|
|
163
|
+
}
|
|
164
|
+
tracked = split0(t)
|
|
165
|
+
sort.Strings(tracked)
|
|
166
|
+
tracked = compact(tracked)
|
|
167
|
+
seen := map[string]bool{}
|
|
168
|
+
for _, p := range tracked {
|
|
169
|
+
seen[p] = true
|
|
170
|
+
}
|
|
171
|
+
for _, p := range split0(u) {
|
|
172
|
+
if !seen[p] {
|
|
173
|
+
seen[p] = true
|
|
174
|
+
untracked = append(untracked, p)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
sort.Strings(untracked)
|
|
178
|
+
return tracked, untracked, nil
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func compact(s []string) []string {
|
|
182
|
+
out := s[:0]
|
|
183
|
+
for i, p := range s {
|
|
184
|
+
if i == 0 || p != s[i-1] {
|
|
185
|
+
out = append(out, p)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return out
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// permBits mirrors Python's stat.S_IMODE: permission plus setuid/setgid/sticky bits.
|
|
192
|
+
func permBits(m fs.FileMode) int {
|
|
193
|
+
b := int(m.Perm())
|
|
194
|
+
if m&fs.ModeSetuid != 0 {
|
|
195
|
+
b |= 0o4000
|
|
196
|
+
}
|
|
197
|
+
if m&fs.ModeSetgid != 0 {
|
|
198
|
+
b |= 0o2000
|
|
199
|
+
}
|
|
200
|
+
if m&fs.ModeSticky != 0 {
|
|
201
|
+
b |= 0o1000
|
|
202
|
+
}
|
|
203
|
+
return b
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
func digest(p string) (string, int, error) {
|
|
207
|
+
fi, err := os.Lstat(p)
|
|
208
|
+
if err != nil {
|
|
209
|
+
return "deleted", 0, nil
|
|
210
|
+
}
|
|
211
|
+
switch {
|
|
212
|
+
case fi.Mode()&fs.ModeSymlink != 0:
|
|
213
|
+
target, err := os.Readlink(p)
|
|
214
|
+
if err != nil {
|
|
215
|
+
return "", 0, err
|
|
216
|
+
}
|
|
217
|
+
return "link:" + ovio.SHA256Bytes([]byte(target)), 0o120000, nil
|
|
218
|
+
case fi.IsDir():
|
|
219
|
+
return "directory", 0, nil
|
|
220
|
+
}
|
|
221
|
+
sum, err := ovio.SHA256File(p)
|
|
222
|
+
return sum, permBits(fi.Mode()), err
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
func holdsKey(p string) (bool, error) {
|
|
226
|
+
fi, err := os.Lstat(p)
|
|
227
|
+
if err != nil || !fi.Mode().IsRegular() {
|
|
228
|
+
return false, nil
|
|
229
|
+
}
|
|
230
|
+
f, err := os.Open(p) // #nosec G304 -- file listed by git inside the target repository
|
|
231
|
+
if err != nil {
|
|
232
|
+
return false, err
|
|
233
|
+
}
|
|
234
|
+
defer func() { _ = f.Close() }()
|
|
235
|
+
head, err := io.ReadAll(io.LimitReader(f, 65536))
|
|
236
|
+
return bytes.Contains(head, keyMarker), err
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
func isSensitiveName(p string) bool {
|
|
240
|
+
base := p[strings.LastIndex(p, "/")+1:]
|
|
241
|
+
for _, pat := range sensitive {
|
|
242
|
+
if ok, _ := path.Match(pat, base); ok {
|
|
243
|
+
return true
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return false
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
func state(root string) (map[string]*entry, error) {
|
|
250
|
+
tracked, untracked, err := files(root)
|
|
251
|
+
if err != nil {
|
|
252
|
+
return nil, err
|
|
253
|
+
}
|
|
254
|
+
out := map[string]*entry{}
|
|
255
|
+
for _, g := range []struct {
|
|
256
|
+
src string
|
|
257
|
+
paths []string
|
|
258
|
+
}{{"tracked", tracked}, {"untracked", untracked}} {
|
|
259
|
+
for _, p := range g.paths {
|
|
260
|
+
full := filepath.Join(root, filepath.FromSlash(p))
|
|
261
|
+
sum, mode, err := digest(full)
|
|
262
|
+
if err != nil {
|
|
263
|
+
return nil, err
|
|
264
|
+
}
|
|
265
|
+
sens := isSensitiveName(p)
|
|
266
|
+
if !sens {
|
|
267
|
+
if sens, err = holdsKey(full); err != nil {
|
|
268
|
+
return nil, err
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
out[p] = &entry{SHA256: sum, Mode: mode, Source: g.src, Sensitive: sens}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return out, nil
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
func indexDigest(root string) (string, error) {
|
|
278
|
+
b, err := git(root, "ls-files", "-s", "-z")
|
|
279
|
+
if err != nil {
|
|
280
|
+
return "", err
|
|
281
|
+
}
|
|
282
|
+
return ovio.SHA256Bytes(b), nil
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// fingerprint is sha256 over sorted "path\0sha\0octal-mode\n" lines.
|
|
286
|
+
func fingerprint(st map[string]*entry) string {
|
|
287
|
+
paths := make([]string, 0, len(st))
|
|
288
|
+
for p := range st {
|
|
289
|
+
paths = append(paths, p)
|
|
290
|
+
}
|
|
291
|
+
sort.Strings(paths)
|
|
292
|
+
var buf bytes.Buffer
|
|
293
|
+
for _, p := range paths {
|
|
294
|
+
fmt.Fprintf(&buf, "%s\x00%s\x00%o\n", p, st[p].SHA256, st[p].Mode)
|
|
295
|
+
}
|
|
296
|
+
return ovio.SHA256Bytes(buf.Bytes())
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
func capture(root, out string, stdout io.Writer) error {
|
|
300
|
+
if _, err := os.Lstat(out); err == nil {
|
|
301
|
+
return fmt.Errorf("refusing to overwrite existing snapshot %s", out)
|
|
302
|
+
}
|
|
303
|
+
absRoot, err := filepath.Abs(root)
|
|
304
|
+
if err != nil {
|
|
305
|
+
return err
|
|
306
|
+
}
|
|
307
|
+
root = ovio.Resolve(absRoot)
|
|
308
|
+
absOut, err := filepath.Abs(out)
|
|
309
|
+
if err != nil {
|
|
310
|
+
return err
|
|
311
|
+
}
|
|
312
|
+
if strings.HasPrefix(ovio.Resolve(absOut), root+string(os.PathSeparator)) {
|
|
313
|
+
return errors.New("snapshot must live outside the target repository")
|
|
314
|
+
}
|
|
315
|
+
idx, err := git(root, "ls-files", "-s", "-z")
|
|
316
|
+
if err != nil {
|
|
317
|
+
return err
|
|
318
|
+
}
|
|
319
|
+
entries := split0(idx)
|
|
320
|
+
subs := map[string]bool{}
|
|
321
|
+
for _, e := range entries {
|
|
322
|
+
meta, p, _ := strings.Cut(e, "\t")
|
|
323
|
+
if f := strings.Fields(meta); len(f) < 3 || f[2] != "0" {
|
|
324
|
+
return errors.New("index has unmerged entries; ask the user which baseline to capture")
|
|
325
|
+
}
|
|
326
|
+
if strings.HasPrefix(e, "160000 ") {
|
|
327
|
+
subs[p] = true
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
before := ovio.SHA256Bytes(idx)
|
|
331
|
+
st, err := state(root)
|
|
332
|
+
if err != nil {
|
|
333
|
+
return err
|
|
334
|
+
}
|
|
335
|
+
for p, v := range st {
|
|
336
|
+
if v.SHA256 == "directory" {
|
|
337
|
+
subs[p] = true
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
gitlinks := []string{}
|
|
341
|
+
for p := range subs {
|
|
342
|
+
gitlinks = append(gitlinks, p)
|
|
343
|
+
}
|
|
344
|
+
sort.Strings(gitlinks)
|
|
345
|
+
|
|
346
|
+
if err := os.MkdirAll(filepath.Join(out, "tree"), 0o700); err != nil {
|
|
347
|
+
return err
|
|
348
|
+
}
|
|
349
|
+
if err := os.Chmod(out, 0o700); err != nil {
|
|
350
|
+
return err
|
|
351
|
+
}
|
|
352
|
+
for p, v := range st {
|
|
353
|
+
v.Copied = !v.Sensitive && v.SHA256 != "deleted" && v.SHA256 != "directory"
|
|
354
|
+
if v.Copied {
|
|
355
|
+
if err := copyEntry(filepath.Join(root, filepath.FromSlash(p)), filepath.Join(out, "tree", filepath.FromSlash(p))); err != nil {
|
|
356
|
+
return err
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// Sensitive paths stay out of the patches too: hash only, never content.
|
|
361
|
+
safe := []string{"--", "."}
|
|
362
|
+
for p, v := range st {
|
|
363
|
+
if v.Sensitive {
|
|
364
|
+
safe = append(safe, ":(top,exclude,literal)"+p)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
for _, o := range []struct {
|
|
368
|
+
name string
|
|
369
|
+
args []string
|
|
370
|
+
}{
|
|
371
|
+
{"status.bin", []string{"status", "--porcelain=v2", "-z", "--untracked-files=all"}},
|
|
372
|
+
{"index.bin", []string{"ls-files", "-s", "-z"}},
|
|
373
|
+
{"staged.patch", slices.Concat(diffArgs, []string{"--cached"}, safe)},
|
|
374
|
+
{"unstaged.patch", slices.Concat(diffArgs, safe)},
|
|
375
|
+
} {
|
|
376
|
+
b, err := git(root, o.args...)
|
|
377
|
+
if err != nil {
|
|
378
|
+
return err
|
|
379
|
+
}
|
|
380
|
+
if err := os.WriteFile(filepath.Join(out, o.name), b, 0o600); err != nil {
|
|
381
|
+
return err
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
var head *string
|
|
385
|
+
if b, err := git(root, "rev-parse", "--verify", "-q", "HEAD"); err == nil {
|
|
386
|
+
if h := strings.TrimSpace(string(b)); h != "" {
|
|
387
|
+
head = &h
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
after, err := indexDigest(root)
|
|
391
|
+
if err != nil {
|
|
392
|
+
return err
|
|
393
|
+
}
|
|
394
|
+
if after != before {
|
|
395
|
+
return errors.New("index changed during capture; stop and reconcile with the user")
|
|
396
|
+
}
|
|
397
|
+
now, err := state(root)
|
|
398
|
+
if err != nil {
|
|
399
|
+
return err
|
|
400
|
+
}
|
|
401
|
+
man := manifest{Schema: "overhaul.snapshot/1", Root: root, Head: head, IndexSHA256: before,
|
|
402
|
+
Fingerprint: fingerprint(now), Files: st, Submodules: gitlinks}
|
|
403
|
+
if err := ovio.WriteJSON(filepath.Join(out, "manifest.json"), man, 0o600); err != nil {
|
|
404
|
+
return err
|
|
405
|
+
}
|
|
406
|
+
withheld := []string{}
|
|
407
|
+
for p, v := range st {
|
|
408
|
+
if v.Sensitive {
|
|
409
|
+
withheld = append(withheld, p)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
sort.Strings(withheld)
|
|
413
|
+
b, err := json.Marshal(struct {
|
|
414
|
+
Snapshot string `json:"snapshot"`
|
|
415
|
+
Fingerprint string `json:"fingerprint"`
|
|
416
|
+
Files int `json:"files"`
|
|
417
|
+
Withheld []string `json:"sensitive_not_copied"`
|
|
418
|
+
}{out, man.Fingerprint, len(st), withheld})
|
|
419
|
+
if err != nil {
|
|
420
|
+
return err
|
|
421
|
+
}
|
|
422
|
+
fmt.Fprintln(stdout, string(b))
|
|
423
|
+
return nil
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// copyEntry copies like shutil.copy2(follow_symlinks=False): symlinks stay
|
|
427
|
+
// symlinks; files keep their mode and modification time.
|
|
428
|
+
func copyEntry(src, dst string) error {
|
|
429
|
+
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
|
|
430
|
+
return err
|
|
431
|
+
}
|
|
432
|
+
fi, err := os.Lstat(src)
|
|
433
|
+
if err != nil {
|
|
434
|
+
return err
|
|
435
|
+
}
|
|
436
|
+
if fi.Mode()&fs.ModeSymlink != 0 {
|
|
437
|
+
target, err := os.Readlink(src)
|
|
438
|
+
if err != nil {
|
|
439
|
+
return err
|
|
440
|
+
}
|
|
441
|
+
return os.Symlink(target, dst)
|
|
442
|
+
}
|
|
443
|
+
in, err := os.Open(src) // #nosec G304 -- file listed by git inside the target repository
|
|
444
|
+
if err != nil {
|
|
445
|
+
return err
|
|
446
|
+
}
|
|
447
|
+
defer func() { _ = in.Close() }()
|
|
448
|
+
w, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) // #nosec G304 -- destination inside the new owner-only snapshot directory
|
|
449
|
+
if err != nil {
|
|
450
|
+
return err
|
|
451
|
+
}
|
|
452
|
+
_, err = io.Copy(w, in)
|
|
453
|
+
if cerr := w.Close(); err == nil {
|
|
454
|
+
err = cerr
|
|
455
|
+
}
|
|
456
|
+
if err == nil {
|
|
457
|
+
err = os.Chmod(dst, fi.Mode())
|
|
458
|
+
}
|
|
459
|
+
if err == nil {
|
|
460
|
+
err = os.Chtimes(dst, time.Time{}, fi.ModTime())
|
|
461
|
+
}
|
|
462
|
+
return err
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
func verify(root, out string, agent map[string]bool, stdout io.Writer) (int, error) {
|
|
466
|
+
b, err := os.ReadFile(filepath.Join(out, "manifest.json")) // #nosec G304 -- manifest inside the operator-selected snapshot
|
|
467
|
+
if err != nil {
|
|
468
|
+
return 2, err
|
|
469
|
+
}
|
|
470
|
+
var man manifest
|
|
471
|
+
if err := json.Unmarshal(b, &man); err != nil {
|
|
472
|
+
return 2, fmt.Errorf("%s: invalid manifest (%v)", out, err)
|
|
473
|
+
}
|
|
474
|
+
now, err := state(root)
|
|
475
|
+
if err != nil {
|
|
476
|
+
return 2, err
|
|
477
|
+
}
|
|
478
|
+
union := map[string]bool{}
|
|
479
|
+
for p := range man.Files {
|
|
480
|
+
union[p] = true
|
|
481
|
+
}
|
|
482
|
+
for p := range now {
|
|
483
|
+
union[p] = true
|
|
484
|
+
}
|
|
485
|
+
report := struct {
|
|
486
|
+
IndexUnchanged bool `json:"index_unchanged"`
|
|
487
|
+
Agent []string `json:"agent_owned_changes"`
|
|
488
|
+
User []string `json:"changes_outside_agent_paths"`
|
|
489
|
+
Fingerprint string `json:"fingerprint"`
|
|
490
|
+
}{Agent: []string{}, User: []string{}, Fingerprint: fingerprint(now)}
|
|
491
|
+
for p := range union {
|
|
492
|
+
was, cur := man.Files[p], now[p]
|
|
493
|
+
if cur == nil {
|
|
494
|
+
cur = &entry{SHA256: "deleted"}
|
|
495
|
+
}
|
|
496
|
+
if was != nil && was.SHA256 == cur.SHA256 && was.Mode == cur.Mode {
|
|
497
|
+
continue
|
|
498
|
+
}
|
|
499
|
+
if agent[p] {
|
|
500
|
+
report.Agent = append(report.Agent, p)
|
|
501
|
+
} else {
|
|
502
|
+
report.User = append(report.User, p)
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
sort.Strings(report.Agent)
|
|
506
|
+
sort.Strings(report.User)
|
|
507
|
+
idx, err := indexDigest(root)
|
|
508
|
+
if err != nil {
|
|
509
|
+
return 2, err
|
|
510
|
+
}
|
|
511
|
+
report.IndexUnchanged = idx == man.IndexSHA256
|
|
512
|
+
j, err := ovio.MarshalIndent(report)
|
|
513
|
+
if err != nil {
|
|
514
|
+
return 2, err
|
|
515
|
+
}
|
|
516
|
+
_, _ = stdout.Write(j)
|
|
517
|
+
if report.IndexUnchanged && len(report.User) == 0 {
|
|
518
|
+
return 0, nil
|
|
519
|
+
}
|
|
520
|
+
return 1, nil
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
func delta(beforePath, root string, stdout io.Writer) error {
|
|
524
|
+
b, err := os.ReadFile(beforePath) // #nosec G304 -- state file is an explicit operator argument
|
|
525
|
+
if err != nil {
|
|
526
|
+
return err
|
|
527
|
+
}
|
|
528
|
+
var old map[string]any
|
|
529
|
+
if err := json.Unmarshal(b, &old); err != nil {
|
|
530
|
+
return fmt.Errorf("%s: invalid state (%v)", beforePath, err)
|
|
531
|
+
}
|
|
532
|
+
st, err := state(root)
|
|
533
|
+
if err != nil {
|
|
534
|
+
return err
|
|
535
|
+
}
|
|
536
|
+
union := map[string]bool{}
|
|
537
|
+
for p := range old {
|
|
538
|
+
union[p] = true
|
|
539
|
+
}
|
|
540
|
+
for p := range st {
|
|
541
|
+
union[p] = true
|
|
542
|
+
}
|
|
543
|
+
paths := make([]string, 0, len(union))
|
|
544
|
+
for p := range union {
|
|
545
|
+
paths = append(paths, p)
|
|
546
|
+
}
|
|
547
|
+
sort.Strings(paths)
|
|
548
|
+
missing := []any{"deleted", float64(0)}
|
|
549
|
+
for _, p := range paths {
|
|
550
|
+
was, ok := old[p]
|
|
551
|
+
if !ok {
|
|
552
|
+
was = missing
|
|
553
|
+
}
|
|
554
|
+
cur := missing
|
|
555
|
+
if v := st[p]; v != nil {
|
|
556
|
+
cur = []any{v.SHA256, float64(v.Mode)}
|
|
557
|
+
}
|
|
558
|
+
if !reflect.DeepEqual(was, cur) {
|
|
559
|
+
fmt.Fprintln(stdout, p)
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return nil
|
|
563
|
+
}
|