diff options
| -rw-r--r-- | cmd/git-lfs-server/main.go | 329 | ||||
| -rw-r--r-- | go.mod | 21 | ||||
| -rw-r--r-- | go.sum | 51 |
3 files changed, 401 insertions, 0 deletions
diff --git a/cmd/git-lfs-server/main.go b/cmd/git-lfs-server/main.go index 06ab7d0..c5b47a3 100644 --- a/cmd/git-lfs-server/main.go +++ b/cmd/git-lfs-server/main.go | |||
| @@ -1 +1,330 @@ | |||
| 1 | package main | 1 | package main |
| 2 | |||
| 3 | import ( | ||
| 4 | "context" | ||
| 5 | "encoding/json" | ||
| 6 | "errors" | ||
| 7 | "fmt" | ||
| 8 | "net/http" | ||
| 9 | "net/http/cgi" | ||
| 10 | "net/url" | ||
| 11 | "os" | ||
| 12 | "os/exec" | ||
| 13 | "path" | ||
| 14 | "regexp" | ||
| 15 | "slices" | ||
| 16 | "strconv" | ||
| 17 | "strings" | ||
| 18 | "time" | ||
| 19 | "unicode" | ||
| 20 | |||
| 21 | "github.com/minio/minio-go/v7" | ||
| 22 | "github.com/minio/minio-go/v7/pkg/credentials" | ||
| 23 | ) | ||
| 24 | |||
| 25 | type operation string | ||
| 26 | type transferAdapter string | ||
| 27 | type hashAlgo string | ||
| 28 | |||
| 29 | const ( | ||
| 30 | operationDownload operation = "download" | ||
| 31 | operationUpload operation = "upload" | ||
| 32 | transferAdapterBasic transferAdapter = "basic" | ||
| 33 | hashAlgoSHA256 hashAlgo = "sha256" | ||
| 34 | ) | ||
| 35 | |||
| 36 | const lfsMIME = "application/vnd.git-lfs+json" | ||
| 37 | |||
| 38 | type batchRef struct { | ||
| 39 | Name string `json:"name"` | ||
| 40 | } | ||
| 41 | |||
| 42 | type batchRequestObject struct { | ||
| 43 | OID string `json:"oid"` | ||
| 44 | Size uint64 `json:"size"` | ||
| 45 | } | ||
| 46 | |||
| 47 | type batchRequest struct { | ||
| 48 | Operation operation `json:"operation"` | ||
| 49 | Transfers []transferAdapter `json:"transfers,omitempty"` | ||
| 50 | Ref *batchRef `json:"ref,omitempty"` | ||
| 51 | Objects []batchRequestObject `json:"objects"` | ||
| 52 | HashAlgo hashAlgo `json:"hash_algo,omitempty"` | ||
| 53 | } | ||
| 54 | |||
| 55 | type RFC3339SecondsTime time.Time | ||
| 56 | |||
| 57 | func (t RFC3339SecondsTime) MarshalJSON() ([]byte, error) { | ||
| 58 | b := make([]byte, 0, len(time.RFC3339)+len(`""`)) | ||
| 59 | b = append(b, '"') | ||
| 60 | b = time.Time(t).AppendFormat(b, time.RFC3339) | ||
| 61 | b = append(b, '"') | ||
| 62 | return b, nil | ||
| 63 | } | ||
| 64 | |||
| 65 | type SecondDuration time.Duration | ||
| 66 | |||
| 67 | func (d SecondDuration) MarshalJSON() ([]byte, error) { | ||
| 68 | var b []byte | ||
| 69 | b = strconv.AppendInt(b, int64(time.Duration(d).Seconds()), 10) | ||
| 70 | return b, nil | ||
| 71 | } | ||
| 72 | |||
| 73 | type batchAction struct { | ||
| 74 | HRef *url.URL `json:"href"` | ||
| 75 | Header map[string]string `json:"header,omitempty"` | ||
| 76 | ExpiresIn *SecondDuration `json:"expires_in,omitempty"` | ||
| 77 | ExpiresAt *RFC3339SecondsTime `json:"expires_at,omitempty"` | ||
| 78 | } | ||
| 79 | |||
| 80 | type batchError struct { | ||
| 81 | Code int `json:"code"` | ||
| 82 | Message string `json:"message"` | ||
| 83 | } | ||
| 84 | |||
| 85 | type batchResponseObject struct { | ||
| 86 | OID string `json:"oid"` | ||
| 87 | Size uint64 `json:"size"` | ||
| 88 | Authenticated *bool `json:"authenticated"` | ||
| 89 | Actions map[operation]batchAction `json:"actions,omitempty"` | ||
| 90 | Error *batchError `json:"error,omitempty"` | ||
| 91 | } | ||
| 92 | |||
| 93 | type batchResponse struct { | ||
| 94 | Transfer transferAdapter `json:"transfer,omitempty"` | ||
| 95 | Objects []batchResponseObject `json:"objects"` | ||
| 96 | HashAlgo hashAlgo `json:"hash_algo,omitempty"` | ||
| 97 | } | ||
| 98 | |||
| 99 | var re = regexp.MustCompile(`^([a-zA-Z0-9-_/]+)\.git/info/lfs/objects/batch$`) | ||
| 100 | |||
| 101 | type handler struct { | ||
| 102 | mc *minio.Client | ||
| 103 | bucket string | ||
| 104 | anonUser string | ||
| 105 | } | ||
| 106 | |||
| 107 | // Requires lowercase hash | ||
| 108 | func isValidSHA256Hash(hash string) bool { | ||
| 109 | if len(hash) != 64 { | ||
| 110 | return false | ||
| 111 | } | ||
| 112 | for _, c := range hash { | ||
| 113 | if !unicode.Is(unicode.ASCII_Hex_Digit, c) { | ||
| 114 | return false | ||
| 115 | } | ||
| 116 | } | ||
| 117 | return true | ||
| 118 | } | ||
| 119 | |||
| 120 | type lfsError struct { | ||
| 121 | Message string `json:"message"` | ||
| 122 | DocumentationURL string `json:"documentation_url,omitempty"` | ||
| 123 | RequestID string `json:"request_id,omitempty"` | ||
| 124 | } | ||
| 125 | |||
| 126 | func makeRespError(w http.ResponseWriter, message string, code int) { | ||
| 127 | w.Header().Set("Content-Type", lfsMIME) | ||
| 128 | w.WriteHeader(code) | ||
| 129 | json.NewEncoder(w).Encode(lfsError{Message: message}) | ||
| 130 | } | ||
| 131 | |||
| 132 | func makeObjError(obj parsedBatchObject, message string, code int) batchResponseObject { | ||
| 133 | return batchResponseObject{ | ||
| 134 | OID: obj.fullHash, | ||
| 135 | Size: obj.size, | ||
| 136 | Error: &batchError{ | ||
| 137 | Message: message, | ||
| 138 | Code: code, | ||
| 139 | }, | ||
| 140 | } | ||
| 141 | } | ||
| 142 | |||
| 143 | func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj parsedBatchObject) batchResponseObject { | ||
| 144 | fullPath := path.Join(repo, obj.firstByte, obj.secondByte, obj.fullHash) | ||
| 145 | expiresIn := time.Hour * 24 | ||
| 146 | expiresInSeconds := SecondDuration(expiresIn) | ||
| 147 | |||
| 148 | info, err := h.mc.StatObject(ctx, h.bucket, fullPath, minio.StatObjectOptions{Checksum: true}) | ||
| 149 | if err != nil { | ||
| 150 | var resp minio.ErrorResponse | ||
| 151 | if errors.As(err, &resp) && resp.StatusCode == http.StatusNotFound { | ||
| 152 | return makeObjError(obj, "Object does not exist", http.StatusNotFound) | ||
| 153 | } | ||
| 154 | // TODO: consider not making this an object-specific, but rather a | ||
| 155 | // generic error such that the entire Batch API request fails. | ||
| 156 | return makeObjError(obj, "Failed to query object information", http.StatusInternalServerError) | ||
| 157 | } | ||
| 158 | if info.ChecksumSHA256 != "" && strings.ToLower(info.ChecksumSHA256) != obj.fullHash { | ||
| 159 | return makeObjError(obj, "Corrupted file", http.StatusUnprocessableEntity) | ||
| 160 | } | ||
| 161 | if uint64(info.Size) != obj.size { | ||
| 162 | return makeObjError(obj, "Incorrect size specified for object", http.StatusUnprocessableEntity) | ||
| 163 | } | ||
| 164 | |||
| 165 | presigned, err := h.mc.PresignedGetObject(ctx, h.bucket, fullPath, expiresIn, url.Values{}) | ||
| 166 | if err != nil { | ||
| 167 | // TODO: consider not making this an object-specific, but rather a | ||
| 168 | // generic error such that the entire Batch API request fails. | ||
| 169 | return makeObjError(obj, "Failed to generate action href", http.StatusInternalServerError) | ||
| 170 | } | ||
| 171 | |||
| 172 | authenticated := true | ||
| 173 | return batchResponseObject{ | ||
| 174 | OID: obj.fullHash, | ||
| 175 | Size: obj.size, | ||
| 176 | Authenticated: &authenticated, | ||
| 177 | Actions: map[operation]batchAction{ | ||
| 178 | operationDownload: { | ||
| 179 | HRef: presigned, | ||
| 180 | ExpiresIn: &expiresInSeconds, | ||
| 181 | }, | ||
| 182 | }, | ||
| 183 | } | ||
| 184 | } | ||
| 185 | |||
| 186 | type parsedBatchObject struct { | ||
| 187 | firstByte string | ||
| 188 | secondByte string | ||
| 189 | fullHash string | ||
| 190 | size uint64 | ||
| 191 | } | ||
| 192 | |||
| 193 | func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
| 194 | submatches := re.FindStringSubmatch(r.URL.Path) | ||
| 195 | if len(submatches) != 1 { | ||
| 196 | makeRespError(w, "Not found", http.StatusNotFound) | ||
| 197 | return | ||
| 198 | } | ||
| 199 | repo := strings.TrimPrefix("/", path.Clean(submatches[0])) | ||
| 200 | |||
| 201 | if !slices.Contains(r.Header.Values("Accept"), lfsMIME) { | ||
| 202 | makeRespError(w, "Expected "+lfsMIME+" in list of acceptable response media types", http.StatusNotAcceptable) | ||
| 203 | return | ||
| 204 | } | ||
| 205 | if r.Header.Get("Content-Type") != lfsMIME { | ||
| 206 | makeRespError(w, "Expected request Content-Type to be "+lfsMIME, http.StatusUnsupportedMediaType) | ||
| 207 | return | ||
| 208 | } | ||
| 209 | |||
| 210 | var body batchRequest | ||
| 211 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { | ||
| 212 | makeRespError(w, "Failed to parse request body as JSON", http.StatusBadRequest) | ||
| 213 | return | ||
| 214 | } | ||
| 215 | |||
| 216 | if body.HashAlgo != hashAlgoSHA256 { | ||
| 217 | makeRespError(w, "Unsupported hash algorithm specified", http.StatusConflict) | ||
| 218 | return | ||
| 219 | } | ||
| 220 | |||
| 221 | // TODO: handle authentication | ||
| 222 | // right now, we're just trying to make everything publically accessible | ||
| 223 | if body.Operation == operationUpload { | ||
| 224 | makeRespError(w, "Upload operations are currently not supported", http.StatusForbidden) | ||
| 225 | return | ||
| 226 | } | ||
| 227 | |||
| 228 | if len(body.Transfers) != 0 && !slices.Contains(body.Transfers, transferAdapterBasic) { | ||
| 229 | makeRespError(w, "Unsupported transfer adapter specified (supported: basic)", http.StatusConflict) | ||
| 230 | return | ||
| 231 | } | ||
| 232 | |||
| 233 | gitoliteArgs := []string{"access", "-q", repo, h.anonUser, "R"} | ||
| 234 | if body.Ref != nil && body.Ref.Name != "" { | ||
| 235 | gitoliteArgs = append(gitoliteArgs, body.Ref.Name) | ||
| 236 | } | ||
| 237 | cmd := exec.Command("gitolite", gitoliteArgs...) | ||
| 238 | err := cmd.Run() | ||
| 239 | permGranted := err == nil | ||
| 240 | var exitErr *exec.ExitError | ||
| 241 | if err != nil && !errors.As(err, &exitErr) { | ||
| 242 | makeRespError(w, "Failed to query access information", http.StatusInternalServerError) | ||
| 243 | return | ||
| 244 | } | ||
| 245 | if !permGranted { | ||
| 246 | // TODO: when handling authorization, make sure to return 403 Forbidden | ||
| 247 | // here when the user *does* have read permissions, but is not allowed | ||
| 248 | // to write when requesting an upload operation. | ||
| 249 | makeRespError(w, "Repository not found", http.StatusNotFound) | ||
| 250 | return | ||
| 251 | } | ||
| 252 | |||
| 253 | var objects []parsedBatchObject | ||
| 254 | for _, obj := range body.Objects { | ||
| 255 | oid := strings.ToLower(obj.OID) | ||
| 256 | if !isValidSHA256Hash(oid) { | ||
| 257 | makeRespError(w, "Invalid hash format in object ID", http.StatusBadRequest) | ||
| 258 | return | ||
| 259 | } | ||
| 260 | objects = append(objects, parsedBatchObject{ | ||
| 261 | firstByte: oid[:2], | ||
| 262 | secondByte: oid[2:4], | ||
| 263 | fullHash: oid, | ||
| 264 | size: obj.Size, | ||
| 265 | }) | ||
| 266 | } | ||
| 267 | |||
| 268 | resp := batchResponse{ | ||
| 269 | Transfer: transferAdapterBasic, | ||
| 270 | HashAlgo: hashAlgoSHA256, | ||
| 271 | } | ||
| 272 | for _, obj := range objects { | ||
| 273 | resp.Objects = append(resp.Objects, h.handleDownloadObject(r.Context(), repo, obj)) | ||
| 274 | } | ||
| 275 | |||
| 276 | w.Header().Set("Content-Type", lfsMIME) | ||
| 277 | w.WriteHeader(http.StatusOK) | ||
| 278 | json.NewEncoder(w).Encode(resp) | ||
| 279 | } | ||
| 280 | |||
| 281 | func die(msg string, args ...any) { | ||
| 282 | fmt.Fprint(os.Stderr, "Error: ") | ||
| 283 | fmt.Fprintf(os.Stderr, msg, args...) | ||
| 284 | fmt.Fprint(os.Stderr, "\n") | ||
| 285 | os.Exit(1) | ||
| 286 | } | ||
| 287 | |||
| 288 | func main() { | ||
| 289 | endpoint := os.Getenv("S3_ENDPOINT") | ||
| 290 | accessKeyID := os.Getenv("S3_ACCESS_KEY_ID") | ||
| 291 | secretAccessKey := os.Getenv("S3_SECRET_ACCESS_KEY") | ||
| 292 | bucket := os.Getenv("S3_BUCKET") | ||
| 293 | anonUser := os.Getenv("ANON_USER") | ||
| 294 | |||
| 295 | if endpoint == "" { | ||
| 296 | die("Expected environment variable S3_ENDPOINT to be set") | ||
| 297 | } | ||
| 298 | if accessKeyID == "" { | ||
| 299 | die("Expected environment variable S3_ACCESS_KEY_ID to be set") | ||
| 300 | } | ||
| 301 | if secretAccessKey == "" { | ||
| 302 | die("Expected environment variable S3_SECRET_ACCESS_KEY to be set") | ||
| 303 | } | ||
| 304 | if bucket == "" { | ||
| 305 | die("Expected environment variable S3_BUCKET to be set") | ||
| 306 | } | ||
| 307 | if anonUser == "" { | ||
| 308 | die("Expected environment variable ANON_USER to be set") | ||
| 309 | } | ||
| 310 | |||
| 311 | mc, err := minio.New(endpoint, &minio.Options{ | ||
| 312 | Creds: credentials.NewStaticV4(accessKeyID, secretAccessKey, ""), | ||
| 313 | Secure: true, | ||
| 314 | }) | ||
| 315 | if err != nil { | ||
| 316 | die("Failed to create S3 client") | ||
| 317 | } | ||
| 318 | |||
| 319 | if err = cgi.Serve(&handler{mc, bucket, anonUser}); err != nil { | ||
| 320 | die("Failed to serve CGI: %s", err) | ||
| 321 | } | ||
| 322 | } | ||
| 323 | |||
| 324 | // Directory stucture: | ||
| 325 | // - lfs/ | ||
| 326 | // - locks/ | ||
| 327 | // - objects/ | ||
| 328 | // - <1st OID byte> | ||
| 329 | // - <2nd OID byte> | ||
| 330 | // - <OID hash> <- this is the object | ||
| @@ -1,3 +1,24 @@ | |||
| 1 | module git.fautchen.eu/gitolfs3 | 1 | module git.fautchen.eu/gitolfs3 |
| 2 | 2 | ||
| 3 | go 1.21.5 | 3 | go 1.21.5 |
| 4 | |||
| 5 | require github.com/minio/minio-go/v7 v7.0.66 | ||
| 6 | |||
| 7 | require ( | ||
| 8 | github.com/dustin/go-humanize v1.0.1 // indirect | ||
| 9 | github.com/google/uuid v1.5.0 // indirect | ||
| 10 | github.com/json-iterator/go v1.1.12 // indirect | ||
| 11 | github.com/klauspost/compress v1.17.4 // indirect | ||
| 12 | github.com/klauspost/cpuid/v2 v2.2.6 // indirect | ||
| 13 | github.com/minio/md5-simd v1.1.2 // indirect | ||
| 14 | github.com/minio/sha256-simd v1.0.1 // indirect | ||
| 15 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect | ||
| 16 | github.com/modern-go/reflect2 v1.0.2 // indirect | ||
| 17 | github.com/rs/xid v1.5.0 // indirect | ||
| 18 | github.com/sirupsen/logrus v1.9.3 // indirect | ||
| 19 | golang.org/x/crypto v0.16.0 // indirect | ||
| 20 | golang.org/x/net v0.19.0 // indirect | ||
| 21 | golang.org/x/sys v0.15.0 // indirect | ||
| 22 | golang.org/x/text v0.14.0 // indirect | ||
| 23 | gopkg.in/ini.v1 v1.67.0 // indirect | ||
| 24 | ) | ||
| @@ -0,0 +1,51 @@ | |||
| 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||
| 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||
| 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||
| 4 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= | ||
| 5 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= | ||
| 6 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= | ||
| 7 | github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= | ||
| 8 | github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | ||
| 9 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= | ||
| 10 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= | ||
| 11 | github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= | ||
| 12 | github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= | ||
| 13 | github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= | ||
| 14 | github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= | ||
| 15 | github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= | ||
| 16 | github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= | ||
| 17 | github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= | ||
| 18 | github.com/minio/minio-go/v7 v7.0.66 h1:bnTOXOHjOqv/gcMuiVbN9o2ngRItvqE774dG9nq0Dzw= | ||
| 19 | github.com/minio/minio-go/v7 v7.0.66/go.mod h1:DHAgmyQEGdW3Cif0UooKOyrT3Vxs82zNdV6tkKhRtbs= | ||
| 20 | github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= | ||
| 21 | github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= | ||
| 22 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= | ||
| 23 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= | ||
| 24 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= | ||
| 25 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= | ||
| 26 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= | ||
| 27 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||
| 28 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||
| 29 | github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= | ||
| 30 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= | ||
| 31 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= | ||
| 32 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= | ||
| 33 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | ||
| 34 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= | ||
| 35 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= | ||
| 36 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | ||
| 37 | golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= | ||
| 38 | golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= | ||
| 39 | golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= | ||
| 40 | golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= | ||
| 41 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||
| 42 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||
| 43 | golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= | ||
| 44 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||
| 45 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= | ||
| 46 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= | ||
| 47 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||
| 48 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= | ||
| 49 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= | ||
| 50 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= | ||
| 51 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||