This repository has no description
21

Configure Feed

Select the types of activity you want to include in your feed.

feat: init

isabel (Jun 13, 2026, 1:40 AM +0100) d3f15e0f

+4531
+2
.gitignore
··· 1 + result/ 2 + vendor/
+50
.goreleaser.yaml
··· 1 + version: 1 2 + 3 + project_name: snot 4 + 5 + before: 6 + hooks: 7 + - go mod tidy 8 + 9 + builds: 10 + - binary: snot 11 + main: ./cmd/snot 12 + env: 13 + - CGO_ENABLED=0 14 + ldflags: 15 + - -s 16 + - -w 17 + - -X main.Version={{.Version}} 18 + - -X github.com/isabelroses/snot/internal/xrpc.version={{.Version}} 19 + goos: 20 + - linux 21 + - windows 22 + - darwin 23 + 24 + archives: 25 + - format: binary 26 + name_template: >- 27 + {{ .ProjectName }}_ 28 + {{- title .Os }}_ 29 + {{- if eq .Arch "amd64" }}x86_64 30 + {{- else if eq .Arch "386" }}i386 31 + {{- else }}{{ .Arch }}{{ end }} 32 + {{- if .Arm }}v{{ .Arm }}{{ end }} 33 + 34 + changelog: 35 + use: github 36 + sort: asc 37 + groups: 38 + - title: Features 39 + regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' 40 + order: 0 41 + - title: "Fixes" 42 + regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' 43 + order: 1 44 + - title: Others 45 + order: 999 46 + filters: 47 + exclude: 48 + - typos 49 + # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 50 + # vim: set ts=2 sw=2 tw=0 fo=cnqoj
+52
cmd/snot/check.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + 8 + "github.com/isabelroses/snot/internal/config" 9 + "github.com/isabelroses/snot/internal/forgejo" 10 + ) 11 + 12 + type CheckCmd struct{} 13 + 14 + func (c *CheckCmd) Run() error { 15 + ctx := context.Background() 16 + 17 + cfg, err := config.Load(ctx) 18 + if err != nil { 19 + return fmt.Errorf("config: %w", err) 20 + } 21 + fmt.Printf("config ok (hostname %s, knot owner %s, %d mapped users)\n", cfg.Hostname, cfg.OwnerDid, len(cfg.UserMap)) 22 + 23 + store, err := forgejo.NewPostgres(ctx, cfg.DbDsn) 24 + if err != nil { 25 + return fmt.Errorf("postgres: %w", err) 26 + } 27 + defer store.Close() 28 + 29 + for did, user := range cfg.UserMap { 30 + repos, err := store.ListRepos(ctx, user) 31 + if err != nil { 32 + return fmt.Errorf("database: %w", err) 33 + } 34 + fmt.Printf("database ok (%d public repos for %s = %s)\n", len(repos), user, did) 35 + } 36 + 37 + fi, err := os.Stat(cfg.RepoRoot) 38 + if err != nil { 39 + return fmt.Errorf("repo root %s: %w", cfg.RepoRoot, err) 40 + } 41 + if !fi.IsDir() { 42 + return fmt.Errorf("repo root %s: not a directory", cfg.RepoRoot) 43 + } 44 + fmt.Printf("repo root ok (%s)\n", cfg.RepoRoot) 45 + 46 + if err := os.MkdirAll(cfg.StateDir, 0o700); err != nil { 47 + return fmt.Errorf("state dir %s: %w", cfg.StateDir, err) 48 + } 49 + fmt.Printf("state dir ok (%s)\n", cfg.StateDir) 50 + 51 + return nil 52 + }
+27
cmd/snot/main.go
··· 1 + package main 2 + 3 + import ( 4 + "github.com/alecthomas/kong" 5 + ) 6 + 7 + var Version = "dev" 8 + 9 + type CLI struct { 10 + Version kong.VersionFlag `help:"Show version and exit."` 11 + 12 + Serve ServeCmd `cmd:"" help:"Run the knot server."` 13 + Check CheckCmd `cmd:"" help:"Validate configuration: database, Forgejo user, repository root."` 14 + Repos ReposCmd `cmd:"" help:"List the public Forgejo repos this knot exposes, with their repo DIDs."` 15 + } 16 + 17 + func main() { 18 + var cli CLI 19 + kctx := kong.Parse(&cli, 20 + kong.Name("snot"), 21 + kong.Description("A tangled knot server backed by a Forgejo instance. Configuration via SNOT_* environment variables."), 22 + kong.UsageOnError(), 23 + kong.Vars{"version": Version}, 24 + ) 25 + 26 + kctx.FatalIfErrorf(kctx.Run()) 27 + }
+50
cmd/snot/repos.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "maps" 7 + "slices" 8 + 9 + "github.com/isabelroses/snot/internal/config" 10 + "github.com/isabelroses/snot/internal/forgejo" 11 + "github.com/isabelroses/snot/internal/state" 12 + ) 13 + 14 + type ReposCmd struct{} 15 + 16 + func (c *ReposCmd) Run() error { 17 + ctx := context.Background() 18 + 19 + cfg, err := config.Load(ctx) 20 + if err != nil { 21 + return fmt.Errorf("config: %w", err) 22 + } 23 + 24 + store, err := forgejo.NewPostgres(ctx, cfg.DbDsn) 25 + if err != nil { 26 + return fmt.Errorf("postgres: %w", err) 27 + } 28 + defer store.Close() 29 + 30 + dids, err := state.LoadRepoDids(cfg.StateDir) 31 + if err != nil { 32 + return fmt.Errorf("state (repodids): %w", err) 33 + } 34 + 35 + userDids := slices.Sorted(maps.Keys(cfg.UserMap)) 36 + for _, ownerDid := range userDids { 37 + repos, err := store.ListRepos(ctx, cfg.UserMap[ownerDid]) 38 + if err != nil { 39 + return err 40 + } 41 + for _, r := range repos { 42 + did, ok := dids.ByRepo(r.OwnerName, r.Name) 43 + if !ok { 44 + did = "(not yet published)" 45 + } 46 + fmt.Printf("%-30s %s\n", r.OwnerName+"/"+r.Name, did) 47 + } 48 + } 49 + return nil 50 + }
+129
cmd/snot/serve.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + "os" 9 + 10 + "github.com/go-chi/chi/v5" 11 + "github.com/go-chi/chi/v5/middleware" 12 + "tangled.org/core/idresolver" 13 + "tangled.org/core/notifier" 14 + "tangled.org/core/xrpc/serviceauth" 15 + 16 + "github.com/isabelroses/snot/internal/config" 17 + "github.com/isabelroses/snot/internal/events" 18 + "github.com/isabelroses/snot/internal/forgejo" 19 + "github.com/isabelroses/snot/internal/gitserve" 20 + "github.com/isabelroses/snot/internal/repodid" 21 + "github.com/isabelroses/snot/internal/resolve" 22 + "github.com/isabelroses/snot/internal/state" 23 + "github.com/isabelroses/snot/internal/xrpc" 24 + ) 25 + 26 + type ServeCmd struct{} 27 + 28 + func (c *ServeCmd) Run() error { 29 + ctx := context.Background() 30 + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 31 + 32 + cfg, err := config.Load(ctx) 33 + if err != nil { 34 + return fmt.Errorf("config: %w", err) 35 + } 36 + 37 + store, err := forgejo.NewPostgres(ctx, cfg.DbDsn) 38 + if err != nil { 39 + return fmt.Errorf("postgres: %w", err) 40 + } 41 + defer store.Close() 42 + 43 + rkeys, err := state.LoadRkeyMap(cfg.StateDir) 44 + if err != nil { 45 + return fmt.Errorf("state: %w", err) 46 + } 47 + 48 + dids, err := state.LoadRepoDids(cfg.StateDir) 49 + if err != nil { 50 + return fmt.Errorf("state (repodids): %w", err) 51 + } 52 + 53 + scheme := "https" 54 + if cfg.Dev { 55 + scheme = "http" 56 + } 57 + base := scheme + "://" + cfg.Hostname 58 + 59 + rs := &resolve.Resolver{ 60 + Cfg: cfg, 61 + Store: store, 62 + Rkeys: rkeys, 63 + Dids: dids, 64 + MintDid: func(ctx context.Context) (string, []byte, error) { 65 + return repodid.Mint(ctx, cfg.PlcUrl, base) 66 + }, 67 + } 68 + 69 + resolver := idresolver.DefaultResolver(cfg.PlcUrl) 70 + 71 + x := &xrpc.Xrpc{ 72 + Cfg: cfg, 73 + Store: store, 74 + Resolve: rs, 75 + Rkeys: rkeys, 76 + Logger: logger.With("component", "xrpc"), 77 + ServiceAuth: serviceauth.NewServiceAuth( 78 + logger.With("component", "serviceauth"), 79 + resolver, 80 + serviceauth.DidWeb(cfg.Hostname).String(), 81 + ), 82 + } 83 + 84 + gs := &gitserve.Handler{ 85 + Cfg: cfg, 86 + Resolve: rs, 87 + Logger: logger.With("component", "gitserve"), 88 + Resolver: resolver, 89 + } 90 + 91 + n := notifier.New() 92 + 93 + r := chi.NewRouter() 94 + r.Use(middleware.RealIP) 95 + r.Use(loggingMiddleware(logger)) 96 + r.Use(corsMiddleware) 97 + 98 + r.Get("/", func(w http.ResponseWriter, _ *http.Request) { 99 + fmt.Fprintf(w, "this is snot, a tangled knot backed by forgejo.\n") 100 + }) 101 + r.Mount("/xrpc", x.Router()) 102 + r.Get("/events", events.Handler(&n, logger.With("component", "events"))) 103 + r.Mount("/", gs.Routes()) 104 + 105 + logger.Info("starting snot", "addr", cfg.ListenAddr, "hostname", cfg.Hostname) 106 + return http.ListenAndServe(cfg.ListenAddr, r) 107 + } 108 + 109 + func loggingMiddleware(l *slog.Logger) func(http.Handler) http.Handler { 110 + return func(next http.Handler) http.Handler { 111 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 112 + l.Info("request", "method", r.Method, "path", r.URL.Path) 113 + next.ServeHTTP(w, r) 114 + }) 115 + } 116 + } 117 + 118 + func corsMiddleware(next http.Handler) http.Handler { 119 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 120 + w.Header().Set("Access-Control-Allow-Origin", "*") 121 + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") 122 + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") 123 + if r.Method == http.MethodOptions { 124 + w.WriteHeader(http.StatusNoContent) 125 + return 126 + } 127 + next.ServeHTTP(w, r) 128 + }) 129 + }
+26
default.nix
··· 1 + { lib, buildGoModule }: 2 + buildGoModule (finalAttrs: { 3 + pname = "snot"; 4 + version = "0.1.0"; 5 + 6 + src = ./.; 7 + 8 + vendorHash = "sha256-mCjuJWTTQ6pRBO2hdYhDVxPLJ2s7BI25XbwAn3V2Klo="; 9 + 10 + subPackages = [ "cmd/snot" ]; 11 + 12 + ldflags = [ 13 + "-s" 14 + "-w" 15 + "-X main.Version=${finalAttrs.version}" 16 + "-X github.com/isabelroses/snot/internal/xrpc.version=${finalAttrs.version}" 17 + ]; 18 + 19 + meta = { 20 + description = "A tangled knot shim backed by a Forgejo instance"; 21 + homepage = "https://github.com/isabelroses/snot"; 22 + license = lib.licenses.mit; 23 + maintainers = with lib.maintainers; [ isabelroses ]; 24 + mainProgram = "snot"; 25 + }; 26 + })
+24
flake.lock
··· 1 + { 2 + "nodes": { 3 + "nixpkgs": { 4 + "locked": { 5 + "lastModified": 1781173989, 6 + "narHash": "sha256-zqS1Hjp9o14nN/YhXu/uqm36ot058fF9wmd7/H/hRKc=", 7 + "rev": "8c91a71d13451abc40eb9dae8910f972f979852f", 8 + "type": "tarball", 9 + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.11pre1015022.8c91a71d1345/nixexprs.tar.xz?lastModified=1781173989&rev=8c91a71d13451abc40eb9dae8910f972f979852f" 10 + }, 11 + "original": { 12 + "type": "tarball", 13 + "url": "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz" 14 + } 15 + }, 16 + "root": { 17 + "inputs": { 18 + "nixpkgs": "nixpkgs" 19 + } 20 + } 21 + }, 22 + "root": "root", 23 + "version": 7 24 + }
+37
flake.nix
··· 1 + { 2 + description = "tangled knot shim backed by Forgejo"; 3 + 4 + inputs = { 5 + nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz"; 6 + }; 7 + 8 + outputs = 9 + { self, nixpkgs }: 10 + let 11 + forAllSystems = 12 + function: 13 + nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed ( 14 + system: function nixpkgs.legacyPackages.${system} 15 + ); 16 + in 17 + { 18 + packages = forAllSystems (pkgs: { 19 + snot = pkgs.callPackage ./default.nix { }; 20 + default = self.packages.${pkgs.stdenv.hostPlatform.system}.snot; 21 + }); 22 + 23 + devShells = forAllSystems (pkgs: { 24 + default = pkgs.callPackage ./shell.nix { }; 25 + }); 26 + 27 + overlays.default = final: _: { snot = final.callPackage ./default.nix { }; }; 28 + 29 + nixosModules.default = { pkgs, ... }: { 30 + _class = "nixos"; 31 + _file = "${self.outPath}/flake.nix#nixosModules.default"; 32 + imports = [ ./nix/module.nix ]; 33 + config.services.snot.package = 34 + self.packages.${pkgs.stdenv.hostPlatform.system}.default; 35 + }; 36 + }; 37 + }
+167
go.mod
··· 1 + module github.com/isabelroses/snot 2 + 3 + go 1.26 4 + 5 + require ( 6 + github.com/alecthomas/kong v1.15.0 7 + github.com/bluekeyes/go-gitdiff v0.8.1 8 + github.com/bluesky-social/indigo v0.0.0-20260220055544-bf41e2ee75ab 9 + github.com/cyphar/filepath-securejoin v0.6.1 10 + github.com/go-chi/chi/v5 v5.2.0 11 + github.com/go-git/go-git/v5 v5.14.0 12 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 13 + github.com/jackc/pgx/v5 v5.10.0 14 + github.com/sethvargo/go-envconfig v1.3.0 15 + tangled.org/core v0.0.0-20260612103734-ff6d47cfb983 16 + ) 17 + 18 + replace github.com/bluesky-social/indigo => github.com/boltlessengineer/indigo v0.0.0-20260315101958-fb1dfa36fed2 19 + 20 + replace github.com/sergi/go-diff => github.com/sergi/go-diff v1.1.0 21 + 22 + replace github.com/go-git/go-git/v5 => github.com/oppiliappan/go-git/v5 v5.17.0 23 + 24 + replace github.com/bluekeyes/go-gitdiff => tangled.sh/oppi.li/go-gitdiff v0.8.2 25 + 26 + replace github.com/alecthomas/chroma/v2 => github.com/oppiliappan/chroma/v2 v2.24.2 27 + 28 + require ( 29 + dario.cat/mergo v1.0.1 // indirect 30 + github.com/Microsoft/go-winio v0.6.2 // indirect 31 + github.com/ProtonMail/go-crypto v1.3.0 // indirect 32 + github.com/alecthomas/chroma/v2 v2.23.1 // indirect 33 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 34 + github.com/aymerick/douceur v0.2.0 // indirect 35 + github.com/beorn7/perks v1.0.1 // indirect 36 + github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect 37 + github.com/carlmjohnson/versioninfo v0.22.5 // indirect 38 + github.com/cenkalti/backoff/v4 v4.3.0 // indirect 39 + github.com/cespare/xxhash/v2 v2.3.0 // indirect 40 + github.com/charmbracelet/colorprofile v0.4.1 // indirect 41 + github.com/charmbracelet/lipgloss v1.1.0 // indirect 42 + github.com/charmbracelet/log v0.4.2 // indirect 43 + github.com/charmbracelet/x/ansi v0.11.6 // indirect 44 + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect 45 + github.com/charmbracelet/x/term v0.2.2 // indirect 46 + github.com/clipperhouse/displaywidth v0.9.0 // indirect 47 + github.com/clipperhouse/stringish v0.1.1 // indirect 48 + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect 49 + github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect 50 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 51 + github.com/dgraph-io/ristretto v0.2.0 // indirect 52 + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 53 + github.com/did-method-plc/go-didplc v0.2.2 // indirect 54 + github.com/dlclark/regexp2 v1.11.5 // indirect 55 + github.com/dustin/go-humanize v1.0.1 // indirect 56 + github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 57 + github.com/emirpasic/gods v1.18.1 // indirect 58 + github.com/felixge/httpsnoop v1.0.4 // indirect 59 + github.com/go-enry/go-enry/v2 v2.9.6 // indirect 60 + github.com/go-enry/go-oniguruma v1.2.1 // indirect 61 + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 62 + github.com/go-git/go-billy/v5 v5.6.2 // indirect 63 + github.com/go-jose/go-jose/v3 v3.0.4 // indirect 64 + github.com/go-logfmt/logfmt v0.6.0 // indirect 65 + github.com/go-logr/logr v1.4.3 // indirect 66 + github.com/go-logr/stdr v1.2.2 // indirect 67 + github.com/go-redis/cache/v9 v9.0.0 // indirect 68 + github.com/gogo/protobuf v1.3.2 // indirect 69 + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect 70 + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 71 + github.com/google/uuid v1.6.0 // indirect 72 + github.com/gorilla/css v1.0.1 // indirect 73 + github.com/hashicorp/errwrap v1.1.0 // indirect 74 + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 75 + github.com/hashicorp/go-multierror v1.1.1 // indirect 76 + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect 77 + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect 78 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 79 + github.com/hashicorp/go-sockaddr v1.0.7 // indirect 80 + github.com/hashicorp/golang-lru v1.0.2 // indirect 81 + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 82 + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect 83 + github.com/ipfs/bbloom v0.0.4 // indirect 84 + github.com/ipfs/boxo v0.36.0 // indirect 85 + github.com/ipfs/go-block-format v0.2.3 // indirect 86 + github.com/ipfs/go-cid v0.6.0 // indirect 87 + github.com/ipfs/go-datastore v0.9.0 // indirect 88 + github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 89 + github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 90 + github.com/ipfs/go-ipld-cbor v0.2.1 // indirect 91 + github.com/ipfs/go-ipld-format v0.6.3 // indirect 92 + github.com/ipfs/go-log v1.0.5 // indirect 93 + github.com/ipfs/go-log/v2 v2.9.1 // indirect 94 + github.com/ipfs/go-metrics-interface v0.3.0 // indirect 95 + github.com/jackc/pgpassfile v1.0.0 // indirect 96 + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 97 + github.com/jackc/puddle/v2 v2.2.2 // indirect 98 + github.com/kevinburke/ssh_config v1.2.0 // indirect 99 + github.com/klauspost/compress v1.18.0 // indirect 100 + github.com/klauspost/cpuid/v2 v2.3.0 // indirect 101 + github.com/landlock-lsm/go-landlock v0.8.1 // indirect 102 + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect 103 + github.com/mattn/go-isatty v0.0.20 // indirect 104 + github.com/mattn/go-runewidth v0.0.19 // indirect 105 + github.com/mattn/go-sqlite3 v1.14.34 // indirect 106 + github.com/microcosm-cc/bluemonday v1.0.27 // indirect 107 + github.com/minio/sha256-simd v1.0.1 // indirect 108 + github.com/mitchellh/mapstructure v1.5.0 // indirect 109 + github.com/mr-tron/base58 v1.2.0 // indirect 110 + github.com/muesli/termenv v0.16.0 // indirect 111 + github.com/multiformats/go-base32 v0.1.0 // indirect 112 + github.com/multiformats/go-base36 v0.2.0 // indirect 113 + github.com/multiformats/go-multibase v0.2.0 // indirect 114 + github.com/multiformats/go-multihash v0.2.3 // indirect 115 + github.com/multiformats/go-varint v0.1.0 // indirect 116 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 117 + github.com/openbao/openbao/api/v2 v2.3.0 // indirect 118 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect 119 + github.com/pjbgf/sha1cd v0.3.2 // indirect 120 + github.com/pkg/errors v0.9.1 // indirect 121 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 122 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 123 + github.com/prometheus/client_golang v1.23.2 // indirect 124 + github.com/prometheus/client_model v0.6.2 // indirect 125 + github.com/prometheus/common v0.67.5 // indirect 126 + github.com/prometheus/procfs v0.19.2 // indirect 127 + github.com/redis/go-redis/v9 v9.7.3 // indirect 128 + github.com/rivo/uniseg v0.4.7 // indirect 129 + github.com/ryanuber/go-glob v1.0.0 // indirect 130 + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 131 + github.com/spaolacci/murmur3 v1.1.0 // indirect 132 + github.com/stretchr/testify v1.11.1 // indirect 133 + github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 134 + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 135 + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 136 + github.com/whyrusleeping/cbor-gen v0.3.1 // indirect 137 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 138 + github.com/yuin/goldmark v1.7.13 // indirect 139 + github.com/yuin/goldmark-emoji v1.0.6 // indirect 140 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc // indirect 141 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab // indirect 142 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 143 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 144 + go.abhg.dev/goldmark/mermaid v0.6.0 // indirect 145 + go.opentelemetry.io/auto/sdk v1.2.1 // indirect 146 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect 147 + go.opentelemetry.io/otel v1.40.0 // indirect 148 + go.opentelemetry.io/otel/metric v1.40.0 // indirect 149 + go.opentelemetry.io/otel/trace v1.40.0 // indirect 150 + go.uber.org/atomic v1.11.0 // indirect 151 + go.uber.org/multierr v1.11.0 // indirect 152 + go.uber.org/zap v1.27.1 // indirect 153 + go.yaml.in/yaml/v2 v2.4.3 // indirect 154 + golang.org/x/crypto v0.48.0 // indirect 155 + golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect 156 + golang.org/x/net v0.50.0 // indirect 157 + golang.org/x/sync v0.19.0 // indirect 158 + golang.org/x/sys v0.41.0 // indirect 159 + golang.org/x/text v0.34.0 // indirect 160 + golang.org/x/time v0.12.0 // indirect 161 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect 162 + google.golang.org/protobuf v1.36.11 // indirect 163 + gopkg.in/warnings.v0 v0.1.2 // indirect 164 + gopkg.in/yaml.v3 v3.0.1 // indirect 165 + kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect 166 + lukechampine.com/blake3 v1.4.1 // indirect 167 + )
+612
go.sum
··· 1 + dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= 2 + dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 + github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 4 + github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 5 + github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 6 + github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= 7 + github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 8 + github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 9 + github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 10 + github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI= 11 + github.com/alecthomas/kong v1.15.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= 12 + github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 13 + github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= 14 + github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 15 + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 16 + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 17 + github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 18 + github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 19 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 20 + github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 21 + github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 22 + github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 23 + github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 24 + github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 25 + github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= 26 + github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 27 + github.com/boltlessengineer/indigo v0.0.0-20260315101958-fb1dfa36fed2 h1:63+EsT7kltod8g1eA0eNuvq1q9ANJWRdxlLeJjJDVYY= 28 + github.com/boltlessengineer/indigo v0.0.0-20260315101958-fb1dfa36fed2/go.mod h1:VG/LeqLGNI3Ew7lsYixajnZGFfWPv144qbUddh+Oyag= 29 + github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 30 + github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 31 + github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 32 + github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 33 + github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= 34 + github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= 35 + github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= 36 + github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 37 + github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 38 + github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 39 + github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 40 + github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 41 + github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= 42 + github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= 43 + github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 44 + github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 45 + github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 46 + github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 47 + github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= 48 + github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= 49 + github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= 50 + github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= 51 + github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= 52 + github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= 53 + github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU= 54 + github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= 55 + github.com/chromedp/chromedp v0.14.0 h1:/xE5m6wEBwivhalHwlCOyYfBcAJNwg4nLw96QiCfYr0= 56 + github.com/chromedp/chromedp v0.14.0/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo= 57 + github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= 58 + github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= 59 + github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 60 + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 61 + github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 62 + github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= 63 + github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= 64 + github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= 65 + github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= 66 + github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= 67 + github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 68 + github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d h1:IiIprFGH6SqstblP0Y9NIo3eaUJGkI/YDOFVSL64Uq4= 69 + github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 70 + github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 71 + github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 72 + github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= 73 + github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= 74 + github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 75 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 76 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 77 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 78 + github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE= 79 + github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= 80 + github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= 81 + github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= 82 + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 83 + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 84 + github.com/did-method-plc/go-didplc v0.2.2 h1:53HFhTT8NCAeFmZ6fdIZCf3PGDvj7A3cDjzOOEqn5XM= 85 + github.com/did-method-plc/go-didplc v0.2.2/go.mod h1:bKdJ21irnwNHgVLWWL32zUWqZueXYbJRUcxplZghByo= 86 + github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 87 + github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 88 + github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 89 + github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 90 + github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 91 + github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= 92 + github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= 93 + github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= 94 + github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 95 + github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 96 + github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 97 + github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 98 + github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 99 + github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 100 + github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 101 + github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 102 + github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 103 + github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= 104 + github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 105 + github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 106 + github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 107 + github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= 108 + github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 109 + github.com/go-enry/go-enry/v2 v2.9.6 h1:np63eOtMV56zfYDHnFVgpEVOk8fr2kmylcMnAZUDbSs= 110 + github.com/go-enry/go-enry/v2 v2.9.6/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8= 111 + github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo= 112 + github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4= 113 + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 114 + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 115 + github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= 116 + github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= 117 + github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= 118 + github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 119 + github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY= 120 + github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 121 + github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 122 + github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 123 + github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= 124 + github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= 125 + github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 126 + github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 127 + github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 128 + github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 129 + github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 130 + github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 131 + github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 132 + github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 133 + github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0= 134 + github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI= 135 + github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 136 + github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 137 + github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 138 + github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 139 + github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= 140 + github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= 141 + github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= 142 + github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 143 + github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= 144 + github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= 145 + github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 146 + github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 147 + github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= 148 + github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 149 + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 150 + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 151 + github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 152 + github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 153 + github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 154 + github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 155 + github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 156 + github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 157 + github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 158 + github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 159 + github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 160 + github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 161 + github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 162 + github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 163 + github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 164 + github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 165 + github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 166 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 167 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 168 + github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 169 + github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 170 + github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 171 + github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 172 + github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 173 + github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c h1:7lF+Vz0LqiRidnzC1Oq86fpX1q/iEv2KJdrCtttYjT4= 174 + github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 175 + github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 176 + github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 177 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= 178 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= 179 + github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 180 + github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 181 + github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 182 + github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 183 + github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 184 + github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 185 + github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 186 + github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 187 + github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 188 + github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= 189 + github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= 190 + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= 191 + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= 192 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= 193 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= 194 + github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= 195 + github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= 196 + github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 197 + github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 198 + github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 199 + github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 200 + github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= 201 + github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= 202 + github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 203 + github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 204 + github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 205 + github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 206 + github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 207 + github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 208 + github.com/ipfs/boxo v0.36.0 h1:DarrMBM46xCs6GU6Vz+AL8VUyXykqHAqZYx8mR0Oics= 209 + github.com/ipfs/boxo v0.36.0/go.mod h1:92hnRXfP5ScKEIqlq9Ns7LR1dFXEVADKWVGH0fjk83k= 210 + github.com/ipfs/go-block-format v0.2.3 h1:mpCuDaNXJ4wrBJLrtEaGFGXkferrw5eqVvzaHhtFKQk= 211 + github.com/ipfs/go-block-format v0.2.3/go.mod h1:WJaQmPAKhD3LspLixqlqNFxiZ3BZ3xgqxxoSR/76pnA= 212 + github.com/ipfs/go-cid v0.6.0 h1:DlOReBV1xhHBhhfy/gBNNTSyfOM6rLiIx9J7A4DGf30= 213 + github.com/ipfs/go-cid v0.6.0/go.mod h1:NC4kS1LZjzfhK40UGmpXv5/qD2kcMzACYJNntCUiDhQ= 214 + github.com/ipfs/go-datastore v0.9.0 h1:WocriPOayqalEsueHv6SdD4nPVl4rYMfYGLD4bqCZ+w= 215 + github.com/ipfs/go-datastore v0.9.0/go.mod h1:uT77w/XEGrvJWwHgdrMr8bqCN6ZTW9gzmi+3uK+ouHg= 216 + github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= 217 + github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps= 218 + github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ= 219 + github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE= 220 + github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw= 221 + github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 222 + github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 223 + github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= 224 + github.com/ipfs/go-ipld-cbor v0.2.1 h1:H05yEJbK/hxg0uf2AJhyerBDbjOuHX4yi+1U/ogRa7E= 225 + github.com/ipfs/go-ipld-cbor v0.2.1/go.mod h1:x9Zbeq8CoE5R2WicYgBMcr/9mnkQ0lHddYWJP2sMV3A= 226 + github.com/ipfs/go-ipld-format v0.6.3 h1:9/lurLDTotJpZSuL++gh3sTdmcFhVkCwsgx2+rAh4j8= 227 + github.com/ipfs/go-ipld-format v0.6.3/go.mod h1:74ilVN12NXVMIV+SrBAyC05UJRk0jVvGqdmrcYZvCBk= 228 + github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 229 + github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 230 + github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= 231 + github.com/ipfs/go-log/v2 v2.9.1 h1:3JXwHWU31dsCpvQ+7asz6/QsFJHqFr4gLgQ0FWteujk= 232 + github.com/ipfs/go-log/v2 v2.9.1/go.mod h1:evFx7sBiohUN3AG12mXlZBw5hacBQld3ZPHrowlJYoo= 233 + github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 234 + github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 235 + github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 236 + github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 237 + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 238 + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 239 + github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= 240 + github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= 241 + github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 242 + github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 243 + github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 244 + github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 245 + github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 246 + github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 247 + github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 248 + github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 249 + github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 250 + github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 251 + github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 252 + github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 253 + github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 254 + github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 255 + github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 256 + github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 257 + github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 258 + github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 259 + github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 260 + github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 261 + github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 262 + github.com/landlock-lsm/go-landlock v0.8.1 h1:Krs1co16IzN7bQcFYIdtNF+BKwZem3geRBkVsZtlCKU= 263 + github.com/landlock-lsm/go-landlock v0.8.1/go.mod h1:mn5GSi81Jf7yMs5WSi+SUi4sUeNLUGVdbT4Id6wXNQw= 264 + github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= 265 + github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 266 + github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 267 + github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 268 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 269 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 270 + github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= 271 + github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 272 + github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= 273 + github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 274 + github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 275 + github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 276 + github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 277 + github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 278 + github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 279 + github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 280 + github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 281 + github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 282 + github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 283 + github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 284 + github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 285 + github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 286 + github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 287 + github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 288 + github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 289 + github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 290 + github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 291 + github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 292 + github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= 293 + github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= 294 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 295 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 296 + github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 297 + github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= 298 + github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= 299 + github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 300 + github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= 301 + github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= 302 + github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= 303 + github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= 304 + github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= 305 + github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47RKZmLU= 306 + github.com/onsi/ginkgo/v2 v2.1.6/go.mod h1:MEH45j8TBi6u9BMogfbp0stKC5cdGjumZj5Y7AG4VIk= 307 + github.com/onsi/ginkgo/v2 v2.3.0/go.mod h1:Eew0uilEqZmIEZr8JrvYlvOM7Rr6xzTmMV8AyFNU9d0= 308 + github.com/onsi/ginkgo/v2 v2.4.0/go.mod h1:iHkDK1fKGcBoEHT5W7YBq4RFWaQulw+caOMkAt4OrFo= 309 + github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw= 310 + github.com/onsi/ginkgo/v2 v2.7.0/go.mod h1:yjiuMwPokqY1XauOgju45q3sJt6VzQ/Fict1LFVcsAo= 311 + github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 312 + github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 313 + github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= 314 + github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= 315 + github.com/onsi/gomega v1.20.1/go.mod h1:DtrZpjmvpn2mPm4YWQa0/ALMDj9v4YxLgojwPeREyVo= 316 + github.com/onsi/gomega v1.21.1/go.mod h1:iYAIXgPSaDHak0LCMA+AWBpIKBr8WZicMxnE8luStNc= 317 + github.com/onsi/gomega v1.22.1/go.mod h1:x6n7VNe4hw0vkyYUM4mjIXx3JbLiPaBPNgB7PRQ1tuM= 318 + github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= 319 + github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= 320 + github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= 321 + github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= 322 + github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= 323 + github.com/openbao/openbao/api/v2 v2.3.0 h1:61FO3ILtpKoxbD9kTWeGaCq8pz1sdt4dv2cmTXsiaAc= 324 + github.com/openbao/openbao/api/v2 v2.3.0/go.mod h1:T47WKHb7DqHa3Ms3xicQtl5EiPE+U8diKjb9888okWs= 325 + github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 326 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A= 327 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= 328 + github.com/oppiliappan/chroma/v2 v2.24.2 h1:lHB9tWQxDoHa6sYEDdFep8SX6FPMmAF+ocGUffFwujE= 329 + github.com/oppiliappan/chroma/v2 v2.24.2/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= 330 + github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo= 331 + github.com/oppiliappan/go-git/v5 v5.17.0/go.mod h1:q/FE8C3SPMoRN7LoH9vRFiBzidAOBWJPS1CqVS8DN+w= 332 + github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= 333 + github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= 334 + github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 335 + github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 336 + github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 337 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 338 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 339 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 340 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 341 + github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 342 + github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= 343 + github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= 344 + github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 345 + github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 346 + github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= 347 + github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= 348 + github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= 349 + github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= 350 + github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA= 351 + github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= 352 + github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 353 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 354 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 355 + github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 356 + github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 357 + github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 358 + github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 359 + github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 360 + github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= 361 + github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= 362 + github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= 363 + github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 364 + github.com/sethvargo/go-envconfig v1.3.0 h1:gJs+Fuv8+f05omTpwWIu6KmuseFAXKrIaOZSh8RMt0U= 365 + github.com/sethvargo/go-envconfig v1.3.0/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw= 366 + github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 367 + github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= 368 + github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= 369 + github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= 370 + github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= 371 + github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 372 + github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 373 + github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 374 + github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 375 + github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 376 + github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 377 + github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 378 + github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 379 + github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 380 + github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 381 + github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 382 + github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 383 + github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 384 + github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 385 + github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 386 + github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 387 + github.com/vmihailenco/go-tinylfu v0.2.2 h1:H1eiG6HM36iniK6+21n9LLpzx1G9R3DJa2UjUjbynsI= 388 + github.com/vmihailenco/go-tinylfu v0.2.2/go.mod h1:CutYi2Q9puTxfcolkliPq4npPuofg9N9t8JVrjzwa3Q= 389 + github.com/vmihailenco/msgpack/v5 v5.3.4/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= 390 + github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= 391 + github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= 392 + github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= 393 + github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 394 + github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= 395 + github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 396 + github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 397 + github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 398 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 399 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 400 + github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 401 + github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 402 + github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 403 + github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 404 + github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 405 + github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= 406 + github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 407 + github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= 408 + github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= 409 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 410 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 411 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A= 412 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab/go.mod h1:SPu13/NPe1kMrbGoJldQwqtpNhXsmIuHCfm/aaGjU0c= 413 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 414 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 415 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 416 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 417 + go.abhg.dev/goldmark/mermaid v0.6.0 h1:VvkYFWuOjD6cmSBVJpLAtzpVCGM1h0B7/DQ9IzERwzY= 418 + go.abhg.dev/goldmark/mermaid v0.6.0/go.mod h1:uMc+PcnIH2NVL7zjH10Q1wr7hL3+4n4jUMifhyBYB9I= 419 + go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= 420 + go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= 421 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= 422 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= 423 + go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= 424 + go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= 425 + go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= 426 + go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= 427 + go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= 428 + go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= 429 + go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= 430 + go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= 431 + go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= 432 + go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= 433 + go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 434 + go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 435 + go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 436 + go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 437 + go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 438 + go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 439 + go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 440 + go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 441 + go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 442 + go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 443 + go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 444 + go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= 445 + go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= 446 + go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 447 + go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= 448 + go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= 449 + golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 450 + golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 451 + golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 452 + golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 453 + golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 454 + golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 455 + golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 456 + golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= 457 + golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= 458 + golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= 459 + golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= 460 + golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 461 + golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 462 + golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 463 + golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 464 + golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= 465 + golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 466 + golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 467 + golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 468 + golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 469 + golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 470 + golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 471 + golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 472 + golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 473 + golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 474 + golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 475 + golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 476 + golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 477 + golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 478 + golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 479 + golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 480 + golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 481 + golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 482 + golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 483 + golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 484 + golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= 485 + golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= 486 + golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 487 + golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 488 + golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= 489 + golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= 490 + golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 491 + golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 492 + golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 493 + golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 494 + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 495 + golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 496 + golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 497 + golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 498 + golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 499 + golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 500 + golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 501 + golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 502 + golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 503 + golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 504 + golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 505 + golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 506 + golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 507 + golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 508 + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 509 + golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 510 + golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 511 + golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 512 + golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 513 + golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 514 + golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 515 + golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 516 + golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 517 + golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 518 + golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 519 + golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 520 + golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 521 + golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 522 + golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 523 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 524 + golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 525 + golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 526 + golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= 527 + golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 528 + golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 529 + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 530 + golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 531 + golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 532 + golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= 533 + golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= 534 + golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 535 + golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 536 + golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 537 + golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= 538 + golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= 539 + golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 540 + golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 541 + golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 542 + golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 543 + golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 544 + golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 545 + golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 546 + golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 547 + golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 548 + golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 549 + golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= 550 + golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= 551 + golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 552 + golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 553 + golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 554 + golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 555 + golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 556 + golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= 557 + golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 558 + golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 559 + golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 560 + golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 561 + golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 562 + golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 563 + golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= 564 + golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 565 + golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 566 + golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= 567 + golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 568 + golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 569 + golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 570 + golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 571 + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 572 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= 573 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 574 + google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 575 + google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 576 + google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 577 + google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 578 + google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 579 + google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 580 + google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 581 + google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 582 + google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 583 + google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= 584 + google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 585 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 586 + gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 587 + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 588 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 589 + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 590 + gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 591 + gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 592 + gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 593 + gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 594 + gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 595 + gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 596 + gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 597 + gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 598 + gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 599 + gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 600 + gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 601 + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 602 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 603 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 604 + honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 605 + kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 h1:Z06sMOzc0GNCwp6efaVrIrz4ywGJ1v+DP0pjVkOfDuA= 606 + kernel.org/pub/linux/libs/security/libcap/psx v1.2.77/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24= 607 + lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 608 + lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 609 + tangled.org/core v0.0.0-20260612103734-ff6d47cfb983 h1:adp/YJS7cq6QI3xc3Ya/NB3KwWFFw0c/CxZvIDCTOvU= 610 + tangled.org/core v0.0.0-20260612103734-ff6d47cfb983/go.mod h1:DzlYk2UwbYk1I2CIn0x2wQAedlJLdatg3YKZpKHNWjU= 611 + tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 612 + tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+35
internal/config/config.go
··· 1 + package config 2 + 3 + import ( 4 + "context" 5 + 6 + "github.com/sethvargo/go-envconfig" 7 + ) 8 + 9 + type Config struct { 10 + Hostname string `env:"SNOT_HOSTNAME, required"` 11 + ListenAddr string `env:"SNOT_LISTEN_ADDR, default=0.0.0.0:5555"` 12 + 13 + // OwnerDid identifies the knot's administrator alone (sh.tangled.owner, 14 + // knot registration). Repo ownership is governed by UserMap. 15 + OwnerDid string `env:"SNOT_OWNER_DID, required"` 16 + 17 + // UserMap maps repo-owner DIDs to the Forgejo users whose repos they 18 + // expose, e.g. SNOT_USER_MAP="did:plc:abc=isabel,did:plc:def=alice". 19 + UserMap map[string]string `env:"SNOT_USER_MAP, required, separator=="` 20 + 21 + DbDsn string `env:"SNOT_DB_DSN, required"` 22 + RepoRoot string `env:"SNOT_REPO_ROOT, required"` 23 + PushRemote string `env:"SNOT_PUSH_REMOTE"` 24 + StateDir string `env:"SNOT_STATE_DIR, default=/var/lib/snot"` 25 + PlcUrl string `env:"SNOT_PLC_URL, default=https://plc.directory"` 26 + Dev bool `env:"SNOT_DEV, default=false"` 27 + } 28 + 29 + func Load(ctx context.Context) (*Config, error) { 30 + var c Config 31 + if err := envconfig.Process(ctx, &c); err != nil { 32 + return nil, err 33 + } 34 + return &c, nil 35 + }
+49
internal/config/config_test.go
··· 1 + package config 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + 7 + "github.com/sethvargo/go-envconfig" 8 + ) 9 + 10 + func load(t *testing.T, env map[string]string) (*Config, error) { 11 + t.Helper() 12 + var c Config 13 + err := envconfig.ProcessWith(context.Background(), &envconfig.Config{ 14 + Target: &c, 15 + Lookuper: envconfig.MapLookuper(env), 16 + }) 17 + return &c, err 18 + } 19 + 20 + func TestLoadDefaults(t *testing.T) { 21 + c, err := load(t, map[string]string{ 22 + "SNOT_HOSTNAME": "knot.example.com", 23 + "SNOT_OWNER_DID": "did:plc:abc123", 24 + "SNOT_USER_MAP": "did:plc:abc123=isabel,did:plc:def456=alice", 25 + "SNOT_DB_DSN": "postgres:///forgejo", 26 + "SNOT_REPO_ROOT": "/var/lib/forgejo/repositories", 27 + }) 28 + if err != nil { 29 + t.Fatal(err) 30 + } 31 + if c.ListenAddr != "0.0.0.0:5555" { 32 + t.Errorf("ListenAddr = %q", c.ListenAddr) 33 + } 34 + if c.StateDir != "/var/lib/snot" { 35 + t.Errorf("StateDir = %q", c.StateDir) 36 + } 37 + if c.PlcUrl != "https://plc.directory" { 38 + t.Errorf("PlcUrl = %q", c.PlcUrl) 39 + } 40 + if c.UserMap["did:plc:abc123"] != "isabel" || c.UserMap["did:plc:def456"] != "alice" { 41 + t.Errorf("UserMap = %v", c.UserMap) 42 + } 43 + } 44 + 45 + func TestLoadMissingRequired(t *testing.T) { 46 + if _, err := load(t, map[string]string{}); err == nil { 47 + t.Fatal("expected error for missing required vars") 48 + } 49 + }
+27
internal/events/events.go
··· 1 + // Package events serves a valid-but-empty knot eventstream: the appview's 2 + // consumer connects and idles; no history is fabricated. 3 + package events 4 + 5 + import ( 6 + "log/slog" 7 + "net/http" 8 + 9 + "tangled.org/core/eventstream" 10 + "tangled.org/core/notifier" 11 + ) 12 + 13 + type emptyBackend struct{} 14 + 15 + func (emptyBackend) GetEvents(cursor int64, limit int) ([]eventstream.Event, error) { 16 + return nil, nil 17 + } 18 + 19 + func Handler(n *notifier.Notifier, l *slog.Logger) http.HandlerFunc { 20 + return func(w http.ResponseWriter, r *http.Request) { 21 + _ = eventstream.Stream(w, r, eventstream.StreamConfig{ 22 + Backend: emptyBackend{}, 23 + Notifier: n, 24 + Logger: l, 25 + }) 26 + } 27 + }
+24
internal/events/events_test.go
··· 1 + package events 2 + 3 + import ( 4 + "log/slog" 5 + "net/http/httptest" 6 + "strings" 7 + "testing" 8 + 9 + "github.com/gorilla/websocket" 10 + "tangled.org/core/notifier" 11 + ) 12 + 13 + func TestWebsocketConnects(t *testing.T) { 14 + n := notifier.New() 15 + srv := httptest.NewServer(Handler(&n, slog.Default())) 16 + defer srv.Close() 17 + 18 + url := "ws" + strings.TrimPrefix(srv.URL, "http") + "?cursor=0" 19 + conn, res, err := websocket.DefaultDialer.Dial(url, nil) 20 + if err != nil { 21 + t.Fatalf("dial: %v (res=%v)", err, res) 22 + } 23 + conn.Close() 24 + }
+35
internal/forgejo/forgejo.go
··· 1 + // forgejo/forgejo.go 2 + package forgejo 3 + 4 + import ( 5 + "context" 6 + "errors" 7 + "time" 8 + ) 9 + 10 + // ErrNotFound is returned for absent, private, or wrong-owner repos alike, 11 + // so callers cannot distinguish private repos from missing ones. 12 + var ErrNotFound = errors.New("forgejo: not found") 13 + 14 + type Repo struct { 15 + ID int64 16 + Name string // lower_name 17 + OwnerName string // lowercased owner name 18 + Description string 19 + DefaultBranch string 20 + CreatedAt time.Time 21 + } 22 + 23 + type PublicKey struct { 24 + Key string 25 + CreatedAt time.Time 26 + } 27 + 28 + type Store interface { 29 + // ResolveRepo returns the repo iff it exists, is public, and belongs to user. 30 + ResolveRepo(ctx context.Context, user, name string) (*Repo, error) 31 + Languages(ctx context.Context, repoID int64) (map[string]int64, error) 32 + PublicKeys(ctx context.Context, user string) ([]PublicKey, error) 33 + // ListRepos returns all public repos belonging to user, ordered by name. 34 + ListRepos(ctx context.Context, user string) ([]Repo, error) 35 + }
+124
internal/forgejo/postgres.go
··· 1 + // forgejo/postgres.go 2 + package forgejo 3 + 4 + import ( 5 + "context" 6 + "errors" 7 + "strings" 8 + "time" 9 + 10 + "github.com/jackc/pgx/v5" 11 + "github.com/jackc/pgx/v5/pgxpool" 12 + ) 13 + 14 + type Postgres struct { 15 + pool *pgxpool.Pool 16 + } 17 + 18 + var _ Store = (*Postgres)(nil) 19 + 20 + func NewPostgres(ctx context.Context, dsn string) (*Postgres, error) { 21 + pool, err := pgxpool.New(ctx, dsn) 22 + if err != nil { 23 + return nil, err 24 + } 25 + return &Postgres{pool: pool}, nil 26 + } 27 + 28 + func (p *Postgres) Close() { 29 + p.pool.Close() 30 + } 31 + 32 + func (p *Postgres) ResolveRepo(ctx context.Context, user, name string) (*Repo, error) { 33 + var repo Repo 34 + var created int64 35 + err := p.pool.QueryRow(ctx, ` 36 + SELECT r.id, r.lower_name, lower(r.owner_name), coalesce(r.description, ''), 37 + r.default_branch, r.created_unix 38 + FROM repository r 39 + JOIN "user" u ON u.id = r.owner_id 40 + WHERE u.lower_name = $1 AND r.lower_name = $2 AND NOT r.is_private`, 41 + strings.ToLower(user), strings.ToLower(name), 42 + ).Scan(&repo.ID, &repo.Name, &repo.OwnerName, &repo.Description, &repo.DefaultBranch, &created) 43 + if errors.Is(err, pgx.ErrNoRows) { 44 + return nil, ErrNotFound 45 + } 46 + if err != nil { 47 + return nil, err 48 + } 49 + repo.CreatedAt = time.Unix(created, 0) 50 + return &repo, nil 51 + } 52 + 53 + func (p *Postgres) Languages(ctx context.Context, repoID int64) (map[string]int64, error) { 54 + rows, err := p.pool.Query(ctx, 55 + `SELECT language, size FROM language_stat WHERE repo_id = $1`, repoID) 56 + if err != nil { 57 + return nil, err 58 + } 59 + defer rows.Close() 60 + 61 + sizes := make(map[string]int64) 62 + for rows.Next() { 63 + var lang string 64 + var size int64 65 + if err := rows.Scan(&lang, &size); err != nil { 66 + return nil, err 67 + } 68 + sizes[lang] = size 69 + } 70 + return sizes, rows.Err() 71 + } 72 + 73 + func (p *Postgres) PublicKeys(ctx context.Context, user string) ([]PublicKey, error) { 74 + // type = 1 is KeyTypeUser (2 = deploy key, 3 = principal). 75 + rows, err := p.pool.Query(ctx, ` 76 + SELECT k.content, k.created_unix 77 + FROM public_key k 78 + JOIN "user" u ON u.id = k.owner_id 79 + WHERE u.lower_name = $1 AND k.type = 1`, 80 + strings.ToLower(user)) 81 + if err != nil { 82 + return nil, err 83 + } 84 + defer rows.Close() 85 + 86 + var keys []PublicKey 87 + for rows.Next() { 88 + var k PublicKey 89 + var created int64 90 + if err := rows.Scan(&k.Key, &created); err != nil { 91 + return nil, err 92 + } 93 + k.CreatedAt = time.Unix(created, 0) 94 + keys = append(keys, k) 95 + } 96 + return keys, rows.Err() 97 + } 98 + 99 + func (p *Postgres) ListRepos(ctx context.Context, user string) ([]Repo, error) { 100 + rows, err := p.pool.Query(ctx, ` 101 + SELECT r.id, r.lower_name, lower(r.owner_name), coalesce(r.description, ''), 102 + r.default_branch, r.created_unix 103 + FROM repository r 104 + JOIN "user" u ON u.id = r.owner_id 105 + WHERE u.lower_name = $1 AND NOT r.is_private 106 + ORDER BY r.lower_name`, 107 + strings.ToLower(user)) 108 + if err != nil { 109 + return nil, err 110 + } 111 + defer rows.Close() 112 + 113 + var repos []Repo 114 + for rows.Next() { 115 + var repo Repo 116 + var created int64 117 + if err := rows.Scan(&repo.ID, &repo.Name, &repo.OwnerName, &repo.Description, &repo.DefaultBranch, &created); err != nil { 118 + return nil, err 119 + } 120 + repo.CreatedAt = time.Unix(created, 0) 121 + repos = append(repos, repo) 122 + } 123 + return repos, rows.Err() 124 + }
+50
internal/forgejo/postgres_test.go
··· 1 + // forgejo/postgres_test.go 2 + package forgejo 3 + 4 + import ( 5 + "context" 6 + "errors" 7 + "os" 8 + "testing" 9 + ) 10 + 11 + // Run against a real Forgejo database: 12 + // 13 + // TEST_DATABASE_URL=postgres://... TEST_FORGEJO_USER=isabel TEST_FORGEJO_REPO=somerepo go test ./forgejo/ 14 + func TestPostgres(t *testing.T) { 15 + dsn := os.Getenv("TEST_DATABASE_URL") 16 + if dsn == "" { 17 + t.Skip("TEST_DATABASE_URL not set") 18 + } 19 + user := os.Getenv("TEST_FORGEJO_USER") 20 + repoName := os.Getenv("TEST_FORGEJO_REPO") 21 + 22 + ctx := context.Background() 23 + p, err := NewPostgres(ctx, dsn) 24 + if err != nil { 25 + t.Fatal(err) 26 + } 27 + defer p.Close() 28 + 29 + repo, err := p.ResolveRepo(ctx, user, repoName) 30 + if err != nil { 31 + t.Fatalf("ResolveRepo: %v", err) 32 + } 33 + if repo.Name == "" || repo.OwnerName == "" || repo.DefaultBranch == "" { 34 + t.Errorf("incomplete repo: %+v", repo) 35 + } 36 + 37 + if _, err := p.ResolveRepo(ctx, user, "definitely-does-not-exist-xyz"); !errors.Is(err, ErrNotFound) { 38 + t.Errorf("want ErrNotFound, got %v", err) 39 + } 40 + 41 + if _, err := p.Languages(ctx, repo.ID); err != nil { 42 + t.Errorf("Languages: %v", err) 43 + } 44 + if _, err := p.PublicKeys(ctx, user); err != nil { 45 + t.Errorf("PublicKeys: %v", err) 46 + } 47 + if _, err := p.ListRepos(ctx, user); err != nil { 48 + t.Errorf("ListRepos: %v", err) 49 + } 50 + }
+206
internal/gitserve/gitserve.go
··· 1 + // Package gitserve serves read-only git smart-HTTP from Forgejo's bare repos. 2 + package gitserve 3 + 4 + import ( 5 + "compress/gzip" 6 + "fmt" 7 + "io" 8 + "log/slog" 9 + "net/http" 10 + "strings" 11 + 12 + "github.com/go-chi/chi/v5" 13 + "tangled.org/core/idresolver" 14 + "tangled.org/core/knotserver/git/service" 15 + 16 + "github.com/isabelroses/snot/internal/config" 17 + "github.com/isabelroses/snot/internal/forgejo" 18 + "github.com/isabelroses/snot/internal/resolve" 19 + ) 20 + 21 + type Handler struct { 22 + Cfg *config.Config 23 + Resolve *resolve.Resolver 24 + Logger *slog.Logger 25 + Resolver *idresolver.Resolver // nil disables handle redirects (tests) 26 + } 27 + 28 + func (h *Handler) Routes() http.Handler { 29 + r := chi.NewRouter() 30 + 31 + r.Route("/{did}", func(r chi.Router) { 32 + r.Use(h.resolveDidRedirect) 33 + 34 + // bare repo-DID form 35 + r.Get("/info/refs", h.InfoRefs) 36 + r.Post("/git-upload-pack", h.UploadPack) 37 + r.Post("/git-upload-archive", h.UploadArchive) 38 + r.Post("/git-receive-pack", h.ReceivePack) 39 + 40 + r.Route("/{name}", func(r chi.Router) { 41 + r.Get("/info/refs", h.InfoRefs) 42 + r.Post("/git-upload-pack", h.UploadPack) 43 + r.Post("/git-upload-archive", h.UploadArchive) 44 + r.Post("/git-receive-pack", h.ReceivePack) 45 + }) 46 + }) 47 + 48 + return r 49 + } 50 + 51 + // resolveDidRedirect 307-redirects handle-based URLs to DID-based ones, 52 + // mirroring knotserver. 53 + func (h *Handler) resolveDidRedirect(next http.Handler) http.Handler { 54 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 55 + didOrHandle := chi.URLParam(r, "did") 56 + if strings.HasPrefix(didOrHandle, "did:") || h.Resolver == nil { 57 + next.ServeHTTP(w, r) 58 + return 59 + } 60 + 61 + trimmed := strings.TrimPrefix(didOrHandle, "@") 62 + id, err := h.Resolver.ResolveIdent(r.Context(), trimmed) 63 + if err != nil { 64 + h.Logger.Error("failed to resolve did/handle", "handle", trimmed, "err", err) 65 + http.Error(w, fmt.Sprintf("failed to resolve did/handle: %s", trimmed), http.StatusInternalServerError) 66 + return 67 + } 68 + 69 + suffix := strings.TrimPrefix(r.URL.Path, "/"+didOrHandle) 70 + newPath := "/" + id.DID.String() + suffix 71 + if r.URL.RawQuery != "" { 72 + newPath += "?" + r.URL.RawQuery 73 + } 74 + http.Redirect(w, r, newPath, http.StatusTemporaryRedirect) 75 + }) 76 + } 77 + 78 + func (h *Handler) repoPath(r *http.Request) (string, *forgejo.Repo, error) { 79 + did := chi.URLParam(r, "did") 80 + name := chi.URLParam(r, "name") 81 + 82 + if name == "" { 83 + repo, path, err := h.Resolve.RepoFromParam(r.Context(), did) 84 + if err != nil { 85 + return "", nil, err 86 + } 87 + return path, repo, nil 88 + } 89 + repo, path, err := h.Resolve.Repo(r.Context(), did, name) 90 + if err != nil { 91 + return "", nil, err 92 + } 93 + return path, repo, nil 94 + } 95 + 96 + func (h *Handler) notFound(w http.ResponseWriter) { 97 + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") 98 + w.WriteHeader(http.StatusNotFound) 99 + fmt.Fprint(w, "repository not found\n") 100 + } 101 + 102 + func (h *Handler) InfoRefs(w http.ResponseWriter, r *http.Request) { 103 + repoPath, repo, err := h.repoPath(r) 104 + if err != nil { 105 + h.notFound(w) 106 + return 107 + } 108 + 109 + cmd := service.ServiceCommand{ 110 + GitProtocol: r.Header.Get("Git-Protocol"), 111 + Dir: repoPath, 112 + Stdout: w, 113 + } 114 + 115 + switch r.URL.Query().Get("service") { 116 + case "git-upload-pack": 117 + w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement") 118 + w.Header().Set("Connection", "Keep-Alive") 119 + w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") 120 + w.WriteHeader(http.StatusOK) 121 + if err := cmd.InfoRefs(); err != nil { 122 + h.Logger.Error("git: info/refs failed", "error", err) 123 + } 124 + case "git-receive-pack": 125 + h.rejectPush(w, repo) 126 + default: 127 + gitError(w, "service unsupported", http.StatusForbidden) 128 + } 129 + } 130 + 131 + func (h *Handler) UploadPack(w http.ResponseWriter, r *http.Request) { 132 + h.serve(w, r, "application/x-git-upload-pack-request", 133 + "application/x-git-upload-pack-result", 134 + func(c *service.ServiceCommand) error { return c.UploadPack() }) 135 + } 136 + 137 + func (h *Handler) UploadArchive(w http.ResponseWriter, r *http.Request) { 138 + h.serve(w, r, "application/x-git-upload-archive-request", 139 + "application/x-git-upload-archive-result", 140 + func(c *service.ServiceCommand) error { return c.UploadArchive() }) 141 + } 142 + 143 + func (h *Handler) serve(w http.ResponseWriter, r *http.Request, wantCT, respCT string, run func(*service.ServiceCommand) error) { 144 + repoPath, _, err := h.repoPath(r) 145 + if err != nil { 146 + h.notFound(w) 147 + return 148 + } 149 + 150 + if ct := r.Header.Get("Content-Type"); ct != wantCT { 151 + gitError(w, fmt.Sprintf("expected Content-Type %q, got %q", wantCT, ct), http.StatusUnsupportedMediaType) 152 + return 153 + } 154 + 155 + var body io.ReadCloser = r.Body 156 + if r.Header.Get("Content-Encoding") == "gzip" { 157 + gz, err := gzip.NewReader(r.Body) 158 + if err != nil { 159 + gitError(w, err.Error(), http.StatusInternalServerError) 160 + return 161 + } 162 + defer gz.Close() 163 + body = gz 164 + } 165 + 166 + w.Header().Set("Content-Type", respCT) 167 + w.Header().Set("Connection", "Keep-Alive") 168 + w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate") 169 + w.WriteHeader(http.StatusOK) 170 + 171 + cmd := service.ServiceCommand{ 172 + GitProtocol: r.Header.Get("Git-Protocol"), 173 + Dir: repoPath, 174 + Stdout: w, 175 + Stdin: body, 176 + } 177 + if err := run(&cmd); err != nil { 178 + h.Logger.Error("git: service failed", "error", err) 179 + } 180 + } 181 + 182 + func (h *Handler) ReceivePack(w http.ResponseWriter, r *http.Request) { 183 + _, repo, err := h.repoPath(r) 184 + if err != nil { 185 + h.notFound(w) 186 + return 187 + } 188 + h.rejectPush(w, repo) 189 + } 190 + 191 + func (h *Handler) rejectPush(w http.ResponseWriter, repo *forgejo.Repo) { 192 + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") 193 + w.WriteHeader(http.StatusForbidden) 194 + fmt.Fprintf(w, "Pushes go through Forgejo, not this knot.") 195 + if h.Cfg.PushRemote != "" { 196 + fmt.Fprintf(w, " Try:\ngit remote set-url --push origin %s:%s/%s\n\n... and push again.", 197 + h.Cfg.PushRemote, repo.OwnerName, repo.Name) 198 + } 199 + fmt.Fprintf(w, "\n\n") 200 + } 201 + 202 + func gitError(w http.ResponseWriter, msg string, status int) { 203 + w.Header().Set("Content-Type", "text/plain; charset=UTF-8") 204 + w.WriteHeader(status) 205 + fmt.Fprintf(w, "%s\n", msg) 206 + }
+104
internal/gitserve/gitserve_test.go
··· 1 + package gitserve 2 + 3 + import ( 4 + "io" 5 + "log/slog" 6 + "net/http" 7 + "net/http/httptest" 8 + "os" 9 + "os/exec" 10 + "path/filepath" 11 + "strings" 12 + "testing" 13 + 14 + "github.com/isabelroses/snot/internal/config" 15 + "github.com/isabelroses/snot/internal/forgejo" 16 + "github.com/isabelroses/snot/internal/resolve" 17 + "github.com/isabelroses/snot/internal/state" 18 + "github.com/isabelroses/snot/internal/testutil" 19 + ) 20 + 21 + func newServer(t *testing.T) *httptest.Server { 22 + t.Helper() 23 + root := testutil.FixtureRepo(t, "isabel", "demo") 24 + rkeys, err := state.LoadRkeyMap(t.TempDir()) 25 + if err != nil { 26 + t.Fatal(err) 27 + } 28 + dids, err := state.LoadRepoDids(t.TempDir()) 29 + if err != nil { 30 + t.Fatal(err) 31 + } 32 + if err := dids.Put("did:plc:repo123", state.RepoDidInfo{User: "isabel", Repo: "demo", Key: []byte("k")}); err != nil { 33 + t.Fatal(err) 34 + } 35 + cfg := &config.Config{ 36 + Hostname: "knot.example.com", 37 + OwnerDid: "did:plc:knotadmin", 38 + UserMap: map[string]string{"did:plc:owner123": "isabel"}, 39 + RepoRoot: root, 40 + PushRemote: "git@forge.example.com", 41 + } 42 + store := &testutil.StubStore{Repos: map[string]*forgejo.Repo{ 43 + "isabel/demo": testutil.StubRepo(1, "isabel", "demo"), 44 + }} 45 + h := &Handler{ 46 + Cfg: cfg, 47 + Resolve: &resolve.Resolver{Cfg: cfg, Store: store, Rkeys: rkeys, Dids: dids}, 48 + Logger: slog.Default(), 49 + // Resolver (handle redirect) nil in tests. 50 + } 51 + srv := httptest.NewServer(h.Routes()) 52 + t.Cleanup(srv.Close) 53 + return srv 54 + } 55 + 56 + func TestCloneByDidName(t *testing.T) { 57 + srv := newServer(t) 58 + dest := filepath.Join(t.TempDir(), "clone") 59 + cmd := exec.Command("git", "clone", srv.URL+"/did:plc:owner123/demo", dest) 60 + if out, err := cmd.CombinedOutput(); err != nil { 61 + t.Fatalf("clone failed: %v\n%s", err, out) 62 + } 63 + if _, err := os.Stat(filepath.Join(dest, "README.md")); err != nil { 64 + t.Error("README.md missing in clone") 65 + } 66 + } 67 + 68 + func TestCloneByRepoDid(t *testing.T) { 69 + srv := newServer(t) 70 + dest := filepath.Join(t.TempDir(), "clone") 71 + cmd := exec.Command("git", "clone", 72 + srv.URL+"/did:plc:repo123", dest) 73 + if out, err := cmd.CombinedOutput(); err != nil { 74 + t.Fatalf("clone by repoDid failed: %v\n%s", err, out) 75 + } 76 + } 77 + 78 + func TestCloneUnknownRepo404s(t *testing.T) { 79 + srv := newServer(t) 80 + res, err := http.Get(srv.URL + "/did:plc:owner123/nope/info/refs?service=git-upload-pack") 81 + if err != nil { 82 + t.Fatal(err) 83 + } 84 + defer res.Body.Close() 85 + if res.StatusCode != 404 { 86 + t.Errorf("code = %d", res.StatusCode) 87 + } 88 + } 89 + 90 + func TestPushRejected(t *testing.T) { 91 + srv := newServer(t) 92 + res, err := http.Get(srv.URL + "/did:plc:owner123/demo/info/refs?service=git-receive-pack") 93 + if err != nil { 94 + t.Fatal(err) 95 + } 96 + defer res.Body.Close() 97 + if res.StatusCode != http.StatusForbidden { 98 + t.Errorf("code = %d", res.StatusCode) 99 + } 100 + body, _ := io.ReadAll(res.Body) 101 + if !strings.Contains(string(body), "git@forge.example.com") { 102 + t.Errorf("push hint missing: %s", body) 103 + } 104 + }
+24
internal/repodid/repodid.go
··· 1 + // Package repodid mints per-repo did:plc identities. The knot is set as the 2 + // DID's atproto_pds service endpoint, acting as a pseudo-PDS that answers 3 + // describeRepo for the repo DID. 4 + package repodid 5 + 6 + import ( 7 + "context" 8 + "fmt" 9 + 10 + tangledrepodid "tangled.org/core/knotserver/repodid" 11 + ) 12 + 13 + // Mint prepares a did:plc genesis op with the knot as service endpoint and 14 + // submits it to the PLC directory. Returns the DID and its rotation key. 15 + func Mint(ctx context.Context, plcURL, knotServiceURL string) (string, []byte, error) { 16 + prepared, err := tangledrepodid.PrepareRepoDID(plcURL, knotServiceURL) 17 + if err != nil { 18 + return "", nil, fmt.Errorf("preparing repo DID: %w", err) 19 + } 20 + if err := prepared.Submit(ctx); err != nil { 21 + return "", nil, fmt.Errorf("submitting repo DID: %w", err) 22 + } 23 + return prepared.RepoDid, prepared.SigningKeyRaw, nil 24 + }
+48
internal/repodid/repodid_test.go
··· 1 + package repodid 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "net/http/httptest" 7 + "strings" 8 + "testing" 9 + 10 + tangledrepodid "tangled.org/core/knotserver/repodid" 11 + ) 12 + 13 + // TestPrepareRepoDID checks that PrepareRepoDID produces a valid did:plc DID 14 + // and non-empty signing key without hitting a real PLC directory. Mint itself 15 + // requires a live Submit call, so we test at the prepare level and separately 16 + // verify that Mint propagates a submit error. 17 + func TestPrepareRepoDID(t *testing.T) { 18 + // Use a stub PLC server that accepts the genesis op. 19 + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 + // go-didplc posts to /<did> — any non-error response is success. 21 + w.WriteHeader(http.StatusOK) 22 + })) 23 + defer stub.Close() 24 + 25 + prepared, err := tangledrepodid.PrepareRepoDID(stub.URL, "https://knot.example.com") 26 + if err != nil { 27 + t.Fatalf("PrepareRepoDID: %v", err) 28 + } 29 + if !strings.HasPrefix(prepared.RepoDid, "did:plc:") { 30 + t.Errorf("RepoDid = %q, want did:plc: prefix", prepared.RepoDid) 31 + } 32 + if len(prepared.SigningKeyRaw) == 0 { 33 + t.Error("SigningKeyRaw is empty") 34 + } 35 + } 36 + 37 + func TestMintPropagatesSubmitError(t *testing.T) { 38 + // Server that rejects submissions. 39 + stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 40 + http.Error(w, "bad request", http.StatusBadRequest) 41 + })) 42 + defer stub.Close() 43 + 44 + _, _, err := Mint(context.Background(), stub.URL, "https://knot.example.com") 45 + if err == nil { 46 + t.Error("Mint should return error when PLC directory rejects submission") 47 + } 48 + }
+113
internal/resolve/resolve.go
··· 1 + // Package resolve maps knot-protocol repo identifiers (ownerDid/name, 2 + // ownerDid/rkey, or bare did:plc repo DIDs) to Forgejo repos on disk. 3 + package resolve 4 + 5 + import ( 6 + "context" 7 + "fmt" 8 + "os" 9 + "path/filepath" 10 + "strings" 11 + 12 + securejoin "github.com/cyphar/filepath-securejoin" 13 + "github.com/isabelroses/snot/internal/config" 14 + "github.com/isabelroses/snot/internal/forgejo" 15 + "github.com/isabelroses/snot/internal/state" 16 + ) 17 + 18 + type Resolver struct { 19 + Cfg *config.Config 20 + Store forgejo.Store 21 + Rkeys *state.RkeyMap 22 + Dids *state.RepoDids 23 + // MintDid mints a new repo DID (injected; tests use a fake). 24 + MintDid func(ctx context.Context) (did string, key []byte, err error) 25 + } 26 + 27 + // UserFor returns the Forgejo user whose repos the given DID exposes. 28 + func (rs *Resolver) UserFor(did string) (string, bool) { 29 + user, ok := rs.Cfg.UserMap[did] 30 + return user, ok 31 + } 32 + 33 + // DidFor returns the DID mapped to a Forgejo user (the inverse of UserFor). 34 + func (rs *Resolver) DidFor(user string) (string, bool) { 35 + for did, u := range rs.Cfg.UserMap { 36 + if strings.EqualFold(u, user) { 37 + return did, true 38 + } 39 + } 40 + return "", false 41 + } 42 + 43 + // Repo resolves an owner DID plus a repo name or record rkey. 44 + func (rs *Resolver) Repo(ctx context.Context, ownerDid, nameOrRkey string) (*forgejo.Repo, string, error) { 45 + user, ok := rs.UserFor(ownerDid) 46 + if !ok { 47 + return nil, "", forgejo.ErrNotFound 48 + } 49 + name := nameOrRkey 50 + if mapped, ok := rs.Rkeys.RepoByRkey(nameOrRkey); ok { 51 + name = mapped 52 + } 53 + return rs.lookup(ctx, user, name) 54 + } 55 + 56 + // RepoFromParam accepts both forms the protocol uses: "ownerDid/name" and a 57 + // bare repo DID (did:plc). 58 + func (rs *Resolver) RepoFromParam(ctx context.Context, param string) (*forgejo.Repo, string, error) { 59 + if !strings.HasPrefix(param, "did:") { 60 + return nil, "", forgejo.ErrNotFound 61 + } 62 + if i := strings.Index(param, "/"); i >= 0 { 63 + return rs.Repo(ctx, param[:i], param[i+1:]) 64 + } 65 + // Bare repo DID — look up in persisted mapping. 66 + if rs.Dids == nil { 67 + return nil, "", forgejo.ErrNotFound 68 + } 69 + info, ok := rs.Dids.Get(param) 70 + if !ok { 71 + return nil, "", forgejo.ErrNotFound 72 + } 73 + // Defense: require the owner DID is still mapped. 74 + if _, mapped := rs.DidFor(info.User); !mapped { 75 + return nil, "", forgejo.ErrNotFound 76 + } 77 + return rs.lookup(ctx, info.User, info.Repo) 78 + } 79 + 80 + // EnsureRepoDid returns the repo's did:plc, minting and persisting one on 81 + // first use. 82 + func (rs *Resolver) EnsureRepoDid(ctx context.Context, repo *forgejo.Repo) (string, error) { 83 + if rs.Dids != nil { 84 + if did, ok := rs.Dids.ByRepo(repo.OwnerName, repo.Name); ok { 85 + return did, nil 86 + } 87 + } 88 + did, key, err := rs.MintDid(ctx) 89 + if err != nil { 90 + return "", fmt.Errorf("minting repo DID: %w", err) 91 + } 92 + if rs.Dids != nil { 93 + if err := rs.Dids.Put(did, state.RepoDidInfo{User: repo.OwnerName, Repo: repo.Name, Key: key}); err != nil { 94 + return "", err 95 + } 96 + } 97 + return did, nil 98 + } 99 + 100 + func (rs *Resolver) lookup(ctx context.Context, user, name string) (*forgejo.Repo, string, error) { 101 + repo, err := rs.Store.ResolveRepo(ctx, user, name) 102 + if err != nil { 103 + return nil, "", err 104 + } 105 + path, err := securejoin.SecureJoin(rs.Cfg.RepoRoot, filepath.Join(repo.OwnerName, repo.Name+".git")) 106 + if err != nil { 107 + return nil, "", forgejo.ErrNotFound 108 + } 109 + if _, err := os.Stat(path); err != nil { 110 + return nil, "", forgejo.ErrNotFound 111 + } 112 + return repo, path, nil 113 + }
+181
internal/resolve/resolve_test.go
··· 1 + package resolve 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "testing" 7 + 8 + "github.com/isabelroses/snot/internal/config" 9 + "github.com/isabelroses/snot/internal/forgejo" 10 + "github.com/isabelroses/snot/internal/state" 11 + "github.com/isabelroses/snot/internal/testutil" 12 + ) 13 + 14 + func newResolver(t *testing.T) *Resolver { 15 + t.Helper() 16 + root := testutil.FixtureRepo(t, "isabel", "demo") 17 + rkeys, err := state.LoadRkeyMap(t.TempDir()) 18 + if err != nil { 19 + t.Fatal(err) 20 + } 21 + if err := rkeys.Put("3l5tidrkey", "demo"); err != nil { 22 + t.Fatal(err) 23 + } 24 + dids, err := state.LoadRepoDids(t.TempDir()) 25 + if err != nil { 26 + t.Fatal(err) 27 + } 28 + if err := dids.Put("did:plc:repo123", state.RepoDidInfo{User: "isabel", Repo: "demo", Key: []byte("k")}); err != nil { 29 + t.Fatal(err) 30 + } 31 + return &Resolver{ 32 + Cfg: &config.Config{ 33 + Hostname: "knot.example.com", 34 + OwnerDid: "did:plc:knotadmin", 35 + UserMap: map[string]string{"did:plc:owner123": "isabel"}, 36 + RepoRoot: root, 37 + }, 38 + Store: &testutil.StubStore{Repos: map[string]*forgejo.Repo{ 39 + "isabel/demo": testutil.StubRepo(1, "isabel", "demo"), 40 + }}, 41 + Rkeys: rkeys, 42 + Dids: dids, 43 + MintDid: func(ctx context.Context) (string, []byte, error) { 44 + return "did:plc:minted1", []byte("k"), nil 45 + }, 46 + } 47 + } 48 + 49 + func TestRepoByName(t *testing.T) { 50 + rs := newResolver(t) 51 + repo, path, err := rs.Repo(context.Background(), "did:plc:owner123", "demo") 52 + if err != nil { 53 + t.Fatal(err) 54 + } 55 + if repo.Name != "demo" || path == "" { 56 + t.Errorf("repo=%+v path=%q", repo, path) 57 + } 58 + } 59 + 60 + func TestRepoByRkey(t *testing.T) { 61 + rs := newResolver(t) 62 + repo, _, err := rs.Repo(context.Background(), "did:plc:owner123", "3l5tidrkey") 63 + if err != nil { 64 + t.Fatal(err) 65 + } 66 + if repo.Name != "demo" { 67 + t.Errorf("repo=%+v", repo) 68 + } 69 + } 70 + 71 + func TestRepoWrongOwner(t *testing.T) { 72 + rs := newResolver(t) 73 + _, _, err := rs.Repo(context.Background(), "did:plc:someoneelse", "demo") 74 + if !errors.Is(err, forgejo.ErrNotFound) { 75 + t.Errorf("err = %v", err) 76 + } 77 + } 78 + 79 + func TestRepoFromParamForms(t *testing.T) { 80 + rs := newResolver(t) 81 + ctx := context.Background() 82 + 83 + for _, param := range []string{ 84 + "did:plc:owner123/demo", 85 + "did:plc:owner123/3l5tidrkey", 86 + "did:plc:repo123", // seeded bare repo DID 87 + } { 88 + if _, _, err := rs.RepoFromParam(ctx, param); err != nil { 89 + t.Errorf("RepoFromParam(%q) = %v", param, err) 90 + } 91 + } 92 + 93 + for _, param := range []string{ 94 + "", 95 + "notadid/demo", 96 + "did:plc:owner123/private", 97 + "did:plc:owner123/../../etc", 98 + "did:plc:unknowndid", // bare DID not in Dids map 99 + "did:plc:knotadmin/demo", // knot admin DID owns no repos 100 + } { 101 + if _, _, err := rs.RepoFromParam(ctx, param); !errors.Is(err, forgejo.ErrNotFound) { 102 + t.Errorf("RepoFromParam(%q) = %v, want ErrNotFound", param, err) 103 + } 104 + } 105 + } 106 + 107 + func TestEnsureRepoDid_SeededReturnsExisting(t *testing.T) { 108 + rs := newResolver(t) 109 + repo := testutil.StubRepo(1, "isabel", "demo") 110 + 111 + did, err := rs.EnsureRepoDid(context.Background(), repo) 112 + if err != nil { 113 + t.Fatal(err) 114 + } 115 + if did != "did:plc:repo123" { 116 + t.Errorf("EnsureRepoDid = %q, want did:plc:repo123 (seeded)", did) 117 + } 118 + 119 + // Second call must be stable (no mint called). 120 + did2, err := rs.EnsureRepoDid(context.Background(), repo) 121 + if err != nil { 122 + t.Fatal(err) 123 + } 124 + if did2 != did { 125 + t.Errorf("second call returned different DID: %q", did2) 126 + } 127 + } 128 + 129 + func TestEnsureRepoDid_MintsAndPersists(t *testing.T) { 130 + root := testutil.FixtureRepo(t, "isabel", "other") 131 + rkeys, _ := state.LoadRkeyMap(t.TempDir()) 132 + dids, _ := state.LoadRepoDids(t.TempDir()) 133 + mintCount := 0 134 + rs := &Resolver{ 135 + Cfg: &config.Config{ 136 + Hostname: "knot.example.com", 137 + OwnerDid: "did:plc:knotadmin", 138 + UserMap: map[string]string{"did:plc:owner123": "isabel"}, 139 + RepoRoot: root, 140 + }, 141 + Store: &testutil.StubStore{Repos: map[string]*forgejo.Repo{ 142 + "isabel/other": testutil.StubRepo(2, "isabel", "other"), 143 + }}, 144 + Rkeys: rkeys, 145 + Dids: dids, 146 + MintDid: func(ctx context.Context) (string, []byte, error) { 147 + mintCount++ 148 + return "did:plc:minted1", []byte("newkey"), nil 149 + }, 150 + } 151 + 152 + repo := testutil.StubRepo(2, "isabel", "other") 153 + 154 + did, err := rs.EnsureRepoDid(context.Background(), repo) 155 + if err != nil { 156 + t.Fatal(err) 157 + } 158 + if did != "did:plc:minted1" { 159 + t.Errorf("EnsureRepoDid = %q, want did:plc:minted1", did) 160 + } 161 + if mintCount != 1 { 162 + t.Errorf("MintDid called %d times, want 1", mintCount) 163 + } 164 + 165 + // Verify persisted. 166 + if _, ok := dids.Get("did:plc:minted1"); !ok { 167 + t.Error("minted DID not persisted in Dids") 168 + } 169 + 170 + // Second call must not remint. 171 + did2, err := rs.EnsureRepoDid(context.Background(), repo) 172 + if err != nil { 173 + t.Fatal(err) 174 + } 175 + if did2 != did { 176 + t.Errorf("second EnsureRepoDid = %q, want %q", did2, did) 177 + } 178 + if mintCount != 1 { 179 + t.Errorf("MintDid called again on second EnsureRepoDid (total=%d)", mintCount) 180 + } 181 + }
+82
internal/state/repodids.go
··· 1 + package state 2 + 3 + import ( 4 + "encoding/json" 5 + "os" 6 + "path/filepath" 7 + "strings" 8 + "sync" 9 + ) 10 + 11 + const repodidFile = "repodids.json" 12 + 13 + // RepoDidInfo records a minted repo DID: which repo it names and the PLC 14 + // rotation/signing key that controls it. Losing the key means the DID's 15 + // document can never be updated, so the state dir must be backed up. 16 + type RepoDidInfo struct { 17 + User string `json:"user"` // forgejo owner (lower) 18 + Repo string `json:"repo"` // repo name (lower) 19 + Key []byte `json:"key"` // raw private key bytes 20 + } 21 + 22 + type RepoDids struct { 23 + mu sync.Mutex 24 + path string 25 + m map[string]RepoDidInfo // keyed by DID 26 + } 27 + 28 + func LoadRepoDids(stateDir string) (*RepoDids, error) { 29 + if err := os.MkdirAll(stateDir, 0o700); err != nil { 30 + return nil, err 31 + } 32 + r := &RepoDids{ 33 + path: filepath.Join(stateDir, repodidFile), 34 + m: make(map[string]RepoDidInfo), 35 + } 36 + b, err := os.ReadFile(r.path) 37 + if os.IsNotExist(err) { 38 + return r, nil 39 + } 40 + if err != nil { 41 + return nil, err 42 + } 43 + if err := json.Unmarshal(b, &r.m); err != nil { 44 + return nil, err 45 + } 46 + return r, nil 47 + } 48 + 49 + func (r *RepoDids) Put(did string, info RepoDidInfo) error { 50 + r.mu.Lock() 51 + defer r.mu.Unlock() 52 + r.m[did] = info 53 + 54 + b, err := json.MarshalIndent(r.m, "", " ") 55 + if err != nil { 56 + return err 57 + } 58 + tmp := r.path + ".tmp" 59 + if err := os.WriteFile(tmp, b, 0o600); err != nil { 60 + return err 61 + } 62 + return os.Rename(tmp, r.path) 63 + } 64 + 65 + func (r *RepoDids) Get(did string) (RepoDidInfo, bool) { 66 + r.mu.Lock() 67 + defer r.mu.Unlock() 68 + info, ok := r.m[did] 69 + return info, ok 70 + } 71 + 72 + // ByRepo returns the DID for the given (user, repo) pair (case-insensitive). 73 + func (r *RepoDids) ByRepo(user, repo string) (string, bool) { 74 + r.mu.Lock() 75 + defer r.mu.Unlock() 76 + for did, info := range r.m { 77 + if strings.EqualFold(info.User, user) && strings.EqualFold(info.Repo, repo) { 78 + return did, true 79 + } 80 + } 81 + return "", false 82 + }
+88
internal/state/repodids_test.go
··· 1 + package state 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + ) 8 + 9 + func TestRepoDidsRoundTrip(t *testing.T) { 10 + dir := t.TempDir() 11 + 12 + r, err := LoadRepoDids(dir) 13 + if err != nil { 14 + t.Fatal(err) 15 + } 16 + 17 + info := RepoDidInfo{User: "isabel", Repo: "demo", Key: []byte("testkey")} 18 + if err := r.Put("did:plc:abc123", info); err != nil { 19 + t.Fatal(err) 20 + } 21 + 22 + // Reload from disk. 23 + r2, err := LoadRepoDids(dir) 24 + if err != nil { 25 + t.Fatal(err) 26 + } 27 + 28 + got, ok := r2.Get("did:plc:abc123") 29 + if !ok { 30 + t.Fatal("Get: not found after reload") 31 + } 32 + if got.User != "isabel" || got.Repo != "demo" || string(got.Key) != "testkey" { 33 + t.Errorf("Get = %+v", got) 34 + } 35 + } 36 + 37 + func TestRepoDidsByRepo(t *testing.T) { 38 + dir := t.TempDir() 39 + r, err := LoadRepoDids(dir) 40 + if err != nil { 41 + t.Fatal(err) 42 + } 43 + 44 + if err := r.Put("did:plc:abc123", RepoDidInfo{User: "Isabel", Repo: "Demo", Key: []byte("k")}); err != nil { 45 + t.Fatal(err) 46 + } 47 + 48 + // Case-insensitive hit. 49 + did, ok := r.ByRepo("isabel", "demo") 50 + if !ok || did != "did:plc:abc123" { 51 + t.Errorf("ByRepo hit: got %q, %v", did, ok) 52 + } 53 + 54 + // Miss. 55 + if _, ok := r.ByRepo("isabel", "other"); ok { 56 + t.Error("ByRepo should miss for unknown repo") 57 + } 58 + } 59 + 60 + func TestRepoDidsFilePerms(t *testing.T) { 61 + dir := t.TempDir() 62 + r, err := LoadRepoDids(dir) 63 + if err != nil { 64 + t.Fatal(err) 65 + } 66 + if err := r.Put("did:plc:xyz", RepoDidInfo{User: "u", Repo: "r", Key: []byte("k")}); err != nil { 67 + t.Fatal(err) 68 + } 69 + 70 + info, err := os.Stat(filepath.Join(dir, repodidFile)) 71 + if err != nil { 72 + t.Fatalf("repodids.json not written: %v", err) 73 + } 74 + if info.Mode().Perm() != 0o600 { 75 + t.Errorf("file mode = %v, want 0600", info.Mode().Perm()) 76 + } 77 + } 78 + 79 + func TestRepoDidsToleratesAbsent(t *testing.T) { 80 + dir := t.TempDir() 81 + r, err := LoadRepoDids(dir) 82 + if err != nil { 83 + t.Fatal(err) 84 + } 85 + if _, ok := r.Get("did:plc:nobody"); ok { 86 + t.Error("expected miss on empty store") 87 + } 88 + }
+73
internal/state/rkeys.go
··· 1 + // Package state persists the small amount of mutable state the shim owns: 2 + // the mapping from sh.tangled.repo record rkeys to Forgejo repo names. 3 + package state 4 + 5 + import ( 6 + "encoding/json" 7 + "os" 8 + "path/filepath" 9 + "sync" 10 + ) 11 + 12 + const rkeyFile = "rkeys.json" 13 + 14 + type RkeyMap struct { 15 + mu sync.Mutex 16 + path string 17 + m map[string]string // rkey -> repo lower_name 18 + } 19 + 20 + func LoadRkeyMap(stateDir string) (*RkeyMap, error) { 21 + if err := os.MkdirAll(stateDir, 0o700); err != nil { 22 + return nil, err 23 + } 24 + r := &RkeyMap{ 25 + path: filepath.Join(stateDir, rkeyFile), 26 + m: make(map[string]string), 27 + } 28 + b, err := os.ReadFile(r.path) 29 + if os.IsNotExist(err) { 30 + return r, nil 31 + } 32 + if err != nil { 33 + return nil, err 34 + } 35 + if err := json.Unmarshal(b, &r.m); err != nil { 36 + return nil, err 37 + } 38 + return r, nil 39 + } 40 + 41 + func (r *RkeyMap) Put(rkey, repoName string) error { 42 + r.mu.Lock() 43 + defer r.mu.Unlock() 44 + r.m[rkey] = repoName 45 + 46 + b, err := json.MarshalIndent(r.m, "", " ") 47 + if err != nil { 48 + return err 49 + } 50 + tmp := r.path + ".tmp" 51 + if err := os.WriteFile(tmp, b, 0o600); err != nil { 52 + return err 53 + } 54 + return os.Rename(tmp, r.path) 55 + } 56 + 57 + func (r *RkeyMap) RepoByRkey(rkey string) (string, bool) { 58 + r.mu.Lock() 59 + defer r.mu.Unlock() 60 + repo, ok := r.m[rkey] 61 + return repo, ok 62 + } 63 + 64 + func (r *RkeyMap) RkeyByRepo(repoName string) (string, bool) { 65 + r.mu.Lock() 66 + defer r.mu.Unlock() 67 + for rkey, repo := range r.m { 68 + if repo == repoName { 69 + return rkey, true 70 + } 71 + } 72 + return "", false 73 + }
+53
internal/state/rkeys_test.go
··· 1 + package state 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + ) 8 + 9 + func TestRkeyMapPersists(t *testing.T) { 10 + dir := t.TempDir() 11 + 12 + m, err := LoadRkeyMap(dir) 13 + if err != nil { 14 + t.Fatal(err) 15 + } 16 + if err := m.Put("3l5abc123", "myrepo"); err != nil { 17 + t.Fatal(err) 18 + } 19 + 20 + m2, err := LoadRkeyMap(dir) 21 + if err != nil { 22 + t.Fatal(err) 23 + } 24 + if got, ok := m2.RepoByRkey("3l5abc123"); !ok || got != "myrepo" { 25 + t.Errorf("RepoByRkey = %q, %v", got, ok) 26 + } 27 + if got, ok := m2.RkeyByRepo("myrepo"); !ok || got != "3l5abc123" { 28 + t.Errorf("RkeyByRepo = %q, %v", got, ok) 29 + } 30 + if _, ok := m2.RepoByRkey("missing"); ok { 31 + t.Error("expected miss") 32 + } 33 + 34 + if _, err := os.Stat(filepath.Join(dir, "rkeys.json")); err != nil { 35 + t.Fatalf("rkeys.json not written: %v", err) 36 + } 37 + } 38 + 39 + func TestPutOverwrites(t *testing.T) { 40 + m, err := LoadRkeyMap(t.TempDir()) 41 + if err != nil { 42 + t.Fatal(err) 43 + } 44 + if err := m.Put("rkey1", "repo1"); err != nil { 45 + t.Fatal(err) 46 + } 47 + if err := m.Put("rkey1", "repo2"); err != nil { 48 + t.Fatal(err) 49 + } 50 + if got, _ := m.RepoByRkey("rkey1"); got != "repo2" { 51 + t.Errorf("got %q", got) 52 + } 53 + }
+101
internal/testutil/fixture.go
··· 1 + // internal/testutil/fixture.go 2 + package testutil 3 + 4 + import ( 5 + "context" 6 + "os" 7 + "os/exec" 8 + "path/filepath" 9 + "slices" 10 + "strings" 11 + "testing" 12 + "time" 13 + 14 + "github.com/isabelroses/snot/internal/forgejo" 15 + ) 16 + 17 + // StubStore implements forgejo.Store from a fixed set of repos. 18 + type StubStore struct { 19 + Repos map[string]*forgejo.Repo // key: "user/name" (lowercase) 20 + Langs map[int64]map[string]int64 21 + Keys []forgejo.PublicKey 22 + } 23 + 24 + func (s *StubStore) ResolveRepo(_ context.Context, user, name string) (*forgejo.Repo, error) { 25 + r, ok := s.Repos[user+"/"+name] 26 + if !ok { 27 + return nil, forgejo.ErrNotFound 28 + } 29 + return r, nil 30 + } 31 + 32 + func (s *StubStore) Languages(_ context.Context, repoID int64) (map[string]int64, error) { 33 + return s.Langs[repoID], nil 34 + } 35 + 36 + func (s *StubStore) PublicKeys(_ context.Context, _ string) ([]forgejo.PublicKey, error) { 37 + return s.Keys, nil 38 + } 39 + 40 + func (s *StubStore) ListRepos(_ context.Context, _ string) ([]forgejo.Repo, error) { 41 + var repos []forgejo.Repo 42 + for _, r := range s.Repos { 43 + repos = append(repos, *r) 44 + } 45 + // Match the postgres implementation's ORDER BY lower_name. 46 + slices.SortFunc(repos, func(a, b forgejo.Repo) int { 47 + return strings.Compare(a.Name, b.Name) 48 + }) 49 + return repos, nil 50 + } 51 + 52 + // FixtureRepo creates {root}/{owner}/{name}.git as a bare repo containing one 53 + // commit (README.md on branch main) and returns the repo root directory. 54 + func FixtureRepo(t *testing.T, owner, name string) (root string) { 55 + t.Helper() 56 + root = t.TempDir() 57 + work := filepath.Join(t.TempDir(), "work") 58 + 59 + run := func(dir string, args ...string) { 60 + t.Helper() 61 + cmd := exec.Command("git", args...) 62 + cmd.Dir = dir 63 + cmd.Env = append(os.Environ(), 64 + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test", 65 + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test", 66 + "GIT_AUTHOR_DATE=1700000000 +0000", "GIT_COMMITTER_DATE=1700000000 +0000", 67 + ) 68 + if out, err := cmd.CombinedOutput(); err != nil { 69 + t.Fatalf("git %v: %v\n%s", args, err, out) 70 + } 71 + } 72 + 73 + if err := os.MkdirAll(work, 0o755); err != nil { 74 + t.Fatal(err) 75 + } 76 + run(work, "init", "-b", "main") 77 + if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("# fixture\n"), 0o644); err != nil { 78 + t.Fatal(err) 79 + } 80 + run(work, "add", ".") 81 + run(work, "commit", "-m", "initial commit") 82 + 83 + bare := filepath.Join(root, owner, name+".git") 84 + if err := os.MkdirAll(filepath.Dir(bare), 0o755); err != nil { 85 + t.Fatal(err) 86 + } 87 + run(work, "clone", "--bare", ".", bare) 88 + return root 89 + } 90 + 91 + // StubRepo is a convenience forgejo.Repo for fixtures. 92 + func StubRepo(id int64, owner, name string) *forgejo.Repo { 93 + return &forgejo.Repo{ 94 + ID: id, 95 + Name: name, 96 + OwnerName: owner, 97 + Description: "a fixture repo", 98 + DefaultBranch: "main", 99 + CreatedAt: time.Unix(1700000000, 0), 100 + } 101 + }
+71
internal/xrpc/create_repo.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.org/core/api/tangled" 11 + xrpcerr "tangled.org/core/xrpc/errors" 12 + "tangled.org/core/xrpc/serviceauth" 13 + 14 + "github.com/isabelroses/snot/internal/forgejo" 15 + ) 16 + 17 + // CreateRepo adopts an existing public Forgejo repo so the appview can record 18 + // it; it never creates repositories. 19 + func (x *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger.With("handler", "CreateRepo") 21 + fail := func(status int, e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, status) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(serviceauth.ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(http.StatusBadRequest, xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + // Any mapped DID may adopt — within its own Forgejo user's repos. 32 + user, mapped := x.Resolve.UserFor(actorDid.String()) 33 + if !mapped { 34 + fail(http.StatusForbidden, xrpcerr.AccessControlError(actorDid.String())) 35 + return 36 + } 37 + 38 + var data tangled.RepoCreate_Input 39 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 40 + fail(http.StatusBadRequest, xrpcerr.GenericError(err)) 41 + return 42 + } 43 + if data.Name == "" || data.Rkey == "" { 44 + fail(http.StatusBadRequest, xrpcerr.GenericError(fmt.Errorf("name and rkey are required"))) 45 + return 46 + } 47 + 48 + repo, err := x.Store.ResolveRepo(r.Context(), user, data.Name) 49 + if errors.Is(err, forgejo.ErrNotFound) { 50 + fail(http.StatusBadRequest, xrpcerr.GenericError(fmt.Errorf( 51 + "repo %q does not exist on the Forgejo instance; this shim only adopts existing public repos", data.Name))) 52 + return 53 + } 54 + if err != nil { 55 + fail(http.StatusInternalServerError, xrpcerr.GenericError(err)) 56 + return 57 + } 58 + 59 + if err := x.Rkeys.Put(data.Rkey, repo.Name); err != nil { 60 + fail(http.StatusInternalServerError, xrpcerr.GenericError(err)) 61 + return 62 + } 63 + 64 + repoDid, err := x.Resolve.EnsureRepoDid(r.Context(), repo) 65 + if err != nil { 66 + fail(http.StatusInternalServerError, xrpcerr.GenericError(err)) 67 + return 68 + } 69 + l.Info("adopted repo", "repo", repo.Name, "rkey", data.Rkey, "repoDid", repoDid) 70 + x.writeJson(w, tangled.RepoCreate_Output{RepoDid: &repoDid}) 71 + }
+100
internal/xrpc/create_repo_test.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "net/http" 8 + "net/http/httptest" 9 + "testing" 10 + 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "tangled.org/core/api/tangled" 13 + "tangled.org/core/xrpc/serviceauth" 14 + ) 15 + 16 + // asActor injects the actor DID the way serviceauth middleware does. 17 + func asActor(req *http.Request, did string) *http.Request { 18 + d, _ := syntax.ParseDID(did) 19 + return req.WithContext(context.WithValue(req.Context(), serviceauth.ActorDid, d)) 20 + } 21 + 22 + func postCreate(t *testing.T, x *Xrpc, actor string, input tangled.RepoCreate_Input) *httptest.ResponseRecorder { 23 + t.Helper() 24 + body, _ := json.Marshal(input) 25 + req := httptest.NewRequest("POST", "/sh.tangled.repo.create", bytes.NewReader(body)) 26 + req = asActor(req, actor) 27 + rec := httptest.NewRecorder() 28 + x.CreateRepo(rec, req) 29 + return rec 30 + } 31 + 32 + func TestCreateRepoAdoptsExisting(t *testing.T) { 33 + x := newXrpc(t) 34 + rec := postCreate(t, x, "did:plc:owner123", 35 + tangled.RepoCreate_Input{Rkey: "3l5newrkey", Name: "demo"}) 36 + if rec.Code != 200 { 37 + t.Fatalf("code=%d body=%s", rec.Code, rec.Body) 38 + } 39 + var out tangled.RepoCreate_Output 40 + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { 41 + t.Fatal(err) 42 + } 43 + // The repo already has a seeded DID (did:plc:repo123) so EnsureRepoDid must 44 + // return that, not mint a new one. 45 + if out.RepoDid == nil || *out.RepoDid != "did:plc:repo123" { 46 + t.Errorf("repoDid = %v", out.RepoDid) 47 + } 48 + if got, _ := x.Rkeys.RepoByRkey("3l5newrkey"); got != "demo" { 49 + t.Errorf("rkey not recorded, got %q", got) 50 + } 51 + // Dids must contain the DID. 52 + if _, ok := x.Resolve.Dids.Get("did:plc:repo123"); !ok { 53 + t.Error("Dids should contain did:plc:repo123 after adopt") 54 + } 55 + } 56 + 57 + func TestCreateRepoAdoptStableOnSecondCall(t *testing.T) { 58 + x := newXrpc(t) 59 + 60 + // Two adopts of the same repo must return the same DID. 61 + rec1 := postCreate(t, x, "did:plc:owner123", 62 + tangled.RepoCreate_Input{Rkey: "3l5rkey1", Name: "demo"}) 63 + rec2 := postCreate(t, x, "did:plc:owner123", 64 + tangled.RepoCreate_Input{Rkey: "3l5rkey2", Name: "demo"}) 65 + if rec1.Code != 200 || rec2.Code != 200 { 66 + t.Fatalf("codes=%d %d", rec1.Code, rec2.Code) 67 + } 68 + var out1, out2 tangled.RepoCreate_Output 69 + if err := json.Unmarshal(rec1.Body.Bytes(), &out1); err != nil { 70 + t.Fatal(err) 71 + } 72 + if err := json.Unmarshal(rec2.Body.Bytes(), &out2); err != nil { 73 + t.Fatal(err) 74 + } 75 + if out1.RepoDid == nil || out2.RepoDid == nil || *out1.RepoDid != *out2.RepoDid { 76 + t.Errorf("second adopt returned different DID: %v vs %v", out1.RepoDid, out2.RepoDid) 77 + } 78 + } 79 + 80 + func TestCreateRepoRejectsMissingRepo(t *testing.T) { 81 + x := newXrpc(t) 82 + rec := postCreate(t, x, "did:plc:owner123", 83 + tangled.RepoCreate_Input{Rkey: "3l5newrkey", Name: "doesnotexist"}) 84 + if rec.Code == 200 { 85 + t.Fatalf("adopting a non-existent repo must fail; body=%s", rec.Body) 86 + } 87 + } 88 + 89 + func TestCreateRepoRejectsUnmappedActor(t *testing.T) { 90 + x := newXrpc(t) 91 + // neither a stranger nor the knot admin may adopt: only mapped DIDs, 92 + // and the admin DID governs the knot, not repos 93 + for _, actor := range []string{"did:plc:intruder", "did:plc:knotadmin"} { 94 + rec := postCreate(t, x, actor, 95 + tangled.RepoCreate_Input{Rkey: "3l5newrkey", Name: "demo"}) 96 + if rec.Code == 200 { 97 + t.Fatalf("unmapped actor %s must not adopt repos", actor) 98 + } 99 + } 100 + }
+124
internal/xrpc/handlers_test.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "io" 6 + "net/http" 7 + "net/http/httptest" 8 + "net/url" 9 + "testing" 10 + ) 11 + 12 + func getJSON(t *testing.T, srv *httptest.Server, nsid string, params url.Values) (int, map[string]any) { 13 + t.Helper() 14 + u := srv.URL + "/" + nsid 15 + if params != nil { 16 + u += "?" + params.Encode() 17 + } 18 + res, err := http.Get(u) 19 + if err != nil { 20 + t.Fatal(err) 21 + } 22 + defer res.Body.Close() 23 + body, _ := io.ReadAll(res.Body) 24 + var m map[string]any 25 + if len(body) > 0 { 26 + if err := json.Unmarshal(body, &m); err != nil { 27 + t.Fatalf("bad json (%d): %s", res.StatusCode, body) 28 + } 29 + } 30 + return res.StatusCode, m 31 + } 32 + 33 + func TestOwner(t *testing.T) { 34 + srv := httptest.NewServer(newXrpc(t).Router()) 35 + defer srv.Close() 36 + code, m := getJSON(t, srv, "sh.tangled.owner", nil) 37 + if code != 200 || m["owner"] != "did:plc:knotadmin" { 38 + t.Errorf("code=%d m=%v", code, m) 39 + } 40 + } 41 + 42 + func TestListKeys(t *testing.T) { 43 + srv := httptest.NewServer(newXrpc(t).Router()) 44 + defer srv.Close() 45 + code, m := getJSON(t, srv, "sh.tangled.knot.listKeys", nil) 46 + if code != 200 { 47 + t.Fatalf("code=%d", code) 48 + } 49 + keys := m["keys"].([]any) 50 + k := keys[0].(map[string]any) 51 + if k["did"] != "did:plc:owner123" || k["key"] != "ssh-ed25519 AAAA test" { 52 + t.Errorf("key = %v", k) 53 + } 54 + } 55 + 56 + func TestLanguages(t *testing.T) { 57 + srv := httptest.NewServer(newXrpc(t).Router()) 58 + defer srv.Close() 59 + code, m := getJSON(t, srv, "sh.tangled.repo.languages", 60 + url.Values{"repo": {"did:plc:owner123/demo"}}) 61 + if code != 200 { 62 + t.Fatalf("code=%d m=%v", code, m) 63 + } 64 + langs := m["languages"].([]any) 65 + if len(langs) != 2 { 66 + t.Errorf("languages = %v", langs) 67 + } 68 + if m["totalSize"].(float64) != 1500 { 69 + t.Errorf("totalSize = %v", m["totalSize"]) 70 + } 71 + } 72 + 73 + func TestDescribeRepo(t *testing.T) { 74 + x := newXrpc(t) 75 + if err := x.Rkeys.Put("3l5tidrkey", "demo"); err != nil { 76 + t.Fatal(err) 77 + } 78 + srv := httptest.NewServer(x.Router()) 79 + defer srv.Close() 80 + 81 + code, m := getJSON(t, srv, "sh.tangled.repo.describeRepo", 82 + url.Values{"repoDid": {"did:plc:repo123"}}) 83 + if code != 200 { 84 + t.Fatalf("code=%d m=%v", code, m) 85 + } 86 + if m["ownerDid"] != "did:plc:owner123" || m["rkey"] != "3l5tidrkey" { 87 + t.Errorf("m=%v", m) 88 + } 89 + 90 + code, _ = getJSON(t, srv, "sh.tangled.repo.describeRepo", 91 + url.Values{"repoDid": {"did:plc:unknowndid"}}) 92 + if code != 404 { 93 + t.Errorf("missing repo code=%d", code) 94 + } 95 + } 96 + 97 + func TestReadHandlersSmoke(t *testing.T) { 98 + srv := httptest.NewServer(newXrpc(t).Router()) 99 + defer srv.Close() 100 + repo := url.Values{"repo": {"did:plc:owner123/demo"}} 101 + 102 + for _, tc := range []struct { 103 + nsid string 104 + params url.Values 105 + }{ 106 + {"sh.tangled.repo.getDefaultBranch", repo}, 107 + {"sh.tangled.repo.branches", repo}, 108 + {"sh.tangled.repo.tags", repo}, 109 + {"sh.tangled.repo.tree", url.Values{"repo": repo["repo"], "ref": {"main"}}}, 110 + {"sh.tangled.repo.log", url.Values{"repo": repo["repo"], "ref": {"main"}}}, 111 + {"sh.tangled.repo.blob", url.Values{"repo": repo["repo"], "ref": {"main"}, "path": {"README.md"}}}, 112 + } { 113 + code, m := getJSON(t, srv, tc.nsid, tc.params) 114 + if code != 200 { 115 + t.Errorf("%s: code=%d body=%v", tc.nsid, code, m) 116 + } 117 + } 118 + 119 + code, _ := getJSON(t, srv, "sh.tangled.repo.branches", 120 + url.Values{"repo": {"did:plc:owner123/missing"}}) 121 + if code == 200 { 122 + t.Error("missing repo should not 200") 123 + } 124 + }
+46
internal/xrpc/list_keys.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "slices" 6 + "time" 7 + 8 + "tangled.org/core/api/tangled" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 + ) 11 + 12 + // ListKeys returns the Forgejo SSH keys of every mapped user, attributed to 13 + // their DID, in one page; the lexicon's cursor/limit pagination is not 14 + // implemented since a small instance holds at most a handful of keys. 15 + func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) { 16 + dids := make([]string, 0, len(x.Cfg.UserMap)) 17 + for did := range x.Cfg.UserMap { 18 + dids = append(dids, did) 19 + } 20 + slices.Sort(dids) 21 + 22 + var publicKeys []*tangled.KnotListKeys_PublicKey 23 + for _, did := range dids { 24 + keys, err := x.Store.PublicKeys(r.Context(), x.Cfg.UserMap[did]) 25 + if err != nil { 26 + x.Logger.Error("failed to get public keys", "error", err) 27 + writeError(w, xrpcerr.NewXrpcError( 28 + xrpcerr.WithTag("InternalServerError"), 29 + xrpcerr.WithMessage("failed to retrieve public keys"), 30 + ), http.StatusInternalServerError) 31 + return 32 + } 33 + for _, key := range keys { 34 + publicKeys = append(publicKeys, &tangled.KnotListKeys_PublicKey{ 35 + Did: did, 36 + Key: key.Key, 37 + CreatedAt: key.CreatedAt.Format(time.RFC3339), 38 + }) 39 + } 40 + } 41 + if publicKeys == nil { 42 + publicKeys = []*tangled.KnotListKeys_PublicKey{} 43 + } 44 + 45 + x.writeJson(w, tangled.KnotListKeys_Output{Keys: publicKeys}) 46 + }
+11
internal/xrpc/owner.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + 6 + "tangled.org/core/api/tangled" 7 + ) 8 + 9 + func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) { 10 + x.writeJson(w, tangled.Owner_Output{Owner: x.Cfg.OwnerDid}) 11 + }
+111
internal/xrpc/repo_archive.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "net/url" 7 + "strings" 8 + 9 + "github.com/go-git/go-git/v5/plumbing" 10 + 11 + "tangled.org/core/api/tangled" 12 + "tangled.org/core/knotserver/git" 13 + xrpcerr "tangled.org/core/xrpc/errors" 14 + ) 15 + 16 + func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) { 17 + repo := r.URL.Query().Get("repo") 18 + repoPath, err := x.parseRepoParam(repo) 19 + if err != nil { 20 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 21 + return 22 + } 23 + 24 + ref := r.URL.Query().Get("ref") 25 + // ref can be empty (git.Open handles this) 26 + 27 + format := r.URL.Query().Get("format") 28 + if format == "" { 29 + format = "tar.gz" // default 30 + } 31 + 32 + prefix := r.URL.Query().Get("prefix") 33 + 34 + if format != "tar.gz" && format != "zip" { 35 + writeError(w, xrpcerr.NewXrpcError( 36 + xrpcerr.WithTag("InvalidRequest"), 37 + xrpcerr.WithMessage("only tar.gz and zip formats are supported"), 38 + ), http.StatusBadRequest) 39 + return 40 + } 41 + 42 + gr, err := git.Open(repoPath, ref) 43 + if err != nil { 44 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 45 + return 46 + } 47 + 48 + repoParts := strings.Split(repo, "/") 49 + repoName := repoParts[len(repoParts)-1] 50 + 51 + immutableLink, err := x.buildImmutableLink(repo, format, gr.Hash().String(), prefix) 52 + if err != nil { 53 + x.Logger.Error( 54 + "failed to build immutable link", 55 + "err", err.Error(), 56 + "repo", repo, 57 + "format", format, 58 + "ref", gr.Hash().String(), 59 + "prefix", prefix, 60 + ) 61 + } 62 + 63 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 64 + 65 + var archivePrefix string 66 + if prefix != "" { 67 + archivePrefix = prefix 68 + } else { 69 + archivePrefix = fmt.Sprintf("%s-%s", repoName, safeRefFilename) 70 + } 71 + 72 + filename := fmt.Sprintf("%s-%s.%s", repoName, safeRefFilename, format) 73 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 74 + w.Header().Set("Content-Type", archiveContentType(format)) 75 + w.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"immutable\"", immutableLink)) 76 + 77 + err = gr.WriteArchive(w, format, archivePrefix) 78 + if err != nil { 79 + // once we start writing to the body we can't report error anymore 80 + // so we are only left with logging the error 81 + x.Logger.Error("writing archive", "error", err.Error(), "format", format) 82 + return 83 + } 84 + } 85 + 86 + func archiveContentType(format string) string { 87 + if format == "zip" { 88 + return "application/zip" 89 + } 90 + return "application/gzip" 91 + } 92 + 93 + func (x *Xrpc) buildImmutableLink(repo string, format string, ref string, prefix string) (string, error) { 94 + scheme := "https" 95 + if x.Cfg.Dev { 96 + scheme = "http" 97 + } 98 + 99 + u, err := url.Parse(scheme + "://" + x.Cfg.Hostname + "/xrpc/" + tangled.RepoArchiveNSID) 100 + if err != nil { 101 + return "", err 102 + } 103 + 104 + params := url.Values{} 105 + params.Set("repo", repo) 106 + params.Set("format", format) 107 + params.Set("ref", ref) 108 + params.Set("prefix", prefix) 109 + 110 + return fmt.Sprintf("%s?%s", u.String(), params.Encode()), nil 111 + }
+193
internal/xrpc/repo_blob.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "crypto/sha256" 6 + "encoding/base64" 7 + "fmt" 8 + "net/http" 9 + "path/filepath" 10 + "slices" 11 + "strings" 12 + "time" 13 + 14 + "tangled.org/core/api/tangled" 15 + "tangled.org/core/knotserver/git" 16 + xrpcerr "tangled.org/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) { 20 + repo := r.URL.Query().Get("repo") 21 + repoPath, err := x.parseRepoParam(repo) 22 + if err != nil { 23 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 24 + return 25 + } 26 + 27 + ref := r.URL.Query().Get("ref") 28 + // ref can be empty (git.Open handles this) 29 + 30 + treePath := r.URL.Query().Get("path") 31 + if treePath == "" { 32 + writeError(w, xrpcerr.NewXrpcError( 33 + xrpcerr.WithTag("InvalidRequest"), 34 + xrpcerr.WithMessage("missing path parameter"), 35 + ), http.StatusBadRequest) 36 + return 37 + } 38 + 39 + raw := r.URL.Query().Get("raw") == "true" 40 + 41 + gr, err := git.Open(repoPath, ref) 42 + if err != nil { 43 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 44 + return 45 + } 46 + 47 + // first check if this path is a submodule 48 + submodule, err := gr.Submodule(treePath) 49 + if err != nil { 50 + // this is okay, continue and try to treat it as a regular file 51 + } else { 52 + response := tangled.RepoBlob_Output{ 53 + Ref: ref, 54 + Path: treePath, 55 + Submodule: &tangled.RepoBlob_Submodule{ 56 + Name: submodule.Name, 57 + Url: submodule.URL, 58 + Branch: &submodule.Branch, 59 + }, 60 + } 61 + x.writeJson(w, response) 62 + return 63 + } 64 + 65 + contents, err := gr.RawContent(treePath) 66 + if err != nil { 67 + x.Logger.Error("file content", "error", err.Error(), "treePath", treePath) 68 + writeError(w, xrpcerr.NewXrpcError( 69 + xrpcerr.WithTag("FileNotFound"), 70 + xrpcerr.WithMessage("file not found at the specified path"), 71 + ), http.StatusNotFound) 72 + return 73 + } 74 + 75 + mimeType := http.DetectContentType(contents) 76 + 77 + // override MIME types for formats that http.DetectContentType does not recognize 78 + switch filepath.Ext(treePath) { 79 + case ".svg": 80 + mimeType = "image/svg+xml" 81 + case ".avif": 82 + mimeType = "image/avif" 83 + case ".jxl": 84 + mimeType = "image/jxl" 85 + case ".heic", ".heif": 86 + mimeType = "image/heif" 87 + } 88 + 89 + if raw { 90 + contentHash := sha256.Sum256(contents) 91 + eTag := fmt.Sprintf("\"%x\"", contentHash) 92 + 93 + switch { 94 + case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 95 + if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 96 + w.WriteHeader(http.StatusNotModified) 97 + return 98 + } 99 + w.Header().Set("ETag", eTag) 100 + w.Header().Set("Content-Type", mimeType) 101 + 102 + case strings.HasPrefix(mimeType, "text/"): 103 + w.Header().Set("Cache-Control", "public, no-cache") 104 + // serve all text content as text/plain 105 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 106 + 107 + case isTextualMimeType(mimeType): 108 + // handle textual application types (json, xml, etc.) as text/plain 109 + w.Header().Set("Cache-Control", "public, no-cache") 110 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 111 + 112 + default: 113 + x.Logger.Error("attempted to serve disallowed file type", "mimetype", mimeType) 114 + writeError(w, xrpcerr.NewXrpcError( 115 + xrpcerr.WithTag("InvalidRequest"), 116 + xrpcerr.WithMessage("only image, video, and text files can be accessed directly"), 117 + ), http.StatusForbidden) 118 + return 119 + } 120 + w.Write(contents) 121 + return 122 + } 123 + 124 + isTextual := func(mt string) bool { 125 + return strings.HasPrefix(mt, "text/") || isTextualMimeType(mt) 126 + } 127 + 128 + var content string 129 + var encoding string 130 + 131 + isBinary := !isTextual(mimeType) 132 + size := int64(len(contents)) 133 + 134 + if isBinary { 135 + content = base64.StdEncoding.EncodeToString(contents) 136 + encoding = "base64" 137 + } else { 138 + content = string(contents) 139 + encoding = "utf-8" 140 + } 141 + 142 + response := tangled.RepoBlob_Output{ 143 + Ref: ref, 144 + Path: treePath, 145 + Content: &content, 146 + Encoding: &encoding, 147 + Size: &size, 148 + IsBinary: &isBinary, 149 + } 150 + 151 + if mimeType != "" { 152 + response.MimeType = &mimeType 153 + } 154 + 155 + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) 156 + defer cancel() 157 + 158 + lastCommit, err := gr.LastCommitFile(ctx, treePath) 159 + if err == nil && lastCommit != nil { 160 + response.LastCommit = &tangled.RepoBlob_LastCommit{ 161 + Hash: lastCommit.Hash.String(), 162 + Message: lastCommit.Message, 163 + When: lastCommit.When.Format(time.RFC3339), 164 + } 165 + 166 + // try to get author information 167 + commit, err := gr.Commit(lastCommit.Hash) 168 + if err == nil { 169 + response.LastCommit.Author = &tangled.RepoBlob_Signature{ 170 + Name: commit.Author.Name, 171 + Email: commit.Author.Email, 172 + } 173 + } 174 + } 175 + 176 + x.writeJson(w, response) 177 + } 178 + 179 + // isTextualMimeType returns true if the MIME type represents textual content 180 + // that should be served as text/plain for security reasons 181 + func isTextualMimeType(mimeType string) bool { 182 + textualTypes := []string{ 183 + "application/json", 184 + "application/xml", 185 + "application/yaml", 186 + "application/x-yaml", 187 + "application/toml", 188 + "application/javascript", 189 + "application/ecmascript", 190 + } 191 + 192 + return slices.Contains(textualTypes, mimeType) 193 + }
+85
internal/xrpc/repo_branch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "net/url" 6 + "time" 7 + 8 + "tangled.org/core/api/tangled" 9 + "tangled.org/core/knotserver/git" 10 + xrpcerr "tangled.org/core/xrpc/errors" 11 + ) 12 + 13 + func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) { 14 + repo := r.URL.Query().Get("repo") 15 + repoPath, err := x.parseRepoParam(repo) 16 + if err != nil { 17 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 18 + return 19 + } 20 + 21 + name := r.URL.Query().Get("name") 22 + if name == "" { 23 + writeError(w, xrpcerr.NewXrpcError( 24 + xrpcerr.WithTag("InvalidRequest"), 25 + xrpcerr.WithMessage("missing name parameter"), 26 + ), http.StatusBadRequest) 27 + return 28 + } 29 + 30 + branchName, _ := url.PathUnescape(name) 31 + 32 + gr, err := git.PlainOpen(repoPath) 33 + if err != nil { 34 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 35 + return 36 + } 37 + 38 + ref, err := gr.Branch(branchName) 39 + if err != nil { 40 + x.Logger.Error("getting branch", "error", err.Error()) 41 + writeError(w, xrpcerr.NewXrpcError( 42 + xrpcerr.WithTag("BranchNotFound"), 43 + xrpcerr.WithMessage("branch not found"), 44 + ), http.StatusNotFound) 45 + return 46 + } 47 + 48 + commit, err := gr.Commit(ref.Hash()) 49 + if err != nil { 50 + x.Logger.Error("getting commit object", "error", err.Error()) 51 + writeError(w, xrpcerr.NewXrpcError( 52 + xrpcerr.WithTag("BranchNotFound"), 53 + xrpcerr.WithMessage("failed to get commit object"), 54 + ), http.StatusInternalServerError) 55 + return 56 + } 57 + 58 + defaultBranch, err := gr.FindMainBranch() 59 + isDefault := false 60 + if err != nil { 61 + x.Logger.Error("getting default branch", "error", err.Error()) 62 + } else if defaultBranch == branchName { 63 + isDefault = true 64 + } 65 + 66 + response := tangled.RepoBranch_Output{ 67 + Name: ref.Name().Short(), 68 + Hash: ref.Hash().String(), 69 + ShortHash: &[]string{ref.Hash().String()[:7]}[0], 70 + When: commit.Author.When.Format(time.RFC3339), 71 + IsDefault: &isDefault, 72 + } 73 + 74 + if commit.Message != "" { 75 + response.Message = &commit.Message 76 + } 77 + 78 + response.Author = &tangled.RepoBranch_Signature{ 79 + Name: commit.Author.Name, 80 + Email: commit.Author.Email, 81 + When: commit.Author.When.Format(time.RFC3339), 82 + } 83 + 84 + x.writeJson(w, response) 85 + }
+49
internal/xrpc/repo_branches.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "tangled.org/core/knotserver/git" 8 + "tangled.org/core/types" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoBranches(w http.ResponseWriter, r *http.Request) { 13 + repo := r.URL.Query().Get("repo") 14 + repoPath, err := x.parseRepoParam(repo) 15 + if err != nil { 16 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 17 + return 18 + } 19 + 20 + // default 21 + limit := 50 22 + offset := 0 23 + 24 + if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 100 { 25 + limit = l 26 + } 27 + 28 + if o, err := strconv.Atoi(r.URL.Query().Get("cursor")); err == nil && o > 0 { 29 + offset = o 30 + } 31 + 32 + gr, err := git.PlainOpen(repoPath) 33 + if err != nil { 34 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 35 + return 36 + } 37 + 38 + branches, _ := gr.Branches(&git.BranchesOptions{ 39 + Limit: limit, 40 + Offset: offset, 41 + }) 42 + 43 + // Create response using existing types.RepoBranchesResponse 44 + response := types.RepoBranchesResponse{ 45 + Branches: branches, 46 + } 47 + 48 + x.writeJson(w, response) 49 + }
+103
internal/xrpc/repo_compare.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.org/core/knotserver/git" 9 + "tangled.org/core/types" 10 + xrpcerr "tangled.org/core/xrpc/errors" 11 + ) 12 + 13 + func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) { 14 + repo := r.URL.Query().Get("repo") 15 + repoPath, err := x.parseRepoParam(repo) 16 + if err != nil { 17 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 18 + return 19 + } 20 + 21 + rev1 := r.URL.Query().Get("rev1") 22 + if rev1 == "" { 23 + writeError(w, xrpcerr.NewXrpcError( 24 + xrpcerr.WithTag("InvalidRequest"), 25 + xrpcerr.WithMessage("missing rev1 parameter"), 26 + ), http.StatusBadRequest) 27 + return 28 + } 29 + 30 + rev2 := r.URL.Query().Get("rev2") 31 + if rev2 == "" { 32 + writeError(w, xrpcerr.NewXrpcError( 33 + xrpcerr.WithTag("InvalidRequest"), 34 + xrpcerr.WithMessage("missing rev2 parameter"), 35 + ), http.StatusBadRequest) 36 + return 37 + } 38 + 39 + gr, err := git.PlainOpen(repoPath) 40 + if err != nil { 41 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 42 + return 43 + } 44 + 45 + commit1, err := gr.ResolveRevision(rev1) 46 + if err != nil { 47 + x.Logger.Error("error resolving revision 1", "msg", err.Error()) 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("RevisionNotFound"), 50 + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev1)), 51 + ), http.StatusBadRequest) 52 + return 53 + } 54 + 55 + commit2, err := gr.ResolveRevision(rev2) 56 + if err != nil { 57 + x.Logger.Error("error resolving revision 2", "msg", err.Error()) 58 + writeError(w, xrpcerr.NewXrpcError( 59 + xrpcerr.WithTag("RevisionNotFound"), 60 + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev2)), 61 + ), http.StatusBadRequest) 62 + return 63 + } 64 + 65 + rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 66 + if err != nil { 67 + x.Logger.Error("error comparing revisions", "msg", err.Error()) 68 + writeError(w, xrpcerr.NewXrpcError( 69 + xrpcerr.WithTag("CompareError"), 70 + xrpcerr.WithMessage("error comparing revisions"), 71 + ), http.StatusBadRequest) 72 + return 73 + } 74 + 75 + var combinedPatch []*gitdiff.File 76 + var combinedPatchRaw string 77 + // we need the combined patch 78 + if len(formatPatch) >= 2 { 79 + mergeBaseCommit, err := gr.MergeBase(commit1, commit2) 80 + if err != nil { 81 + x.Logger.Error("error comparing revisions", "msg", err.Error()) 82 + } else { 83 + diffTree, err := gr.DiffTree(mergeBaseCommit, commit2) 84 + if err != nil { 85 + x.Logger.Error("error comparing revisions", "msg", err.Error()) 86 + } else { 87 + combinedPatch = diffTree.Diff 88 + combinedPatchRaw = diffTree.Patch 89 + } 90 + } 91 + } 92 + 93 + response := types.RepoFormatPatchResponse{ 94 + Rev1: commit1.Hash.String(), 95 + Rev2: commit2.Hash.String(), 96 + FormatPatch: formatPatch, 97 + FormatPatchRaw: rawPatch, 98 + CombinedPatch: combinedPatch, 99 + CombinedPatchRaw: combinedPatchRaw, 100 + } 101 + 102 + x.writeJson(w, response) 103 + }
+55
internal/xrpc/repo_describe_repo.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "errors" 5 + "net/http" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "tangled.org/core/api/tangled" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 + 11 + "github.com/isabelroses/snot/internal/forgejo" 12 + ) 13 + 14 + func (x *Xrpc) RepoDescribeRepo(w http.ResponseWriter, r *http.Request) { 15 + raw := r.URL.Query().Get("repoDid") 16 + repoDid, err := syntax.ParseDID(raw) 17 + if err != nil { 18 + writeError(w, xrpcerr.NewXrpcError( 19 + xrpcerr.WithTag("InvalidRequest"), 20 + xrpcerr.WithMessage("missing or invalid repoDid parameter"), 21 + ), http.StatusBadRequest) 22 + return 23 + } 24 + 25 + repo, _, err := x.Resolve.RepoFromParam(r.Context(), repoDid.String()) 26 + if errors.Is(err, forgejo.ErrNotFound) { 27 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNotFound) 28 + return 29 + } 30 + if err != nil { 31 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 32 + return 33 + } 34 + 35 + // The owner is the DID mapped to this repo's Forgejo user — not the 36 + // knot's admin DID, which governs the knot alone. 37 + ownerDid, ok := x.Resolve.DidFor(repo.OwnerName) 38 + if !ok { 39 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNotFound) 40 + return 41 + } 42 + 43 + // The record rkey is learned at adopt time; fall back to the repo name, 44 + // which matches records that use name-as-rkey. 45 + rkey, ok := x.Rkeys.RkeyByRepo(repo.Name) 46 + if !ok { 47 + rkey = repo.Name 48 + } 49 + 50 + x.writeJson(w, tangled.RepoDescribeRepo_Output{ 51 + RepoDid: repoDid.String(), 52 + OwnerDid: ownerDid, 53 + Rkey: rkey, 54 + }) 55 + }
+41
internal/xrpc/repo_diff.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + 6 + "tangled.org/core/knotserver/git" 7 + "tangled.org/core/types" 8 + xrpcerr "tangled.org/core/xrpc/errors" 9 + ) 10 + 11 + func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) { 12 + repo := r.URL.Query().Get("repo") 13 + repoPath, err := x.parseRepoParam(repo) 14 + if err != nil { 15 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 16 + return 17 + } 18 + 19 + ref := r.URL.Query().Get("ref") 20 + // ref can be empty (git.Open handles this) 21 + 22 + gr, err := git.Open(repoPath, ref) 23 + if err != nil { 24 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 25 + return 26 + } 27 + 28 + diff, err := gr.Diff() 29 + if err != nil { 30 + x.Logger.Error("getting diff", "error", err.Error()) 31 + writeError(w, xrpcerr.RefNotFoundError, http.StatusInternalServerError) 32 + return 33 + } 34 + 35 + response := types.RepoCommitResponse{ 36 + Ref: ref, 37 + Diff: diff, 38 + } 39 + 40 + x.writeJson(w, response) 41 + }
+39
internal/xrpc/repo_get_default_branch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "time" 6 + 7 + "tangled.org/core/api/tangled" 8 + "tangled.org/core/knotserver/git" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoGetDefaultBranch(w http.ResponseWriter, r *http.Request) { 13 + repo := r.URL.Query().Get("repo") 14 + repoPath, err := x.parseRepoParam(repo) 15 + if err != nil { 16 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 17 + return 18 + } 19 + 20 + gr, err := git.PlainOpen(repoPath) 21 + 22 + branch, err := gr.FindMainBranch() 23 + if err != nil { 24 + x.Logger.Error("getting default branch", "error", err.Error()) 25 + writeError(w, xrpcerr.NewXrpcError( 26 + xrpcerr.WithTag("InvalidRequest"), 27 + xrpcerr.WithMessage("failed to get default branch"), 28 + ), http.StatusInternalServerError) 29 + return 30 + } 31 + 32 + response := tangled.RepoGetDefaultBranch_Output{ 33 + Name: branch, 34 + Hash: "", 35 + When: time.UnixMicro(0).Format(time.RFC3339), 36 + } 37 + 38 + x.writeJson(w, response) 39 + }
+59
internal/xrpc/repo_languages.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "errors" 5 + "math" 6 + "net/http" 7 + 8 + "tangled.org/core/api/tangled" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 + 11 + "github.com/isabelroses/snot/internal/forgejo" 12 + ) 13 + 14 + func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) { 15 + repo, _, err := x.Resolve.RepoFromParam(r.Context(), r.URL.Query().Get("repo")) 16 + if errors.Is(err, forgejo.ErrNotFound) { 17 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNotFound) 18 + return 19 + } 20 + if err != nil { 21 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 22 + return 23 + } 24 + 25 + sizes, err := x.Store.Languages(r.Context(), repo.ID) 26 + if err != nil { 27 + x.Logger.Error("failed to read language stats", "error", err) 28 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 29 + return 30 + } 31 + 32 + var apiLanguages []*tangled.RepoLanguages_Language 33 + var totalSize int64 34 + for _, size := range sizes { 35 + totalSize += size 36 + } 37 + for name, size := range sizes { 38 + percentage := math.Round(float64(size) / float64(totalSize) * 100) 39 + apiLanguages = append(apiLanguages, &tangled.RepoLanguages_Language{ 40 + Name: name, 41 + Size: size, 42 + Percentage: int64(percentage), 43 + }) 44 + } 45 + 46 + // Forgejo's language_stat is computed for the default branch only; the 47 + // ref is echoed back for lexicon shape but does not select a revision. 48 + response := tangled.RepoLanguages_Output{ 49 + Ref: r.URL.Query().Get("ref"), 50 + Languages: apiLanguages, 51 + } 52 + if totalSize > 0 { 53 + response.TotalSize = &totalSize 54 + totalFiles := int64(len(sizes)) 55 + response.TotalFiles = &totalFiles 56 + } 57 + 58 + x.writeJson(w, response) 59 + }
+78
internal/xrpc/repo_log.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "tangled.org/core/knotserver/git" 8 + "tangled.org/core/types" 9 + xrpcerr "tangled.org/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoLog(w http.ResponseWriter, r *http.Request) { 13 + repo := r.URL.Query().Get("repo") 14 + repoPath, err := x.parseRepoParam(repo) 15 + if err != nil { 16 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 17 + return 18 + } 19 + 20 + ref := r.URL.Query().Get("ref") 21 + 22 + cursor := r.URL.Query().Get("cursor") 23 + 24 + limit := 50 // default 25 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 26 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 27 + limit = l 28 + } 29 + } 30 + 31 + gr, err := git.Open(repoPath, ref) 32 + if err != nil { 33 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 34 + return 35 + } 36 + 37 + offset := 0 38 + if cursor != "" { 39 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { 40 + offset = o 41 + } 42 + } 43 + 44 + commits, err := gr.Commits(offset, limit) 45 + if err != nil { 46 + x.Logger.Error("fetching commits", "error", err.Error()) 47 + writeError(w, xrpcerr.NewXrpcError( 48 + xrpcerr.WithTag("PathNotFound"), 49 + xrpcerr.WithMessage("failed to read commit log"), 50 + ), http.StatusNotFound) 51 + return 52 + } 53 + 54 + total, err := gr.TotalCommits() 55 + if err != nil { 56 + x.Logger.Error("fetching total commits", "error", err.Error()) 57 + writeError(w, xrpcerr.NewXrpcError( 58 + xrpcerr.WithTag("InternalServerError"), 59 + xrpcerr.WithMessage("failed to fetch total commits"), 60 + ), http.StatusNotFound) 61 + return 62 + } 63 + 64 + tcommits := make([]types.Commit, len(commits)) 65 + for i, c := range commits { 66 + tcommits[i].FromGoGitCommit(c) 67 + } 68 + 69 + // Create response using existing types.RepoLogResponse 70 + response := types.RepoLogResponse{ 71 + Commits: tcommits, 72 + Ref: ref, 73 + Page: (offset / limit) + 1, 74 + Total: total, 75 + } 76 + 77 + x.writeJson(w, response) 78 + }
+85
internal/xrpc/repo_tag.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + 7 + "github.com/go-git/go-git/v5/plumbing" 8 + "github.com/go-git/go-git/v5/plumbing/object" 9 + 10 + "tangled.org/core/knotserver/git" 11 + "tangled.org/core/types" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) RepoTag(w http.ResponseWriter, r *http.Request) { 16 + repo := r.URL.Query().Get("repo") 17 + repoPath, err := x.parseRepoParam(repo) 18 + if err != nil { 19 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 20 + return 21 + } 22 + 23 + tagName := r.URL.Query().Get("tag") 24 + if tagName == "" { 25 + writeError(w, xrpcerr.NewXrpcError( 26 + xrpcerr.WithTag("InvalidRequest"), 27 + xrpcerr.WithMessage("missing name parameter"), 28 + ), http.StatusBadRequest) 29 + return 30 + } 31 + 32 + gr, err := git.PlainOpen(repoPath) 33 + if err != nil { 34 + x.Logger.Error("failed to open", "error", err) 35 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 36 + return 37 + } 38 + 39 + // if this is not already formatted as refs/tags/v0.1.0, then format it 40 + if !plumbing.ReferenceName(tagName).IsTag() { 41 + tagName = plumbing.NewTagReferenceName(tagName).String() 42 + } 43 + 44 + tags, err := gr.Tags(&git.TagsOptions{ 45 + Pattern: tagName, 46 + }) 47 + 48 + if len(tags) != 1 { 49 + writeError(w, xrpcerr.NewXrpcError( 50 + xrpcerr.WithTag("TagNotFound"), 51 + xrpcerr.WithMessage(fmt.Sprintf("expected 1 tag to be returned, got %d tags", len(tags))), 52 + ), http.StatusBadRequest) 53 + return 54 + } 55 + 56 + tag := tags[0] 57 + 58 + if err != nil { 59 + x.Logger.Warn("getting tags", "error", err.Error()) 60 + tags = []object.Tag{} 61 + } 62 + 63 + var target *object.Tag 64 + if tag.Target != plumbing.ZeroHash { 65 + target = &tag 66 + } 67 + tr := types.TagReference{ 68 + Tag: target, 69 + } 70 + 71 + tr.Reference = types.Reference{ 72 + Name: tag.Name, 73 + Hash: tag.Hash.String(), 74 + } 75 + 76 + if tag.Message != "" { 77 + tr.Message = tag.Message 78 + } 79 + 80 + response := types.RepoTagResponse{ 81 + Tag: &tr, 82 + } 83 + 84 + x.writeJson(w, response) 85 + }
+79
internal/xrpc/repo_tags.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "github.com/go-git/go-git/v5/plumbing" 8 + "github.com/go-git/go-git/v5/plumbing/object" 9 + 10 + "tangled.org/core/knotserver/git" 11 + "tangled.org/core/types" 12 + xrpcerr "tangled.org/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) { 16 + repo := r.URL.Query().Get("repo") 17 + repoPath, err := x.parseRepoParam(repo) 18 + if err != nil { 19 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 20 + return 21 + } 22 + 23 + // default 24 + limit := 50 25 + offset := 0 26 + 27 + if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 100 { 28 + limit = l 29 + } 30 + 31 + if o, err := strconv.Atoi(r.URL.Query().Get("cursor")); err == nil && o > 0 { 32 + offset = o 33 + } 34 + 35 + gr, err := git.PlainOpen(repoPath) 36 + if err != nil { 37 + x.Logger.Error("failed to open", "error", err) 38 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 39 + return 40 + } 41 + 42 + tags, err := gr.Tags(&git.TagsOptions{ 43 + Limit: limit, 44 + Offset: offset, 45 + }) 46 + 47 + if err != nil { 48 + x.Logger.Warn("getting tags", "error", err.Error()) 49 + tags = []object.Tag{} 50 + } 51 + 52 + rtags := []*types.TagReference{} 53 + for _, tag := range tags { 54 + var target *object.Tag 55 + if tag.Target != plumbing.ZeroHash { 56 + target = &tag 57 + } 58 + tr := types.TagReference{ 59 + Tag: target, 60 + } 61 + 62 + tr.Reference = types.Reference{ 63 + Name: tag.Name, 64 + Hash: tag.Hash.String(), 65 + } 66 + 67 + if tag.Message != "" { 68 + tr.Message = tag.Message 69 + } 70 + 71 + rtags = append(rtags, &tr) 72 + } 73 + 74 + response := types.RepoTagsResponse{ 75 + Tags: rtags, 76 + } 77 + 78 + x.writeJson(w, response) 79 + }
+146
internal/xrpc/repo_tree.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "path/filepath" 6 + "time" 7 + "unicode/utf8" 8 + 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/pages/markup" 11 + "tangled.org/core/knotserver/git" 12 + "tangled.org/core/types" 13 + xrpcerr "tangled.org/core/xrpc/errors" 14 + ) 15 + 16 + func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) { 17 + ctx := r.Context() 18 + 19 + repo := r.URL.Query().Get("repo") 20 + repoPath, err := x.parseRepoParam(repo) 21 + if err != nil { 22 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 23 + return 24 + } 25 + 26 + ref := r.URL.Query().Get("ref") 27 + // ref can be empty (git.Open handles this) 28 + 29 + path := r.URL.Query().Get("path") 30 + // path can be empty (defaults to root) 31 + 32 + gr, err := git.Open(repoPath, ref) 33 + if err != nil { 34 + x.Logger.Error("failed to open git repository", "error", err, "path", repoPath, "ref", ref) 35 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 36 + return 37 + } 38 + 39 + files, err := gr.FileTree(ctx, path) 40 + if err != nil { 41 + x.Logger.Error("failed to get file tree", "error", err, "path", path) 42 + writeError(w, xrpcerr.NewXrpcError( 43 + xrpcerr.WithTag("PathNotFound"), 44 + xrpcerr.WithMessage("failed to read repository tree"), 45 + ), http.StatusNotFound) 46 + return 47 + } 48 + 49 + // if any of these files are a readme candidate, pass along its blob contents too 50 + var readmeFileName string 51 + var readmeContents string 52 + for _, file := range files { 53 + if markup.IsReadmeFile(file.Name, file.Mode) { 54 + contents, err := gr.RawContent(filepath.Join(path, file.Name)) 55 + if err != nil { 56 + x.Logger.Error("failed to read contents of file", "path", path, "file", file.Name) 57 + } 58 + 59 + if utf8.Valid(contents) { 60 + readmeFileName = file.Name 61 + readmeContents = string(contents) 62 + break 63 + } 64 + } 65 + } 66 + 67 + // convert NiceTree -> tangled.RepoTree_TreeEntry 68 + treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 69 + for i, file := range files { 70 + entry := &tangled.RepoTree_TreeEntry{ 71 + Name: file.Name, 72 + Mode: file.Mode, 73 + Size: file.Size, 74 + } 75 + 76 + if file.LastCommit != nil { 77 + entry.Last_commit = &tangled.RepoTree_LastCommit{ 78 + Hash: file.LastCommit.Hash.String(), 79 + Message: file.LastCommit.Message, 80 + When: file.LastCommit.When.Format(time.RFC3339), 81 + } 82 + } 83 + 84 + treeEntries[i] = entry 85 + } 86 + 87 + var parentPtr *string 88 + if path != "" { 89 + parentPtr = &path 90 + } 91 + 92 + var dotdotPtr *string 93 + if path != "" { 94 + dotdot := filepath.Dir(path) 95 + if dotdot != "." { 96 + dotdotPtr = &dotdot 97 + } 98 + } 99 + 100 + response := tangled.RepoTree_Output{ 101 + Ref: ref, 102 + Parent: parentPtr, 103 + Dotdot: dotdotPtr, 104 + Files: treeEntries, 105 + Readme: &tangled.RepoTree_Readme{ 106 + Filename: readmeFileName, 107 + Contents: readmeContents, 108 + }, 109 + } 110 + 111 + // calculate lastCommit for the directory as a whole 112 + var lastCommitTree *types.LastCommitInfo 113 + for _, e := range files { 114 + if e.LastCommit == nil { 115 + continue 116 + } 117 + 118 + if lastCommitTree == nil { 119 + lastCommitTree = e.LastCommit 120 + continue 121 + } 122 + 123 + if lastCommitTree.When.After(e.LastCommit.When) { 124 + lastCommitTree = e.LastCommit 125 + } 126 + } 127 + 128 + if lastCommitTree != nil { 129 + response.LastCommit = &tangled.RepoTree_LastCommit{ 130 + Hash: lastCommitTree.Hash.String(), 131 + Message: lastCommitTree.Message, 132 + When: lastCommitTree.When.Format(time.RFC3339), 133 + } 134 + 135 + // try to get author information 136 + commit, err := gr.Commit(lastCommitTree.Hash) 137 + if err == nil { 138 + response.LastCommit.Author = &tangled.RepoTree_Signature{ 139 + Name: commit.Author.Name, 140 + Email: commit.Author.Email, 141 + } 142 + } 143 + } 144 + 145 + x.writeJson(w, response) 146 + }
+52
internal/xrpc/version.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "runtime/debug" 7 + 8 + "tangled.org/core/api/tangled" 9 + ) 10 + 11 + // version is set during build time. 12 + var version string 13 + 14 + func (x *Xrpc) Version(w http.ResponseWriter, r *http.Request) { 15 + if version == "" { 16 + info, ok := debug.ReadBuildInfo() 17 + if !ok { 18 + http.Error(w, "failed to read build info", http.StatusInternalServerError) 19 + return 20 + } 21 + 22 + modVer := info.Main.Version 23 + if modVer == "" || modVer == "(devel)" { 24 + modVer = "(devel)" 25 + } 26 + 27 + var sha string 28 + var modified bool 29 + for _, setting := range info.Settings { 30 + switch setting.Key { 31 + case "vcs.revision": 32 + sha = setting.Value 33 + case "vcs.modified": 34 + modified = setting.Value == "true" 35 + } 36 + } 37 + 38 + if sha == "" { 39 + version = modVer 40 + } else if modified { 41 + version = fmt.Sprintf("%s (%s with modifications)", modVer, sha) 42 + } else { 43 + version = fmt.Sprintf("%s (%s)", modVer, sha) 44 + } 45 + } 46 + 47 + response := tangled.KnotVersion_Output{ 48 + Version: version, 49 + } 50 + 51 + x.writeJson(w, response) 52 + }
+136
internal/xrpc/xrpc.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "errors" 8 + "log/slog" 9 + "net/http" 10 + 11 + "github.com/go-chi/chi/v5" 12 + "tangled.org/core/api/tangled" 13 + xrpcerr "tangled.org/core/xrpc/errors" 14 + "tangled.org/core/xrpc/serviceauth" 15 + 16 + "github.com/isabelroses/snot/internal/config" 17 + "github.com/isabelroses/snot/internal/forgejo" 18 + "github.com/isabelroses/snot/internal/resolve" 19 + "github.com/isabelroses/snot/internal/state" 20 + ) 21 + 22 + const maxResponseKB = 5120 23 + 24 + type Xrpc struct { 25 + Cfg *config.Config 26 + Store forgejo.Store 27 + Resolve *resolve.Resolver 28 + Rkeys *state.RkeyMap 29 + Logger *slog.Logger 30 + ServiceAuth *serviceauth.ServiceAuth // nil in tests: auth middleware skipped 31 + } 32 + 33 + func (x *Xrpc) Router() http.Handler { 34 + r := chi.NewRouter() 35 + 36 + r.Group(func(r chi.Router) { 37 + if x.ServiceAuth != nil { 38 + r.Use(x.ServiceAuth.VerifyServiceAuth) 39 + } 40 + r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 41 + }) 42 + 43 + // writes the shim does not support 44 + for _, nsid := range []string{ 45 + tangled.RepoSetDefaultBranchNSID, 46 + tangled.RepoDeleteBranchNSID, 47 + tangled.RepoDeleteNSID, 48 + tangled.RepoForkStatusNSID, 49 + tangled.RepoForkSyncNSID, 50 + tangled.RepoHiddenRefNSID, 51 + tangled.RepoMergeNSID, 52 + tangled.RepoMergeCheckNSID, 53 + } { 54 + r.Post("/"+nsid, x.notImplemented) 55 + } 56 + 57 + // repo query endpoints (no auth required) 58 + r.Get("/"+tangled.RepoTreeNSID, x.RepoTree) 59 + r.Get("/"+tangled.RepoLogNSID, x.RepoLog) 60 + r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches) 61 + r.Get("/"+tangled.RepoTagsNSID, x.RepoTags) 62 + r.Get("/"+tangled.RepoTagNSID, x.RepoTag) 63 + r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob) 64 + r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff) 65 + r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare) 66 + r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch) 67 + r.Get("/"+tangled.RepoDescribeRepoNSID, x.RepoDescribeRepo) 68 + r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch) 69 + r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive) 70 + r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages) 71 + 72 + // knot/service query endpoints (no auth required) 73 + r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys) 74 + r.Get("/"+tangled.KnotVersionNSID, x.Version) 75 + r.Get("/"+tangled.OwnerNSID, x.Owner) 76 + 77 + return r 78 + } 79 + 80 + func (x *Xrpc) notImplemented(w http.ResponseWriter, r *http.Request) { 81 + writeError(w, xrpcerr.NewXrpcError( 82 + xrpcerr.WithTag("MethodNotImplemented"), 83 + xrpcerr.WithMessage("this knot is a read-only Forgejo shim; use Forgejo directly"), 84 + ), http.StatusNotImplemented) 85 + } 86 + 87 + // parseRepoParam resolves the `repo` query parameter (ownerDid/name, 88 + // ownerDid/rkey, or bare repo DID) to an on-disk repo path. Signature matches 89 + // upstream so handler files copy verbatim. 90 + func (x *Xrpc) parseRepoParam(repo string) (string, error) { 91 + _, path, err := x.Resolve.RepoFromParam(context.Background(), repo) 92 + if errors.Is(err, forgejo.ErrNotFound) { 93 + return "", xrpcerr.RepoNotFoundError 94 + } 95 + if err != nil { 96 + return "", xrpcerr.GenericError(err) 97 + } 98 + return path, nil 99 + } 100 + 101 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 102 + w.Header().Set("Content-Type", "application/json") 103 + w.WriteHeader(status) 104 + json.NewEncoder(w).Encode(e) 105 + } 106 + 107 + type limitWriter struct { 108 + buf bytes.Buffer 109 + limit int 110 + written int 111 + } 112 + 113 + var errResponseTooLarge = errors.New("response too large") 114 + 115 + func (lw *limitWriter) Write(p []byte) (int, error) { 116 + if lw.written+len(p) > lw.limit { 117 + return 0, errResponseTooLarge 118 + } 119 + n, err := lw.buf.Write(p) 120 + lw.written += n 121 + return n, err 122 + } 123 + 124 + func (x *Xrpc) writeJson(w http.ResponseWriter, response any) { 125 + lw := &limitWriter{limit: maxResponseKB * 1024} 126 + if err := json.NewEncoder(lw).Encode(response); err != nil { 127 + if errors.Is(err, errResponseTooLarge) { 128 + writeError(w, xrpcerr.RequestTooLargeError, http.StatusRequestEntityTooLarge) 129 + } else { 130 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 131 + } 132 + return 133 + } 134 + w.Header().Set("Content-Type", "application/json") 135 + w.Write(lw.buf.Bytes()) 136 + }
+92
internal/xrpc/xrpc_test.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "net/http/httptest" 6 + "testing" 7 + 8 + "log/slog" 9 + 10 + "github.com/isabelroses/snot/internal/config" 11 + "github.com/isabelroses/snot/internal/forgejo" 12 + "github.com/isabelroses/snot/internal/resolve" 13 + "github.com/isabelroses/snot/internal/state" 14 + "github.com/isabelroses/snot/internal/testutil" 15 + ) 16 + 17 + // newXrpc builds an Xrpc over a fixture repo; reused by later handler tests. 18 + func newXrpc(t *testing.T) *Xrpc { 19 + t.Helper() 20 + root := testutil.FixtureRepo(t, "isabel", "demo") 21 + rkeys, err := state.LoadRkeyMap(t.TempDir()) 22 + if err != nil { 23 + t.Fatal(err) 24 + } 25 + dids, err := state.LoadRepoDids(t.TempDir()) 26 + if err != nil { 27 + t.Fatal(err) 28 + } 29 + if err := dids.Put("did:plc:repo123", state.RepoDidInfo{User: "isabel", Repo: "demo", Key: []byte("k")}); err != nil { 30 + t.Fatal(err) 31 + } 32 + cfg := &config.Config{ 33 + Hostname: "knot.example.com", 34 + OwnerDid: "did:plc:knotadmin", 35 + UserMap: map[string]string{"did:plc:owner123": "isabel"}, 36 + RepoRoot: root, 37 + } 38 + store := &testutil.StubStore{ 39 + Repos: map[string]*forgejo.Repo{ 40 + "isabel/demo": testutil.StubRepo(1, "isabel", "demo"), 41 + }, 42 + Langs: map[int64]map[string]int64{1: {"Go": 1000, "Nix": 500}}, 43 + Keys: []forgejo.PublicKey{{Key: "ssh-ed25519 AAAA test"}}, 44 + } 45 + rs := &resolve.Resolver{ 46 + Cfg: cfg, 47 + Store: store, 48 + Rkeys: rkeys, 49 + Dids: dids, 50 + MintDid: func(ctx context.Context) (string, []byte, error) { 51 + return "did:plc:minted1", []byte("k"), nil 52 + }, 53 + } 54 + return &Xrpc{ 55 + Cfg: cfg, 56 + Store: store, 57 + Resolve: rs, 58 + Rkeys: rkeys, 59 + Logger: slog.Default(), 60 + } 61 + } 62 + 63 + func TestParseRepoParam(t *testing.T) { 64 + x := newXrpc(t) 65 + if _, err := x.parseRepoParam("did:plc:owner123/demo"); err != nil { 66 + t.Errorf("did/name form: %v", err) 67 + } 68 + if _, err := x.parseRepoParam("did:plc:repo123"); err != nil { 69 + t.Errorf("bare repoDid form: %v", err) 70 + } 71 + if _, err := x.parseRepoParam("did:plc:owner123/nope"); err == nil { 72 + t.Error("expected error for unknown repo") 73 + } 74 + if _, err := x.parseRepoParam("plainstring"); err == nil { 75 + t.Error("expected error for non-DID") 76 + } 77 + } 78 + 79 + func TestRouterNotImplemented(t *testing.T) { 80 + x := newXrpc(t) 81 + srv := httptest.NewServer(x.Router()) 82 + defer srv.Close() 83 + 84 + res, err := srv.Client().Post(srv.URL+"/sh.tangled.repo.delete", "application/json", nil) 85 + if err != nil { 86 + t.Fatal(err) 87 + } 88 + defer res.Body.Close() 89 + if res.StatusCode != 501 { 90 + t.Errorf("delete status = %d, want 501", res.StatusCode) 91 + } 92 + }
+161
nix/module.nix
··· 1 + { 2 + config, 3 + lib, 4 + pkgs, 5 + ... 6 + }: 7 + let 8 + cfg = config.services.snot; 9 + 10 + inherit (lib) 11 + getExe 12 + mkEnableOption 13 + mkIf 14 + mkOption 15 + mkPackageOption 16 + types 17 + ; 18 + in 19 + { 20 + options.services.snot = { 21 + enable = mkEnableOption "snot, a tangled knot backed by Forgejo"; 22 + 23 + package = mkPackageOption pkgs "snot" { }; 24 + 25 + settings = mkOption { 26 + description = '' 27 + Environment variables to set for the service. Secrets should be 28 + specified using {option}`environmentFiles`. 29 + ''; 30 + type = types.submodule { 31 + freeformType = types.attrsOf (types.nullOr types.str); 32 + options = { 33 + SNOT_HOSTNAME = mkOption { 34 + type = types.str; 35 + example = "knot.example.com"; 36 + description = "Public hostname of the shim (the knot domain)."; 37 + }; 38 + 39 + SNOT_LISTEN_ADDR = mkOption { 40 + type = types.str; 41 + default = "0.0.0.0:5555"; 42 + description = "Address for the shim to listen on."; 43 + }; 44 + 45 + SNOT_OWNER_DID = mkOption { 46 + type = types.str; 47 + example = "did:plc:abc123"; 48 + description = "atproto DID of the knot owner; governs the knot alone, not repos."; 49 + }; 50 + 51 + SNOT_USER_MAP = mkOption { 52 + type = types.str; 53 + example = "did:plc:abc123=isabel,did:plc:def456=alice"; 54 + description = "Comma-separated did=user pairs mapping repo-owner DIDs to Forgejo users."; 55 + }; 56 + 57 + SNOT_DB_DSN = mkOption { 58 + type = types.str; 59 + default = "postgres://snot@/forgejo?host=/run/postgresql"; 60 + description = '' 61 + Postgres DSN for the Forgejo database. The default uses 62 + unix-socket peer auth; create a `snot` role with 63 + SELECT grants on `"user"`, `repository`, `language_stat`, and 64 + `public_key`. 65 + ''; 66 + }; 67 + 68 + SNOT_REPO_ROOT = mkOption { 69 + type = types.str; 70 + default = "/var/lib/forgejo/repositories"; 71 + description = "Forgejo's repository storage directory."; 72 + }; 73 + 74 + SNOT_PUSH_REMOTE = mkOption { 75 + type = types.nullOr types.str; 76 + default = null; 77 + example = "git@git.example.com"; 78 + description = "SSH remote base suggested when an HTTP push is rejected."; 79 + }; 80 + 81 + SNOT_STATE_DIR = mkOption { 82 + type = types.str; 83 + default = "/var/lib/snot"; 84 + description = "Directory holding the signing key and rkey map."; 85 + }; 86 + 87 + SNOT_PLC_URL = mkOption { 88 + type = types.str; 89 + default = "https://plc.directory"; 90 + description = "URL of the DID PLC directory."; 91 + }; 92 + }; 93 + }; 94 + }; 95 + 96 + forgejoGroup = mkOption { 97 + type = types.str; 98 + default = "forgejo"; 99 + description = "Group with read access to the repository root."; 100 + }; 101 + 102 + environmentFiles = mkOption { 103 + type = types.listOf types.path; 104 + default = [ ]; 105 + description = '' 106 + Files to load environment variables from. Loaded variables override 107 + values set in {option}`settings`; use them for secrets such as a 108 + `SNOT_DB_DSN` containing a password. 109 + ''; 110 + }; 111 + }; 112 + 113 + config = mkIf cfg.enable { 114 + systemd.services.snot = { 115 + description = "tangled knot shim backed by Forgejo"; 116 + wantedBy = [ "multi-user.target" ]; 117 + after = [ 118 + "network.target" 119 + "postgresql.service" 120 + ]; 121 + path = [ pkgs.git ]; 122 + 123 + serviceConfig = { 124 + ExecStart = "${getExe cfg.package} serve"; 125 + Environment = lib.mapAttrsToList (k: v: "${k}=${v}") ( 126 + lib.filterAttrs (_: v: v != null) cfg.settings 127 + ); 128 + EnvironmentFile = cfg.environmentFiles; 129 + 130 + DynamicUser = true; 131 + SupplementaryGroups = [ cfg.forgejoGroup ]; 132 + StateDirectory = "snot"; 133 + StateDirectoryMode = "0700"; 134 + 135 + Restart = "on-failure"; 136 + 137 + # hardening 138 + NoNewPrivileges = true; 139 + PrivateTmp = true; 140 + PrivateDevices = true; 141 + ProtectSystem = "strict"; 142 + ProtectHome = true; 143 + ReadOnlyPaths = [ cfg.settings.SNOT_REPO_ROOT ]; 144 + ProtectKernelTunables = true; 145 + ProtectKernelModules = true; 146 + ProtectControlGroups = true; 147 + RestrictAddressFamilies = [ 148 + "AF_UNIX" 149 + "AF_INET" 150 + "AF_INET6" 151 + ]; 152 + RestrictNamespaces = true; 153 + LockPersonality = true; 154 + MemoryDenyWriteExecute = true; 155 + RestrictRealtime = true; 156 + SystemCallArchitectures = "native"; 157 + SystemCallFilter = [ "@system-service" ]; 158 + }; 159 + }; 160 + }; 161 + }
+1
readme.md
··· 1 + i cba rn
+21
shell.nix
··· 1 + { 2 + mkShell, 3 + callPackage, 4 + 5 + # extra tooling 6 + go, 7 + gopls, 8 + goreleaser, 9 + }: 10 + let 11 + defaultPackage = callPackage ./default.nix { }; 12 + in 13 + mkShell { 14 + inputsFrom = [ defaultPackage ]; 15 + 16 + packages = [ 17 + go 18 + gopls 19 + goreleaser 20 + ]; 21 + }