Repository navigation
Expand file tree
/
Copy pathgit.go
More file actions
executable file
·901 lines (871 loc) · 37.8 KB
/
Copy pathgit.go
File metadata and controls
executable file
·901 lines (871 loc) · 37.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
package opnborg
import (
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"github.com/go-git/go-git/v5"
gitcfg "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
gitindex "github.com/go-git/go-git/v5/plumbing/format/index"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport/ssh"
xssh "golang.org/x/crypto/ssh"
)
const (
_dotGit = ".git"
_gitignore = ".gitignore"
// _ignore is the canonical .gitignore content written into the storage
// root. It keeps the archive/history files, the symlink targets, the
// logs, and the on-disk security-approval ledger out of the commit
// history. The approval ledger lives in the dedicated .db directory
// (<store>/.db) and comprises three SQLite files — approval.db (main
// database), approval.db-wal (write-ahead log), and approval.db-shm
// (shared memory index). The ".db/" entry ignores that whole directory
// (for the opnborg commit cycle AND for manual git commands), and the
// three explicit filenames additionally cover a legacy root-level
// ledger and any stale spelling. gitCommit additionally skips every
// approval-ledger path at staging time (isApprovalDBPath) so a ledger
// update can never enter a commit, even when a stale or hand-edited
// .gitignore failed to ignore the file.
_ignore = ".archive\nCONFIG*\nLogs\n.db/\napproval.db\napproval.db-shm\napproval.db-wal\n"
_origin = "origin"
_commitMsg = "opnborg auto update"
_authorName = "OPNBORG-AUTO-COMMIT"
_defaultSSHUser = "git"
_aggressivePackWindow = uint(250)
)
// gitLastPush tracks the outcome of the most recent upstream push so the
// WebUI dashboard can surface upstream sync health. It is mutated only from
// gitPush (which runs inside gitCheckIn on the main loop) and read from the
// httpd render goroutine, so a dedicated mutex guards it.
var (
gitLastPushMu sync.Mutex
gitLastPushTS time.Time
gitLastPushOK bool
gitLastPushMsg string
)
// gitRepo opens the existing git repository at the given path, or initialises a
// fresh non-bare repository when none is present yet. The path is expected to
// already exist (the storage root is created elsewhere); this only manages the
// .git metadata side of the folder.
func gitRepo(path string) (*git.Repository, error) {
repo, err := git.PlainOpen(path)
if err == nil {
return repo, nil
}
if !errors.Is(err, git.ErrRepositoryNotExists) {
return nil, err
}
return git.PlainInit(path, false)
}
// gitEnsureIgnore writes the .gitignore file in the storage root when it is
// missing, so the archive/history files, the symlink targets, the logs, and
// the on-disk security-approval ledger stay out of the commit history. When a
// .gitignore already exists it is reconciled rather than clobbered: the
// reconcile guarantees the ".db/" directory entry is present so the ledger
// directory (database, WAL sidecars, and any stray file inside it) is never
// added, evaluated, or committed — by the opnborg commit cycle or by a manual
// `git add .`. This migration matters because older opnborg releases placed
// the ledger files at the store root or used a glob ("approval.db*") that
// covers the files but not the directory. Existing recognised file patterns
// are preserved as-is; fresh stores also list approval.db, approval.db-wal and
// approval.db-shm explicitly. All other lines (including operator-added custom
// ignores) are preserved.
func gitEnsureIgnore(config *OPNCall) error {
ignore := filepath.Join(config.Path, _gitignore)
if _, err := os.Stat(ignore); err == nil {
return reconcileGitignoreApprovalLedger(ignore)
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.WriteFile(ignore, []byte(_ignore), 0660); err != nil {
displayChan <- []byte("[GIT][REPO][ERROR][FAIL:UNABLE-TO-CREATE-GIT-IGNORE-FILE] " + config.Path)
return err
}
return nil
}
// reconcileGitignoreApprovalLedger guarantees the .gitignore at ignore
// carries the ".db/" directory entry, which ignores the whole security-approval
// ledger: the database, both SQLite WAL sidecars, and any stray file inside the
// directory. When the entry (with or without a leading slash) is already
// present the file is left untouched, so a clean .gitignore incurs no write;
// otherwise only that one line is appended. The three ledger file names may
// already be covered by any recognised spelling (bare name, sidecar, or
// trailing-star glob — see ignoresApprovalLedger) and are not duplicated.
// Operator-added custom ignores and the canonical archive/CONFIG/Logs lines
// are always preserved.
func reconcileGitignoreApprovalLedger(ignore string) error {
raw, err := os.ReadFile(ignore)
if err != nil {
return err
}
out := string(raw)
// The ledger file names may already be covered by any recognised spelling
// (bare name, sidecars, trailing-star glob — see ignoresApprovalLedger),
// and the directory entry subsumes all of them. What must never be
// missing is the .db/ directory ignore: without it a stray file in .db/
// (or a WAL sidecar not covered by a hand-edited pattern list) could be
// picked up by a manual `git add .`. Append only that line when absent.
if gitignoreHasLine(out, _approvalDBDir+"/") || gitignoreHasLine(out, _approvalDBDir) {
return nil
}
if !strings.HasSuffix(out, "\n") {
out += "\n"
}
return os.WriteFile(ignore, []byte(out+_approvalDBDir+"/\n"), 0660)
}
// gitignoreHasLine reports whether body carries the exact ignore pattern
// want (on its own non-comment line, with an optional leading slash).
func gitignoreHasLine(body, want string) bool {
for line := range strings.SplitSeq(body, "\n") {
p := strings.TrimSpace(line)
if p == "" || strings.HasPrefix(p, "#") {
continue
}
if strings.TrimPrefix(p, "/") == want {
return true
}
}
return false
}
// gitignoreIgnoresApprovalLedger reports whether a whole .gitignore body
// already contains a line that would ignore the security-approval ledger: the
// main database file (approval.db) or any of its SQLite WAL sidecars
// (approval.db-wal, approval.db-shm). A gitignore pattern without a slash
// matches by basename at any depth, so a bare "approval.db",
// "approval.db-wal", "approval.db-shm", or a trailing-star
// "approval.db*" glob each satisfies the check. Blank lines, comments, and
// any pattern that does not reduce to one of the ledger filenames (with an
// optional leading slash) are reported as not ignoring the ledger.
func gitignoreIgnoresApprovalLedger(body string) bool {
for line := range strings.SplitSeq(body, "\n") {
if ignoresApprovalLedger(line) {
return true
}
}
return false
}
// _approvalLedgerNames lists the ignore patterns that cover the on-disk
// security-approval ledger. Hoisted out of the per-line hot path so
// ignoresApprovalLedger does not allocate it for every .gitignore line.
var _approvalLedgerNames = []string{_approvalDBName, _approvalDBName + "-wal", _approvalDBName + "-shm"}
// ignoresApprovalLedger reports whether a single .gitignore line would ignore
// the security-approval ledger: the main database file (approval.db) or any of
// its SQLite WAL sidecars (approval.db-wal, approval.db-shm). A gitignore
// pattern without a slash matches by basename at any depth, so a bare
// "approval.db" or "approval.db*" line ignores the ledger everywhere in the
// store. The check is conservative: blank lines, comments, and any pattern
// that does not reduce to one of the ledger filenames (with an optional
// leading slash) are reported as not ignoring the ledger, so legitimate
// operator-added ignores are never treated as covering the ledger.
func ignoresApprovalLedger(line string) bool {
// A range over SplitSeq yields an empty trailing element for a
// newline-terminated input; an empty line never ignores anything.
p := strings.TrimSpace(line)
if p == "" || strings.HasPrefix(p, "#") {
return false
}
p = strings.TrimPrefix(p, "/")
// The dedicated ledger directory ignores the ledger wholesale: a ".db"
// or ".db/" pattern matches the directory (and everything inside it) at
// any depth, so it always satisfies the check.
if p == _approvalDBDir {
return true
}
if dir, ok := strings.CutSuffix(p, "/"); ok && dir == _approvalDBDir {
return true
}
if strings.Contains(p, "/") {
// An anchored path pattern is out of scope: the ledger lives at the
// store root, so a nested ignore never targets it.
return false
}
if slices.Contains(_approvalLedgerNames, p) {
return true
}
// A trailing-* glob anchored to the ledger basename (e.g. "approval.db*")
// also matches the sidecars.
if base, ok := strings.CutSuffix(p, "*"); ok {
if base == _approvalDBName || strings.HasPrefix(base, _approvalDBName+"-") {
return true
}
}
return false
}
// isApprovalDBPath reports whether a worktree path belongs to the
// security-approval ledger: the dedicated .db directory (anything at or
// below "<store>/.db" at any depth), the ledger database (approval.db), or
// one of its SQLite WAL sidecars (approval.db-wal, approval.db-shm). The
// first guard is the .db directory: every path whose first component is
// ".db", plus any path containing a "/.db/" segment, is excluded wholesale —
// no file inside the ledger directory may ever be staged or committed,
// regardless of its filename. The second guard is the substring
// "approval.db": it matches the main database and every WAL sidecar at any
// depth in the store ("approval.db", "approval.db-wal", "approval.db-shm",
// "store/sub/approval.db", "approval.db.bak", ...), which is exactly the
// "never commit a file containing the string 'approval.db'" guarantee — it
// also covers a legacy root-level ledger written before the .db directory
// existed. The base-name fallback additionally catches ledger-style
// spellings that do not literally contain "approval.db" (e.g. a hypothetical
// "approval-wal.db") so the protection is strictly broader than the
// substring alone. gitCommit uses it to skip every approval-ledger path at
// staging time so a ledger update can never enter a commit, even when a
// stale or hand-edited .gitignore failed to ignore the file.
func isApprovalDBPath(p string) bool {
p = strings.TrimPrefix(p, "./")
if p == _approvalDBDir || strings.HasPrefix(p, _approvalDBDir+"/") || strings.Contains(p, "/"+_approvalDBDir+"/") {
return true
}
if strings.Contains(p, _approvalDBName) {
return true
}
base := filepath.Base(p)
if !strings.HasPrefix(base, "approval") {
return false
}
return strings.HasSuffix(base, ".db") || strings.Contains(base, ".db-")
}
// gitLedgerScrubIndex is the last-chance filter in front of every commit: it
// removes every security-approval ledger path (approval.db, its SQLite WAL
// sidecars, anything inside .db/, and every ledger-style spelling — see
// isApprovalDBPath) from the git index and persists the scrubbed index. A
// commit tree is built from the index, so a ledger path that is absent from
// the index can never reach a commit — no matter which code path staged it
// (a stale go-git re-stage of tracked-but-ignored files, a future regression
// reintroducing wtree.Add("."), or a hand-run `git add .` against a stale
// .gitignore). The staging skip in gitCommit is the first filter; this scrub
// is the independent second one. It returns the removed paths so the caller
// can log loudly when the first line of defence was bypassed, and a nil
// slice (with no write) when the index is already ledger-clean.
func gitLedgerScrubIndex(repo *git.Repository) ([]string, error) {
idx, err := repo.Storer.Index()
if err != nil {
return nil, fmt.Errorf("read index: %w", err)
}
var removed []string
entries := make([]*gitindex.Entry, 0, len(idx.Entries))
for _, e := range idx.Entries {
if isApprovalDBPath(e.Name) {
removed = append(removed, e.Name)
continue
}
entries = append(entries, e)
}
if len(removed) == 0 {
return nil, nil
}
idx.Entries = entries
if err := repo.Storer.SetIndex(idx); err != nil {
return removed, fmt.Errorf("persist scrubbed index: %w", err)
}
return removed, nil
}
// gitLedgerVerifyCommit is the runtime verification behind every commit: it
// walks the freshly created commit's tree and reports every security-approval
// ledger path it finds. A non-empty result means the staged-filter defences
// were bypassed and ledger content reached the history — the caller logs the
// breach and fails the tick so the operator notices. It is deliberately
// independent of the staging path: it inspects the commit object that now
// exists, not the code that produced it. A repo without any commits yet is
// clean (the caller passes HEAD, which only exists after the first commit).
func gitLedgerVerifyCommit(repo *git.Repository, commit plumbing.Hash) ([]string, error) {
obj, err := repo.CommitObject(commit)
if err != nil {
return nil, fmt.Errorf("read commit %s: %w", commit, err)
}
tree, err := obj.Tree()
if err != nil {
return nil, fmt.Errorf("read tree of %s: %w", commit, err)
}
var leaked []string
iter := tree.Files()
for {
f, err := iter.Next()
if err != nil {
if !errors.Is(err, io.EOF) {
return leaked, fmt.Errorf("walk tree of %s: %w", commit, err)
}
break
}
if isApprovalDBPath(f.Name) || strings.HasPrefix(f.Name, _approvalDBDir+"/") || f.Name == _approvalDBDir {
leaked = append(leaked, f.Name)
}
}
return leaked, nil
}
// gitLedgerAuditTracked is the startup runtime verification: it walks the
// HEAD tree of the storage repo and reports every tracked security-approval
// ledger path. A non-empty result means ledger content is already inside the
// commit history (a store inherited from an era or a manual `git add` that
// committed the ledger before the guards existed). opnborg never rewrites
// history — the finding is logged loudly so the operator can purge the blob
// upstream — but the new-commit defences (staging skip + index scrub + tree
// verify) guarantee no further ledger content is added. A repo with no
// commits yet (fresh init) reports clean.
func gitLedgerAuditTracked(repo *git.Repository) ([]string, error) {
head, err := repo.Head()
if err != nil {
if errors.Is(err, plumbing.ErrReferenceNotFound) {
return nil, nil
}
return nil, fmt.Errorf("read HEAD: %w", err)
}
obj, err := repo.CommitObject(head.Hash())
if err != nil {
return nil, fmt.Errorf("read HEAD commit: %w", err)
}
tree, err := obj.Tree()
if err != nil {
return nil, fmt.Errorf("read HEAD tree: %w", err)
}
var tracked []string
iter := tree.Files()
for {
f, err := iter.Next()
if err != nil {
if !errors.Is(err, io.EOF) {
return tracked, fmt.Errorf("walk HEAD tree: %w", err)
}
break
}
if isApprovalDBPath(f.Name) || strings.HasPrefix(f.Name, _approvalDBDir+"/") || f.Name == _approvalDBDir {
tracked = append(tracked, f.Name)
}
}
return tracked, nil
}
// sshUserFromURL extracts the SSH user from an SSH git URL. It handles both the
// scp-style form ("user@host:path") and the ssh:// URI form
// ("ssh://user@host[:port]/path"). It falls back to the conventional "git"
// user when the URL does not carry an explicit one.
func sshUserFromURL(upstream string) string {
if strings.HasPrefix(upstream, "ssh://") {
rest := upstream[len("ssh://"):]
if at := strings.IndexByte(rest, '@'); at > 0 {
return rest[:at]
}
return _defaultSSHUser
}
if at := strings.IndexByte(upstream, '@'); at > 0 {
return upstream[:at]
}
return _defaultSSHUser
}
// gitSSHAuth builds an SSH public-key auth method from the private key file
// at keyPath, using the user derived from the upstream URL. Host key
// verification never reads or writes a known_hosts file (go-git's default
// callback would fail with "cannot create known hosts callback: $HOME is not
// defined" in container/CI runs that unset HOME). Instead:
//
// - When hostKeyFingerprint is empty, host key verification is skipped
// entirely (insecure) so the upstream push works in unattended
// environments. Pin a fingerprint whenever the upstream is reachable over
// an untrusted network.
// - When hostKeyFingerprint is set, only an upstream host whose presented
// public key SHA-256 fingerprint matches is accepted; any other key
// aborts the push.
//
// The fingerprint may be given either in the OpenSSH presentation form
// ("SHA256:<base64-no-pad>") or as the raw base64 body, and is matched
// case-insensitively against xssh.FingerprintSHA256(remote).
func gitSSHAuth(upstream, keyPath, hostKeyFingerprint string) (*ssh.PublicKeys, error) {
auth, err := ssh.NewPublicKeysFromFile(sshUserFromURL(upstream), keyPath, "")
if err != nil {
return nil, fmt.Errorf("ssh key %s: %w", keyPath, err)
}
want := normalizeFingerprint(hostKeyFingerprint)
if want == "" {
auth.HostKeyCallback = xssh.InsecureIgnoreHostKey()
return auth, nil
}
auth.HostKeyCallback = func(_ string, _ net.Addr, key xssh.PublicKey) error {
got := normalizeFingerprint(xssh.FingerprintSHA256(key))
if strings.EqualFold(got, want) {
return nil
}
return fmt.Errorf("upstream host key fingerprint mismatch: got SHA256:%s, want SHA256:%s", got, want)
}
return auth, nil
}
// normalizeFingerprint reduces a host-key fingerprint to its comparable base64
// body so both "SHA256:<base64>" and bare "<base64>" inputs match. An empty
// input yields an empty result.
func normalizeFingerprint(fp string) string {
fp = strings.TrimSpace(fp)
if fp == "" {
return ""
}
return strings.TrimPrefix(fp, _sshFingerprintPrefix)
}
// _sshFingerprintPrefix is the OpenSSH SHA-256 fingerprint prefix used by
// xssh.FingerprintSHA256 and ssh-keyscan output.
const _sshFingerprintPrefix = "SHA256:"
// _refspec is the default fetch/push refspec written into the origin remote so
// every local branch tracks its upstream counterpart. The leading "+" makes the
// push a forced update, which keeps the upstream in lockstep with the local
// auto-commit history even when histories diverge (e.g. after a manual upstream
// rewrite).
const _refspec = "+refs/heads/*:refs/heads/*"
// gitEnsureOrigin makes sure an "origin" remote pointing at upstream exists in
// the repository, recreating it when the recorded URL or refspecs drifted so
// the push targets the currently configured upstream and the right refs. A
// fresh remote is always created with the default head refspec so pushes have
// a valid mapping even when callers omit an explicit RefSpecs list.
//
// The URL check requires an exact single-URL match: go-git merges every
// configured fetch "url" and "pushurl" into one cfg.URLs slice (pushurls last)
// and Remote.Push pushes to cfg.URLs[len-1]. A stale pushurl would therefore
// pass a cfg.URLs[0] check while silently redirecting the push, so any extra
// URL (mismatched or merely redundant) is treated as drift and repaired.
func gitEnsureOrigin(repo *git.Repository, upstream string) error {
rem, err := repo.Remote(_origin)
if err == nil {
cfg := rem.Config()
urlDrift := len(cfg.URLs) != 1 || cfg.URLs[0] != upstream
refspecDrift := len(cfg.Fetch) != 1 || cfg.Fetch[0] != gitcfg.RefSpec(_refspec)
if urlDrift || refspecDrift {
if err := repo.DeleteRemote(_origin); err != nil {
return err
}
} else {
return nil
}
} else if !errors.Is(err, git.ErrRemoteNotFound) {
return err
}
if _, err := repo.CreateRemote(&gitcfg.RemoteConfig{
Name: _origin,
URLs: []string{upstream},
Fetch: []gitcfg.RefSpec{gitcfg.RefSpec(_refspec)},
}); err != nil {
return err
}
return nil
}
// gitCommit stages the whole worktree and commits any pending changes. It
// returns committed=false (a no-op) when the worktree is clean. The on-disk
// security-approval ledger (approval.db and its SQLite WAL sidecars
// approval.db-wal / approval.db-shm) is never committed. Three independent
// defences keep it out:
//
// 1. The .gitignore written/reconciled by gitEnsureIgnore (see _ignore /
// reconcileGitignoreApprovalLedger, reconciled at the start of every
// gitInit and gitCheckIn) lists ".db/" plus all three ledger file names,
// so ignored untracked ledger files never even surface in the status map.
// 2. The status partition below treats a changeset that touches only
// approval-ledger files as a clean no-op, and the path-by-path staging
// (wtree.Add(".") and CommitOptions.All are NOT used: both re-stage
// tracked-but-gitignored files) skips every isApprovalDBPath path.
// 3. gitLedgerScrubIndex removes any ledger entry that nevertheless made it
// into the index (staged by this loop, a legacy tracked file, or a
// hand-run `git add .` against a stale .gitignore), because the commit
// tree is built from the index. gitLedgerVerifyCommit then walks the
// fresh tree and fails the tick loudly if any ledger path leaked in
// anyway, and gitLedgerAuditTracked re-checks HEAD at every startup.
func gitCommit(config *OPNCall, repo *git.Repository) (bool, error) {
wtree, err := repo.Worktree()
if err != nil {
return false, err
}
status, err := wtree.Status()
if err != nil {
return false, err
}
// Partition the status into real changes and approval-ledger files.
// Approval-ledger files are gitignored and never staged; if they are the
// only changes the tick is a clean no-op so no commit (and no Ollama
// round-trip) is spent on a ledger rotation.
hasReal := false
for p := range status {
if !isApprovalDBPath(p) {
hasReal = true
break
}
}
if !hasReal {
return false, nil
}
// Stage every pending change individually, skipping the on-disk
// security-approval ledger. See the function doc comment for why
// wtree.Add(".") + CommitOptions.All cannot be used: they re-stage
// tracked-but-gitignored ledger files. The status map excludes
// gitignored untracked files, so this stages the same non-ledger set
// that wtree.Add(".") would, minus any approval.db* path.
for p := range status {
if isApprovalDBPath(p) {
continue
}
if _, err := wtree.Add(p); err != nil {
return false, fmt.Errorf("stage %s: %w", p, err)
}
}
// Second, independent filter: scrub every approval-ledger path from the
// index. A commit tree is built from the index, so this guarantees no
// staged ledger path (staged by this loop, a legacy tracked entry, or a
// stray hand-run `git add`) can reach the tree below — belt and braces on
// top of the staging skip above.
if scrubbed, err := gitLedgerScrubIndex(repo); err != nil {
displayChan <- []byte("[GIT][REPO][LEDGER][INDEX][FAIL] " + err.Error())
} else if len(scrubbed) > 0 {
displayChan <- []byte("[GIT][REPO][LEDGER][INDEX][WARN] unstaged approval-ledger path(s): " + strings.Join(scrubbed, ","))
}
// Choose the commit message. The default is the static _commitMsg string.
// When Ollama-assisted generation is enabled (OLLAMA_DESC_URL +
// OLLAMA_DESC_MODEL both set) the diff between HEAD and the worktree is
// routed to the model so it can author a short headline plus a brief
// summary and a trailing tag: severity line. Commits whose changes are
// all Unifi .unf files keep the default message without consulting the
// model. Any model error falls back to the default so the backup is
// never left uncommitted.
commitMsg := _commitMsg
authorName := _authorName
if (config.Ollama.Enable || config.OpenAI.Enable) && !hasUnifiAutobackupChange(status) && !onlyUnifiChanges(status) {
// Surface to the WebUI that changes are under AI review and not
// yet committed. The flag is cleared after the commit completes
// (success or failure) so the banner disappears once the change
// is either committed or fell back to the default message. It is
// only armed when a model round-trip is actually about to happen:
// .unf-only and unifi-autobackup changesets bypass the model
// entirely (generateCommitMessage returns a static subject
// without calling it), so arming the banner for them would show
// a misleading "AI review in progress" state.
reviewPending.Store(true)
defer reviewPending.Store(false)
}
if msg := generateCommitMessage(config, repo, wtree); msg != "" {
commitMsg = msg
// When Ollama authored the message, replace the static
// OPNBORG-AUTO-COMMIT author handle with a short, sanitised
// version of the commit headline so the commit log surfaces
// the change at a glance. Falls back to _authorName when the
// message carries no headline (default message, .unf bypass, or
// a model outage that degraded to the default message).
if name := authorFromCommitMessage(msg); name != "" {
authorName = name
}
}
commit, err := wtree.Commit(commitMsg, &git.CommitOptions{
Author: &object.Signature{
Name: authorName,
Email: config.Email,
When: time.Now(),
},
All: false,
AllowEmptyCommits: false,
})
if err != nil {
return false, err
}
obj, err := repo.CommitObject(commit)
if err != nil {
return false, err
}
displayChan <- []byte("[GIT][REPO][COMMIT][" + obj.Hash.String() + "] " + obj.Message)
// Post-commit runtime verification: the freshly created tree must be free
// of every approval-ledger path. A non-empty result is a defence breach
// (the staging skip and the index scrub were both bypassed); fail the tick
// so the operator notices, after logging the leaked path(s). The backup
// itself is safely committed at this point, so the error is a loud
// incident signal, not a data-loss rollback.
leaked, err := gitLedgerVerifyCommit(repo, commit)
if err != nil {
displayChan <- []byte("[GIT][REPO][LEDGER][VERIFY][FAIL] " + err.Error())
} else if len(leaked) > 0 {
displayChan <- []byte("[GIT][REPO][LEDGER][VERIFY][FAIL:LEDGER-COMMITTED] " + strings.Join(leaked, ","))
return true, fmt.Errorf("approval ledger committed into %s: %s", obj.Hash, strings.Join(leaked, ","))
}
// Record the commit in the security-approval ledger when its Ollama
// security-impact tag is above low/none/backup (medium / high /
// critical). A ledger problem is a non-fatal no-op so a backup is never
// left uncommitted.
approvalTrackCommit(config, obj.Hash.String(), obj.Message, obj.Author.When)
return true, nil
}
// gitPush pushes the current branch to the configured upstream SSH repository,
// authenticating with the private key at config.Git.SSHKey. It is a no-op when
// no upstream is configured. Before pushing it re-syncs the origin remote URL
// and refspecs with the current configuration so a drifted upstream URL or a
// remote created without refspecs never silently keeps commits local. The
// outcome (timestamp, success flag, message) is recorded for the WebUI
// dashboard upstream-sync panel.
func gitPush(config *OPNCall, repo *git.Repository) error {
upstream := config.Git.Upstream
if upstream == "" {
return nil
}
if config.Git.SSHKey == "" {
recordPush(false, "missing OPN_GIT_SSH_KEY")
return errors.New("git push: upstream configured but OPN_GIT_SSH_KEY is empty")
}
auth, err := gitSSHAuth(upstream, config.Git.SSHKey, config.Git.SSHHostKey)
if err != nil {
recordPush(false, "ssh key: "+err.Error())
return err
}
if err := gitEnsureOrigin(repo, upstream); err != nil {
recordPush(false, "origin: "+err.Error())
return err
}
// Resolve the current branch (HEAD) so the push carries an explicit refspec
// mapping the local branch onto its upstream twin, regardless of any
// configured default fetch refspec. A detached HEAD (which opnborg never
// produces, since gitInit always leaves a named branch checked out) is a
// misconfiguration and is rejected rather than pushed to a guessed branch.
//
// A freshly init'd repo with no commits yet has no HEAD. In that state
// there is nothing to push, so the sync is treated as a no-op success
// rather than an error: the next tick that produces a commit will have a
// HEAD and push it upstream.
head, err := repo.Head()
if err != nil {
if errors.Is(err, plumbing.ErrReferenceNotFound) {
displayChan <- []byte("[GIT][REPO][PUSH][NOHEAD] " + upstream)
recordPush(true, "no HEAD yet, nothing to push")
return nil
}
recordPush(false, "head: "+err.Error())
return err
}
// remoteBranch is the upstream branch name the push targets, and also the
// name of the local remote-tracking ref (refs/remotes/origin/<branch>) we
// keep in sync after a push so the WebUI dashboard can derive the upstream
// state without a network round-trip. go-git's Push does not update the
// local tracking ref itself (unlike canonical git), so we do it here.
// A detached HEAD is never expected from opnborg (gitInit always leaves a
// named branch checked out), so treat it as a misconfiguration and fail
// loudly rather than guessing "master" and force-writing refs/heads/master
// on the upstream.
var refspec gitcfg.RefSpec
var remoteBranch string
if head.Name().IsBranch() {
remoteBranch = head.Name().Short()
refspec = gitcfg.RefSpec("+refs/heads/" + remoteBranch + ":refs/heads/" + remoteBranch)
} else {
msg := "detached HEAD, refusing to guess an upstream branch"
recordPush(false, msg)
return errors.New("git push: " + msg)
}
pushedHash := head.Hash()
if err := repo.Push(&git.PushOptions{
RemoteName: _origin,
Auth: auth,
RefSpecs: []gitcfg.RefSpec{refspec},
}); err != nil {
if errors.Is(err, git.NoErrAlreadyUpToDate) {
displayChan <- []byte("[GIT][REPO][PUSH][UPTODATE] " + upstream)
recordPushTrackingRef(repo, remoteBranch, pushedHash)
recordPush(true, "already up-to-date")
return nil
}
displayChan <- []byte("[GIT][REPO][PUSH][FAIL] " + err.Error())
recordPush(false, err.Error())
return err
}
recordPushTrackingRef(repo, remoteBranch, pushedHash)
displayChan <- []byte("[GIT][REPO][PUSH][FINISH] " + upstream)
recordPush(true, "pushed to "+upstream)
return nil
}
// recordPushTrackingRef writes (or refreshes) the local remote-tracking ref
// refs/remotes/origin/<branch> so it points at the commit hash just pushed
// upstream. go-git's Push does not maintain this ref, so without it the
// dashboard upstream-sync panel would always report "never pushed" even after a
// successful push. A stale tracking ref is force-updated so it reflects the
// last known upstream tip.
func recordPushTrackingRef(repo *git.Repository, branch string, hash plumbing.Hash) {
if branch == "" || hash.IsZero() {
return
}
refName := plumbing.NewRemoteReferenceName(_origin, branch)
if err := repo.Storer.SetReference(plumbing.NewHashReference(refName, hash)); err != nil {
displayChan <- []byte("[GIT][REPO][PUSH][TRACK] " + err.Error())
}
}
// recordPush stores the outcome of an upstream push attempt under gitLastPushMu
// so the WebUI dashboard can render upstream sync health without re-running the
// push.
func recordPush(ok bool, msg string) {
gitLastPushMu.Lock()
gitLastPushTS = time.Now()
gitLastPushOK = ok
gitLastPushMsg = msg
gitLastPushMu.Unlock()
}
// gitInit ensures the storage folder is a git repository and the .gitignore is
// in place. It is safe to call repeatedly (open-or-init semantics). It also
// raises the repo's pack.deltaWindow to the aggressive value so the per-tick
// repack (gitGC) behaves like `git gc --aggressive` rather than the default
// 10-object window.
func gitInit(config *OPNCall) error {
if err := os.Chdir(config.Path); err != nil {
return err
}
if err := gitEnsureIgnore(config); err != nil {
return err
}
// Relocate a legacy root-level approval ledger (approval.db plus its WAL
// sidecars at the store root) into the dedicated .db directory and make
// sure the store root holds no ledger leftovers. A failure is logged but
// non-fatal: the ledger then simply opens at the canonical .db location.
if err := approvalDBMigrate(config.Path); err != nil {
displayChan <- []byte("[APPROVAL][DB][MIGRATE][FAIL] " + err.Error())
}
repo, err := gitRepo(config.Path)
if err != nil {
return err
}
// Startup runtime verification: audit the existing HEAD tree for tracked
// approval-ledger paths. A finding means ledger content sits in history
// from before the guards existed (or from a manual `git add`); it is
// logged loudly for upstream purging. The per-commit filters guarantee
// nothing further is added.
if tracked, err := gitLedgerAuditTracked(repo); err != nil {
displayChan <- []byte("[GIT][REPO][LEDGER][AUDIT][FAIL] " + err.Error())
} else if len(tracked) > 0 {
displayChan <- []byte("[GIT][REPO][LEDGER][AUDIT][WARN:LEDGER-IN-HISTORY] " + strings.Join(tracked, ","))
displayChan <- []byte("[GIT][REPO][LEDGER][AUDIT][WARN] purge the blob upstream: git filter-repo / BFG on " + strings.Join(tracked, ","))
}
// Open the security-approval ledger so it is ready before the first
// worker pass. A failure is logged but non-fatal: the daemon keeps
// running and the ledger is lazily re-opened on first use. When the
// ledger file does not yet exist on disk it is created fresh from the
// schema; in that case the full storage-repo git history is scanned
// once so every pre-existing security-relevant commit (medium / high /
// critical tag) is backfilled into the ledger for operator triage.
// Re-tracking a commit already present is a safe no-op, so the scan is
// idempotent and never clobbers a recorded approval state.
fresh := !approvalDBExists(config.Path)
if _, err := approvalDBOpen(config.Path); err != nil {
displayChan <- []byte("[APPROVAL][DB][OPEN][FAIL] " + err.Error())
} else if fresh {
displayChan <- []byte("[APPROVAL][DB][INIT] first-time ledger, scanning repo history")
approvalBackfillFromHistory(config)
}
return gitEnsureAggressiveWindow(repo)
}
// validateGitConfig enforces the cross-field rules of the git backup feature:
// an upstream URL requires a readable SSH private key file, and a key file is
// not meaningful without an upstream to push to. The feature being disabled
// clears any stale upstream/key values so callers downstream can rely on
// Enable as the single gate.
func validateGitConfig(config *OPNCall) error {
if !config.Git.Enable {
config.Git.Upstream = ""
config.Git.SSHKey = ""
return nil
}
if config.Git.Upstream == "" && config.Git.SSHKey == "" {
return nil
}
if config.Git.Upstream == "" {
return errors.New("OPN_GIT_SSH_KEY requires OPN_GIT_UPSTREAM (the upstream SSH git URL to sync with)")
}
if config.Git.SSHKey == "" {
return errors.New("OPN_GIT_UPSTREAM requires OPN_GIT_SSH_KEY (path to the SSH private key used for upstream auth)")
}
info, err := os.Stat(config.Git.SSHKey)
if err != nil {
return fmt.Errorf("OPN_GIT_SSH_KEY: %w", err)
}
if info.IsDir() {
return fmt.Errorf("OPN_GIT_SSH_KEY: %s is a directory, expected a private key file", config.Git.SSHKey)
}
return nil
}
// gitEnsureAggressiveWindow raises the repository's pack.deltaWindow to the
// aggressive value when it is still at or below the go-git default (10). This
// makes the per-tick repack performed by gitGC use the same wider delta window
// as `git gc --aggressive` (pack.deltaWindow=250). It is a no-op once the
// window is already at or above the target, and it never lowers a user-set
// higher value. Called once at gitInit.
func gitEnsureAggressiveWindow(repo *git.Repository) error {
cfg, err := repo.ConfigScoped(gitcfg.LocalScope)
if err != nil {
return err
}
if cfg.Pack.Window >= _aggressivePackWindow {
return nil
}
cfg.Pack.Window = _aggressivePackWindow
return repo.SetConfig(cfg)
}
// gitGC performs the internal go-git equivalent of `git gc --aggressive`:
// it repacks all reachable objects into a fresh packfile, consolidating loose
// objects and old packs via OFS deltas using the aggressive delta window set
// by gitEnsureAggressiveWindow. It runs after every successful commit+push
// so the on-disk backup store stays compact over time without invoking an
// external git binary. The repack is a no-op on storages that do not support
// packing (e.g. the in-memory test backend).
//
// Pruning of unreachable loose objects is deliberately omitted: go-git's
// Prune implementation is prone to errors on repos with rotated packfiles,
// and the repack alone already consolidates every reachable loose object into
// a packfile. Unreachable objects are harmless — they occupy negligible disk
// and are never served — so the simpler repack-only design is both safer and
// sufficient.
//
// gitGC is best-effort: a repack failure is logged to displayChan and the
// tick continues rather than aborting. The next tick will retry the
// housekeeping on a fresh repo handle.
func gitGC(repo *git.Repository) error {
if err := repo.RepackObjects(&git.RepackConfig{
UseRefDeltas: false,
}); err != nil {
if errors.Is(err, git.ErrPackedObjectsNotSupported) {
return nil
}
displayChan <- []byte("[GIT][REPO][GC][REPACK][FAIL] " + err.Error())
return nil
}
return nil
}
// commit any pending backup changes, push to the upstream remote when one is
// configured, and finally run the internal equivalent of `git gc --aggressive`
// so the on-disk backup store stays compact. The gc only runs when a new
// commit was created this tick (a clean worktree needs no repack).
//
// When an upstream is configured the push runs on every tick, not only after
// a fresh commit: the local repo may hold commits a previous tick failed to
// push (transient upstream outage), or the upstream may have drifted from the
// local HEAD. The configured refspec is a forced update (+refs/heads/*), so a
// divergent upstream is resynced to the local history rather than leaving the
// remote out of step. A clean tick with nothing to push is a no-op success.
//
// It returns committed=true when a new commit was created this tick.
func gitCheckIn(config *OPNCall) (bool, error) {
if err := os.Chdir(config.Path); err != nil {
return false, err
}
if err := gitEnsureIgnore(config); err != nil {
return false, err
}
repo, err := gitRepo(config.Path)
if err != nil {
return false, err
}
committed, err := gitCommit(config, repo)
if err != nil {
displayChan <- []byte("[GIT][REPO][COMMIT][FAIL] " + err.Error())
return false, err
}
// Always attempt the upstream sync when an upstream is configured, even on
// a clean tick, so the remote is reconciled with the local HEAD every pass.
if config.Git.Upstream != "" {
if err := gitPush(config, repo); err != nil {
return committed, err
}
}
if !committed {
return false, nil
}
_ = gitGC(repo)
displayChan <- []byte("[GIT][REPO][GC][FINISH]")
return true, nil
}