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= | ||