aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore6
-rw-r--r--Cargo.lock (renamed from rs/Cargo.lock)0
-rw-r--r--Cargo.toml (renamed from rs/Cargo.toml)0
-rw-r--r--cmd/git-lfs-authenticate/main.c253
-rw-r--r--cmd/git-lfs-authenticate/main.go141
-rw-r--r--cmd/git-lfs-server/main.go897
-rw-r--r--cmd/gitolfs3-gen-ed25519-key/main.go31
-rw-r--r--common/Cargo.toml (renamed from rs/common/Cargo.toml)0
-rw-r--r--common/src/lib.rs (renamed from rs/common/src/lib.rs)0
-rw-r--r--flake.nix13
-rw-r--r--git-lfs-authenticate/Cargo.toml (renamed from rs/git-lfs-authenticate/Cargo.toml)0
-rw-r--r--git-lfs-authenticate/src/main.rs (renamed from rs/git-lfs-authenticate/src/main.rs)0
-rw-r--r--go.mod27
-rw-r--r--go.sum53
-rw-r--r--rs/.gitignore1
-rw-r--r--server/Cargo.toml (renamed from rs/server/Cargo.toml)0
-rw-r--r--server/src/main.rs (renamed from rs/server/src/main.rs)0
-rw-r--r--shell/Cargo.toml (renamed from rs/shell/Cargo.toml)0
-rw-r--r--shell/src/main.rs (renamed from rs/shell/src/main.rs)0
19 files changed, 6 insertions, 1416 deletions
diff --git a/.gitignore b/.gitignore
index 9a23854..15873fc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,7 @@
1.direnv/ 1.direnv/
2.idea/ 2.idea/
3.vscode/ 3.vscode/
4
5# go build
6cmd/git-lfs-authenticate/git-lfs-authenticate
7cmd/git-lfs-server/git-lfs-server
8# nix build 4# nix build
9/result 5/result
6# cargo build
7/target/
diff --git a/rs/Cargo.lock b/Cargo.lock
index f3beb9e..f3beb9e 100644
--- a/rs/Cargo.lock
+++ b/Cargo.lock
diff --git a/rs/Cargo.toml b/Cargo.toml
index 6439e6b..6439e6b 100644
--- a/rs/Cargo.toml
+++ b/Cargo.toml
diff --git a/cmd/git-lfs-authenticate/main.c b/cmd/git-lfs-authenticate/main.c
deleted file mode 100644
index a7ec031..0000000
--- a/cmd/git-lfs-authenticate/main.c
+++ /dev/null
@@ -1,253 +0,0 @@
1#include <assert.h>
2#include <ctype.h>
3#include <errno.h>
4#include <stdbool.h>
5#include <stdio.h>
6#include <string.h>
7
8#include <sys/stat.h>
9
10#include <openssl/evp.h>
11#include <openssl/hmac.h>
12
13void die(const char *format, ...) {
14 fputs("Fatal: ", stderr);
15 va_list ap;
16 va_start(ap, format);
17 vfprintf(stderr, format, ap);
18 va_end(ap);
19 fputc('\n', stderr);
20 exit(EXIT_FAILURE);
21}
22
23#define USAGE "Usage: git-lfs-authenticate <REPO> upload/download"
24
25bool hasprefix(const char *str, const char *prefix) {
26 if (strlen(prefix) > strlen(str))
27 return false;
28 return !strncmp(str, prefix, strlen(prefix));
29}
30
31bool hassuffix(const char *str, const char *suffix) {
32 if (strlen(suffix) > strlen(str))
33 return false;
34 str += strlen(str) - strlen(suffix);
35 return !strcmp(str, suffix);
36}
37
38const char *trimspace(const char *str, size_t *length) {
39 while (*length > 0 && isspace(str[0])) {
40 str++; (*length)--;
41 }
42 while (*length > 0 && isspace(str[*length - 1]))
43 (*length)--;
44 return str;
45}
46
47void printescjson(const char *str) {
48 for (size_t i = 0; i < strlen(str); i++) {
49 switch (str[i]) {
50 case '"': fputs("\\\"", stdout); break;
51 case '\\': fputs("\\\\", stdout); break;
52 case '\b': fputs("\\b", stdout); break;
53 case '\f': fputs("\\f", stdout); break;
54 case '\n': fputs("\\n", stdout); break;
55 case '\r': fputs("\\r", stdout); break;
56 case '\t': fputs("\\t", stdout); break;
57 default: fputc(str[i], stdout);
58 }
59 }
60}
61
62void checkrepopath(const char *path) {
63 if (strstr(path, "//") || strstr(path, "/./") || strstr(path, "/../")
64 || hasprefix(path, "./") || hasprefix(path, "../") || hasprefix(path, "/../"))
65 die("Bad repository name: Is unresolved path");
66 if (strlen(path) > 100)
67 die("Bad repository name: Longer than 100 characters");
68 if (hasprefix(path, "/"))
69 die("Bad repository name: Unexpected absolute path");
70 if (!hassuffix(path, ".git"))
71 die("Bad repository name: Expected '.git' repo path suffix");
72
73 struct stat statbuf;
74 if (stat(path, &statbuf)) {
75 if (errno == ENOENT)
76 die("Repo not found");
77 die("Could not stat repo: %s", strerror(errno));
78 }
79 if (!S_ISDIR(statbuf.st_mode)) {
80 die("Repo not found");
81 }
82}
83
84char *readkeyfile(const char *path, size_t *len) {
85 FILE *f = fopen(path, "r");
86 if (!f)
87 die("Cannot read key file: %s", strerror(errno));
88 *len = 0;
89 size_t bufcap = 4096;
90 char *buf = malloc(bufcap);
91 while (!feof(f) && !ferror(f)) {
92 if (*len + 4096 > bufcap) {
93 bufcap *= 2;
94 buf = realloc(buf, bufcap);
95 }
96 *len += fread(buf + *len, sizeof(char), 4096, f);
97 }
98 if (ferror(f) && !feof(f)) {
99 OPENSSL_cleanse(buf, *len);
100 die("Failed to read key file (length: %lu)", *len);
101 }
102 fclose(f);
103 return buf;
104}
105
106#define KEYSIZE 64
107
108void readkey(const char *path, uint8_t dest[KEYSIZE]) {
109 size_t keybuf_len = 0;
110 char *keybuf = readkeyfile(path, &keybuf_len);
111
112 size_t keystr_len = keybuf_len;
113 const char *keystr = trimspace(keybuf, &keystr_len);
114 if (keystr_len != 2 * KEYSIZE) {
115 OPENSSL_cleanse(keybuf, keybuf_len);
116 die("Bad key length");
117 }
118
119 for (size_t i = 0; i < KEYSIZE; i++) {
120 const char c = keystr[i];
121 uint8_t nibble = 0;
122 if (c >= '0' && c <= '9')
123 nibble = c - '0';
124 else if (c >= 'a' && c <= 'f')
125 nibble = c - 'a' + 10;
126 else if (c >= 'A' && c <= 'F')
127 nibble = c - 'A' + 10;
128 else {
129 OPENSSL_cleanse(keybuf, keybuf_len);
130 OPENSSL_cleanse(dest, KEYSIZE);
131 die("Cannot decode key");
132 }
133 size_t ikey = i / 2;
134 if (i % 2) dest[ikey] |= nibble;
135 else dest[ikey] = nibble << 4;
136 }
137
138 OPENSSL_cleanse(keybuf, keybuf_len);
139 free(keybuf);
140}
141
142void u64tobe(uint64_t x, uint8_t b[8]) {
143 b[0] = (uint8_t)(x >> 56);
144 b[1] = (uint8_t)(x >> 48);
145 b[2] = (uint8_t)(x >> 40);
146 b[3] = (uint8_t)(x >> 32);
147 b[4] = (uint8_t)(x >> 24);
148 b[5] = (uint8_t)(x >> 16);
149 b[6] = (uint8_t)(x >> 8);
150 b[7] = (uint8_t)(x >> 0);
151}
152
153void *memcat(void *dest, const void *src, size_t n) {
154 return memcpy(dest, src, n) + n;
155}
156
157#define MAX_TAG_SIZE EVP_MAX_MD_SIZE
158
159typedef struct taginfo {
160 const char *authtype;
161 const char *repopath;
162 const char *operation;
163 const int64_t expiresat_s;
164} taginfo_t;
165
166void maketag(const taginfo_t info, uint8_t key[KEYSIZE], uint8_t dest[MAX_TAG_SIZE], uint32_t *len) {
167 uint8_t expiresat_b[8];
168 u64tobe(info.expiresat_s, expiresat_b);
169
170 const uint8_t zero[1] = { 0 };
171 const size_t fullsize = strlen(info.authtype) +
172 1 + strlen(info.repopath) +
173 1 + strlen(info.operation) +
174 1 + sizeof(expiresat_b);
175 uint8_t *claimbuf = alloca(fullsize);
176 uint8_t *head = claimbuf;
177 head = memcat(head, info.authtype, strlen(info.authtype));
178 head = memcat(head, zero, 1);
179 head = memcat(head, info.repopath, strlen(info.repopath));
180 head = memcat(head, zero, 1);
181 head = memcat(head, info.operation, strlen(info.operation));
182 head = memcat(head, zero, 1);
183 head = memcat(head, expiresat_b, sizeof(expiresat_b));
184 assert(head == claimbuf + fullsize);
185
186 memset(dest, 0, MAX_TAG_SIZE);
187 *len = 0;
188 if (!HMAC(EVP_sha256(), key, KEYSIZE, claimbuf, fullsize, dest, len)) {
189 OPENSSL_cleanse(key, KEYSIZE);
190 die("Failed to generate tag");
191 }
192}
193
194#define MAX_HEXTAG_STRLEN MAX_TAG_SIZE * 2
195
196void makehextag(const taginfo_t info, uint8_t key[KEYSIZE], char dest[MAX_HEXTAG_STRLEN + 1]) {
197 uint8_t rawtag[MAX_TAG_SIZE];
198 uint32_t rawtag_len;
199 maketag(info, key, rawtag, &rawtag_len);
200
201 memset(dest, 0, MAX_HEXTAG_STRLEN + 1);
202 for (size_t i = 0; i < rawtag_len; i++) {
203 uint8_t b = rawtag[i];
204 dest[i * 2] = (b >> 4) + ((b >> 4) < 10 ? '0' : 'a');
205 dest[i*2 + 1] = (b & 0x0F) + ((b & 0x0F) < 10 ? '0' : 'a');
206 }
207}
208
209int main(int argc, char *argv[]) {
210 if (argc != 3) {
211 puts(USAGE);
212 exit(EXIT_FAILURE);
213 }
214
215 const char *repopath = argv[1];
216 const char *operation = argv[2];
217 if (strcmp(operation, "download") && strcmp(operation, "upload")) {
218 puts(USAGE);
219 exit(EXIT_FAILURE);
220 }
221 checkrepopath(repopath);
222
223 const char *hrefbase = getenv("GITOLFS3_HREF_BASE");
224 const char *keypath = getenv("GITOLFS3_KEY_PATH");
225
226 if (!hrefbase || strlen(hrefbase) == 0)
227 die("Incomplete configuration: Base URL not provided");
228 if (hrefbase[strlen(hrefbase) - 1] != '/')
229 die("Bad configuration: Base URL should end with slash");
230 if (!keypath || strlen(keypath) == 0)
231 die("Incomplete configuration: Key path not provided");
232
233 uint8_t key[64];
234 readkey(keypath, key);
235
236 int64_t expiresin_s = 5 * 60;
237 int64_t expiresat_s = (int64_t)time(NULL) + expiresin_s;
238
239 taginfo_t taginfo = {
240 .authtype = "git-lfs-authenticate",
241 .repopath = repopath,
242 .operation = operation,
243 .expiresat_s = expiresat_s,
244 };
245 char hextag[MAX_HEXTAG_STRLEN + 1];
246 makehextag(taginfo, key, hextag);
247
248 printf("{\"header\":{\"Authorization\":\"Gitolfs3-Hmac-Sha256 %s\"},"
249 "\"expires_in\":%ld,\"href\":\"", hextag, expiresin_s);
250 printescjson(hrefbase);
251 printescjson(repopath);
252 printf("/info/lfs?p=1&te=%ld\"}\n", expiresat_s);
253}
diff --git a/cmd/git-lfs-authenticate/main.go b/cmd/git-lfs-authenticate/main.go
deleted file mode 100644
index 59ed978..0000000
--- a/cmd/git-lfs-authenticate/main.go
+++ /dev/null
@@ -1,141 +0,0 @@
1package main
2
3import (
4 "bytes"
5 "crypto/hmac"
6 "crypto/sha256"
7 "encoding/binary"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "io"
13 "io/fs"
14 "os"
15 "path"
16 "strings"
17 "time"
18)
19
20func die(msg string, args ...any) {
21 fmt.Fprint(os.Stderr, "Fatal: ")
22 fmt.Fprintf(os.Stderr, msg, args...)
23 fmt.Fprint(os.Stderr, "\n")
24 os.Exit(1)
25}
26
27type authenticateResponse struct {
28 // When providing href, the Git LFS client will use href as the base URL
29 // instead of building the base URL using the Service Discovery mechanism.
30 // It should end with /info/lfs. See
31 // https://github.com/git-lfs/git-lfs/blob/baf40ac99850a62fe98515175d52df5c513463ec/docs/api/server-discovery.md#ssh
32 HRef string `json:"href,omitempty"`
33 Header map[string]string `json:"header"`
34 // In seconds.
35 ExpiresIn int64 `json:"expires_in,omitempty"`
36 // The expires_at (RFC3339) property could also be used, but we leave it
37 // out since we don't use it. The Git LFS docs recommend using expires_in
38 // instead (???)
39}
40
41func wipe(b []byte) {
42 for i := range b {
43 b[i] = 0
44 }
45}
46
47const usage = "Usage: git-lfs-authenticate <REPO> upload/download"
48
49func main() {
50 // Even though not explicitly described in the Git LFS documentation, the
51 // git-lfs-authenticate command is expected to either exit succesfully with
52 // exit code 0 and to then print credentials in the prescribed JSON format
53 // to standard out. On errors, the command should exit with a non-zero exit
54 // code and print the error message in plain text to standard error. See
55 // https://github.com/git-lfs/git-lfs/blob/baf40ac99850a62fe98515175d52df5c513463ec/lfshttp/ssh.go#L76-L117
56
57 if len(os.Args) != 3 {
58 fmt.Println(usage)
59 os.Exit(1)
60 }
61
62 repo := strings.TrimPrefix(path.Clean(os.Args[1]), "/")
63 operation := os.Args[2]
64 if operation != "download" && operation != "upload" {
65 fmt.Println(usage)
66 os.Exit(1)
67 }
68 if repo == ".." || strings.HasPrefix(repo, "../") {
69 die("highly illegal repo name (Anzeige ist raus)")
70 }
71 if !strings.HasSuffix(repo, ".git") {
72 die("expected repo name to have '.git' suffix")
73 }
74
75 repoDir := path.Join(repo)
76 finfo, err := os.Stat(repoDir)
77 if err != nil {
78 if errors.Is(err, fs.ErrNotExist) {
79 die("repo not found")
80 }
81 die("could not stat repo: %s", err)
82 }
83 if !finfo.IsDir() {
84 die("repo not found")
85 }
86
87 hrefBase := os.Getenv("GITOLFS3_HREF_BASE")
88 if hrefBase == "" {
89 die("incomplete configuration: base URL not provided")
90 }
91 if !strings.HasSuffix(hrefBase, "/") {
92 hrefBase += "/"
93 }
94
95 keyPath := os.Getenv("GITOLFS3_KEY_PATH")
96 if keyPath == "" {
97 die("incomplete configuration: key path not provided")
98 }
99
100 keyStr, err := os.ReadFile(keyPath)
101 if err != nil {
102 wipe(keyStr)
103 die("cannot read key")
104 }
105 keyStr = bytes.TrimSpace(keyStr)
106 defer wipe(keyStr)
107 if hex.DecodedLen(len(keyStr)) != 64 {
108 die("bad key length")
109 }
110 key := make([]byte, 64)
111 defer wipe(key)
112 if _, err = hex.Decode(key, keyStr); err != nil {
113 die("cannot decode key")
114 }
115
116 expiresIn := time.Minute * 5
117 expiresAtUnix := time.Now().Add(expiresIn).Unix()
118
119 tag := hmac.New(sha256.New, key)
120 io.WriteString(tag, "git-lfs-authenticate")
121 tag.Write([]byte{0})
122 io.WriteString(tag, repo)
123 tag.Write([]byte{0})
124 io.WriteString(tag, operation)
125 tag.Write([]byte{0})
126 binary.Write(tag, binary.BigEndian, &expiresAtUnix)
127 tagStr := hex.EncodeToString(tag.Sum(nil))
128
129 response := authenticateResponse{
130 Header: map[string]string{
131 "Authorization": "Gitolfs3-Hmac-Sha256 " + tagStr,
132 },
133 ExpiresIn: int64(expiresIn.Seconds()),
134 HRef: fmt.Sprintf("%s%s?p=1&te=%d",
135 hrefBase,
136 path.Join(repo, "/info/lfs"),
137 expiresAtUnix,
138 ),
139 }
140 json.NewEncoder(os.Stdout).Encode(response)
141}
diff --git a/cmd/git-lfs-server/main.go b/cmd/git-lfs-server/main.go
deleted file mode 100644
index eec7d00..0000000
--- a/cmd/git-lfs-server/main.go
+++ /dev/null
@@ -1,897 +0,0 @@
1package main
2
3import (
4 "bytes"
5 "context"
6 "crypto/ed25519"
7 "crypto/sha256"
8 "encoding/base64"
9 "encoding/hex"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "hash"
14 "io"
15 "mime"
16 "net"
17 "net/http"
18 "net/url"
19 "os"
20 "os/exec"
21 "path"
22 "regexp"
23 "runtime/debug"
24 "slices"
25 "strconv"
26 "strings"
27 "time"
28 "unicode"
29
30 "github.com/golang-jwt/jwt/v5"
31 "github.com/minio/minio-go/v7"
32 "github.com/minio/minio-go/v7/pkg/credentials"
33 "github.com/rs/xid"
34)
35
36type operation string
37type transferAdapter string
38type hashAlgo string
39
40const (
41 operationDownload operation = "download"
42 operationUpload operation = "upload"
43 transferAdapterBasic transferAdapter = "basic"
44 hashAlgoSHA256 hashAlgo = "sha256"
45)
46
47const lfsMIME = "application/vnd.git-lfs+json"
48
49type batchRef struct {
50 Name string `json:"name"`
51}
52
53type batchRequestObject struct {
54 OID string `json:"oid"`
55 Size int64 `json:"size"`
56}
57
58type batchRequest struct {
59 Operation operation `json:"operation"`
60 Transfers []transferAdapter `json:"transfers,omitempty"`
61 Ref *batchRef `json:"ref,omitempty"`
62 Objects []batchRequestObject `json:"objects"`
63 HashAlgo hashAlgo `json:"hash_algo,omitempty"`
64}
65
66type batchAction struct {
67 HRef string `json:"href"`
68 Header map[string]string `json:"header,omitempty"`
69 // In seconds.
70 ExpiresIn int64 `json:"expires_in,omitempty"`
71 // expires_at (RFC3339) could also be used, but we leave it out since we
72 // don't use it.
73}
74
75type batchError struct {
76 Code int `json:"code"`
77 Message string `json:"message"`
78}
79
80type batchResponseObject struct {
81 OID string `json:"oid"`
82 Size int64 `json:"size"`
83 Authenticated *bool `json:"authenticated"`
84 Actions map[operation]batchAction `json:"actions,omitempty"`
85 Error *batchError `json:"error,omitempty"`
86}
87
88type batchResponse struct {
89 Transfer transferAdapter `json:"transfer,omitempty"`
90 Objects []batchResponseObject `json:"objects"`
91 HashAlgo hashAlgo `json:"hash_algo,omitempty"`
92}
93
94type handler struct {
95 mc *minio.Client
96 bucket string
97 anonUser string
98 gitolitePath string
99 privateKey ed25519.PrivateKey
100 baseURL *url.URL
101 exportAllForwardedHosts []string
102}
103
104func isValidSHA256Hash(hash string) bool {
105 if len(hash) != 64 {
106 return false
107 }
108 for _, c := range hash {
109 if !unicode.Is(unicode.ASCII_Hex_Digit, c) {
110 return false
111 }
112 }
113 return true
114}
115
116type lfsError struct {
117 Message string `json:"message"`
118 DocumentationURL string `json:"documentation_url,omitempty"`
119 RequestID string `json:"request_id,omitempty"`
120}
121
122func makeRespError(ctx context.Context, w http.ResponseWriter, message string, code int) {
123 err := lfsError{Message: message}
124 if val := ctx.Value(requestIDKey); val != nil {
125 err.RequestID = val.(string)
126 }
127 w.Header().Set("Content-Type", lfsMIME+"; charset=utf-8")
128 w.WriteHeader(code)
129 json.NewEncoder(w).Encode(err)
130}
131
132func 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
143func sha256AsBase64(hash string) string {
144 raw, err := hex.DecodeString(hash)
145 if err != nil {
146 return ""
147 }
148 return base64.StdEncoding.EncodeToString(raw)
149}
150
151func (h *handler) handleDownloadObject(ctx context.Context, repo string, obj parsedBatchObject) batchResponseObject {
152 fullPath := path.Join(repo+".git", "lfs/objects", obj.firstByte, obj.secondByte, obj.fullHash)
153
154 info, err := h.mc.StatObject(ctx, h.bucket, fullPath, minio.StatObjectOptions{Checksum: true})
155 if err != nil {
156 var resp minio.ErrorResponse
157 if errors.As(err, &resp) && resp.StatusCode == http.StatusNotFound {
158 return makeObjError(obj, "Object does not exist", http.StatusNotFound)
159 }
160 // TODO: consider not making this an object-specific, but rather a
161 // generic error such that the entire Batch API request fails.
162 reqlog(ctx, "Failed to query object information (full path: %s): %s", fullPath, err)
163 return makeObjError(obj, "Failed to query object information", http.StatusInternalServerError)
164 }
165 if info.ChecksumSHA256 != "" && strings.ToLower(info.ChecksumSHA256) != obj.fullHash {
166 return makeObjError(obj, "Object corrupted", http.StatusUnprocessableEntity)
167 }
168 if info.Size != obj.size {
169 return makeObjError(obj, "Incorrect size specified for object or object currupted", http.StatusUnprocessableEntity)
170 }
171
172 expiresIn := time.Minute * 10
173 claims := handleObjectCustomClaims{
174 Gitolfs3: handleObjectGitolfs3Claims{
175 Type: "basic-transfer",
176 Operation: operationDownload,
177 Repository: repo,
178 OID: obj.fullHash,
179 Size: obj.size,
180 },
181 RegisteredClaims: &jwt.RegisteredClaims{
182 IssuedAt: jwt.NewNumericDate(time.Now()),
183 ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiresIn)),
184 },
185 }
186
187 token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
188 ss, err := token.SignedString(h.privateKey)
189 if err != nil {
190 // TODO: consider not making this an object-specific, but rather a
191 // generic error such that the entire Batch API request fails.
192 reqlog(ctx, "Fatal: failed to generate JWT: %s", err)
193 return makeObjError(obj, "Failed to generate token", http.StatusInternalServerError)
194 }
195 uploadPath := path.Join(repo+".git", "info/lfs/objects", obj.firstByte, obj.secondByte, obj.fullHash)
196
197 authenticated := true
198 return batchResponseObject{
199 OID: obj.fullHash,
200 Size: obj.size,
201 Authenticated: &authenticated,
202 Actions: map[operation]batchAction{
203 operationDownload: {
204 Header: map[string]string{
205 "Authorization": "Bearer " + ss,
206 },
207 HRef: h.baseURL.ResolveReference(&url.URL{Path: uploadPath}).String(),
208 ExpiresIn: int64(expiresIn.Seconds()),
209 },
210 },
211 }
212}
213
214type handleObjectGitolfs3Claims struct {
215 Type string `json:"type"`
216 Operation operation `json:"operation"`
217 Repository string `json:"repository"`
218 OID string `json:"oid"`
219 Size int64 `json:"size"`
220}
221
222type handleObjectCustomClaims struct {
223 Gitolfs3 handleObjectGitolfs3Claims `json:"gitolfs3"`
224 *jwt.RegisteredClaims
225}
226
227// Return nil when the object already exists
228func (h *handler) handleUploadObject(ctx context.Context, repo string, obj parsedBatchObject) *batchResponseObject {
229 fullPath := path.Join(repo+".git", "lfs/objects", obj.firstByte, obj.secondByte, obj.fullHash)
230 _, err := h.mc.StatObject(ctx, h.bucket, fullPath, minio.GetObjectOptions{})
231 if err == nil {
232 // The object exists
233 return nil
234 }
235
236 var resp minio.ErrorResponse
237 if !errors.As(err, &resp) || resp.StatusCode != http.StatusNotFound {
238 // TODO: consider not making this an object-specific, but rather a
239 // generic error such that the entire Batch API request fails.
240 reqlog(ctx, "Failed to generate action href (full path: %s): %s", fullPath, err)
241 objErr := makeObjError(obj, "Failed to generate action href", http.StatusInternalServerError)
242 return &objErr
243 }
244
245 expiresIn := time.Minute * 10
246 claims := handleObjectCustomClaims{
247 Gitolfs3: handleObjectGitolfs3Claims{
248 Type: "basic-transfer",
249 Operation: operationUpload,
250 Repository: repo,
251 OID: obj.fullHash,
252 Size: obj.size,
253 },
254 RegisteredClaims: &jwt.RegisteredClaims{
255 IssuedAt: jwt.NewNumericDate(time.Now()),
256 ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiresIn)),
257 },
258 }
259
260 token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
261 ss, err := token.SignedString(h.privateKey)
262 if err != nil {
263 // TODO: consider not making this an object-specific, but rather a
264 // generic error such that the entire Batch API request fails.
265 reqlog(ctx, "Fatal: failed to generate JWT: %s", err)
266 objErr := makeObjError(obj, "Failed to generate token", http.StatusInternalServerError)
267 return &objErr
268 }
269
270 uploadPath := path.Join(repo+".git", "info/lfs/objects", obj.firstByte, obj.secondByte, obj.fullHash)
271 uploadHRef := h.baseURL.ResolveReference(&url.URL{Path: uploadPath}).String()
272 // The object does not exist.
273 authenticated := true
274 return &batchResponseObject{
275 OID: obj.fullHash,
276 Size: obj.size,
277 Authenticated: &authenticated,
278 Actions: map[operation]batchAction{
279 operationUpload: {
280 Header: map[string]string{
281 "Authorization": "Bearer " + ss,
282 },
283 HRef: uploadHRef,
284 ExpiresIn: int64(expiresIn.Seconds()),
285 },
286 },
287 }
288}
289
290type validatingReader struct {
291 promisedSize int64
292 promisedSha256 []byte
293
294 reader io.Reader
295 bytesRead int64
296 current hash.Hash
297 err error
298}
299
300func newValidatingReader(promisedSize int64, promisedSha256 []byte, r io.Reader) *validatingReader {
301 return &validatingReader{
302 promisedSize: promisedSize,
303 promisedSha256: promisedSha256,
304 reader: r,
305 current: sha256.New(),
306 }
307}
308
309var errTooBig = errors.New("validator: uploaded file bigger than indicated")
310var errTooSmall = errors.New("validator: uploaded file smaller than indicated")
311var errBadSum = errors.New("validator: bad checksum provided or file corrupted")
312
313func (i *validatingReader) Read(b []byte) (int, error) {
314 if i.err != nil {
315 return 0, i.err
316 }
317 n, err := i.reader.Read(b)
318 i.bytesRead += int64(n)
319 if i.bytesRead > i.promisedSize {
320 i.err = errTooBig
321 return 0, i.err
322 }
323 if err != nil && errors.Is(err, io.EOF) {
324 if i.bytesRead < i.promisedSize {
325 i.err = errTooSmall
326 return n, i.err
327 }
328 }
329 // According to the documentation, Hash.Write never returns an error
330 i.current.Write(b[:n])
331 if i.bytesRead == i.promisedSize {
332 if !bytes.Equal(i.promisedSha256, i.current.Sum(nil)) {
333 i.err = errBadSum
334 return 0, i.err
335 }
336 }
337 return n, err
338}
339
340func (h *handler) handlePutObject(w http.ResponseWriter, r *http.Request, repo, oid string) {
341 ctx := r.Context()
342
343 authz := r.Header.Get("Authorization")
344 if authz == "" {
345 makeRespError(ctx, w, "Missing Authorization header", http.StatusBadRequest)
346 return
347 }
348 if !strings.HasPrefix(authz, "Bearer ") {
349 makeRespError(ctx, w, "Invalid Authorization header", http.StatusBadRequest)
350 return
351 }
352 authz = strings.TrimPrefix(authz, "Bearer ")
353
354 var claims handleObjectCustomClaims
355 _, err := jwt.ParseWithClaims(authz, &claims, func(token *jwt.Token) (any, error) {
356 if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok {
357 return nil, fmt.Errorf("expected signing method EdDSA, got %s", token.Header["alg"])
358 }
359 return h.privateKey.Public(), nil
360 })
361 if err != nil {
362 makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized)
363 return
364 }
365 if claims.Gitolfs3.Type != "basic-transfer" {
366 makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized)
367 return
368 }
369 if claims.Gitolfs3.Repository != repo {
370 makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized)
371 return
372 }
373 if claims.Gitolfs3.OID != oid {
374 makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized)
375 return
376 }
377 if claims.Gitolfs3.Operation != operationUpload {
378 makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized)
379 return
380 }
381
382 // Check with claims
383 if lengthStr := r.Header.Get("Content-Length"); lengthStr != "" {
384 length, err := strconv.ParseInt(lengthStr, 10, 64)
385 if err != nil {
386 makeRespError(ctx, w, "Bad Content-Length format", http.StatusBadRequest)
387 return
388 }
389 if length != claims.Gitolfs3.Size {
390 makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized)
391 return
392 }
393 }
394
395 sha256Raw, err := hex.DecodeString(oid)
396 if err != nil || len(sha256Raw) != sha256.Size {
397 makeRespError(ctx, w, "Invalid OID", http.StatusBadRequest)
398 return
399 }
400
401 reader := newValidatingReader(claims.Gitolfs3.Size, sha256Raw, r.Body)
402
403 fullPath := path.Join(repo+".git", "lfs/objects", oid[:2], oid[2:4], oid)
404 _, err = h.mc.PutObject(ctx, h.bucket, fullPath, reader, int64(claims.Gitolfs3.Size), minio.PutObjectOptions{
405 SendContentMd5: true,
406 })
407 if err != nil {
408 if errors.Is(err, errBadSum) {
409 makeRespError(ctx, w, "Bad checksum (OID does not match contents)", http.StatusBadRequest)
410 } else if errors.Is(err, errTooSmall) {
411 makeRespError(ctx, w, "Uploaded object smaller than expected", http.StatusBadRequest)
412 } else if errors.Is(err, errTooBig) {
413 makeRespError(ctx, w, "Uploaded object bigger than expected", http.StatusBadRequest)
414 } else {
415 reqlog(ctx, "Failed to upload object: %s", err)
416 makeRespError(ctx, w, "Failed to upload object", http.StatusInternalServerError)
417 }
418 return
419 }
420}
421
422func (h *handler) handleGetObject(w http.ResponseWriter, r *http.Request, repo, oid string) {
423 ctx := r.Context()
424
425 authz := r.Header.Get("Authorization")
426 if authz == "" {
427 makeRespError(ctx, w, "Missing Authorization header", http.StatusBadRequest)
428 return
429 }
430 if !strings.HasPrefix(authz, "Bearer ") {
431 makeRespError(ctx, w, "Invalid Authorization header", http.StatusBadRequest)
432 return
433 }
434 authz = strings.TrimPrefix(authz, "Bearer ")
435
436 var claims handleObjectCustomClaims
437 _, err := jwt.ParseWithClaims(authz, &claims, func(token *jwt.Token) (any, error) {
438 if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok {
439 return nil, fmt.Errorf("expected signing method EdDSA, got %s", token.Header["alg"])
440 }
441 return h.privateKey.Public(), nil
442 })
443 if err != nil {
444 makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized)
445 return
446 }
447 if claims.Gitolfs3.Type != "basic-transfer" {
448 makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized)
449 return
450 }
451 if claims.Gitolfs3.Repository != repo {
452 makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized)
453 return
454 }
455 if claims.Gitolfs3.OID != oid {
456 makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized)
457 return
458 }
459 if claims.Gitolfs3.Operation != operationDownload {
460 makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized)
461 return
462 }
463
464 sha256Raw, err := hex.DecodeString(oid)
465 if err != nil || len(sha256Raw) != sha256.Size {
466 makeRespError(ctx, w, "Invalid OID", http.StatusBadRequest)
467 return
468 }
469
470 fullPath := path.Join(repo+".git", "lfs/objects", oid[:2], oid[2:4], oid)
471 obj, err := h.mc.GetObject(ctx, h.bucket, fullPath, minio.GetObjectOptions{})
472
473 var resp minio.ErrorResponse
474 if errors.As(err, &resp) && resp.StatusCode != http.StatusNotFound {
475 makeRespError(ctx, w, "Not found", http.StatusNotFound)
476 return
477 } else if err != nil {
478 reqlog(ctx, "Failed to get object: %s", err)
479 makeRespError(ctx, w, "Failed to get object", http.StatusInternalServerError)
480 return
481 }
482
483 stat, err := obj.Stat()
484 if err != nil {
485 reqlog(ctx, "Failed to stat: %s", err)
486 makeRespError(ctx, w, "Internal server error", http.StatusInternalServerError)
487 return
488 }
489
490 if stat.Size != claims.Gitolfs3.Size {
491 reqlog(ctx, "Claims size does not match S3 object size")
492 makeRespError(ctx, w, "Internal server error", http.StatusInternalServerError)
493 return
494 }
495
496 w.Header().Set("Content-Length", strconv.FormatInt(claims.Gitolfs3.Size, 10))
497 w.WriteHeader(http.StatusOK)
498
499 vr := newValidatingReader(claims.Gitolfs3.Size, sha256Raw, obj)
500 _, err = io.Copy(w, vr)
501 if errors.Is(err, errBadSum) {
502 reqlog(ctx, "Bad object checksum")
503 }
504}
505
506type parsedBatchObject struct {
507 firstByte string
508 secondByte string
509 fullHash string
510 size int64
511}
512
513func isLFSMediaType(t string) bool {
514 if mediaType, params, err := mime.ParseMediaType(t); err == nil {
515 if mediaType == lfsMIME {
516 if params["charset"] == "" || strings.ToLower(params["charset"]) == "utf-8" {
517 return true
518 }
519 }
520 }
521 return false
522}
523
524var reBatchAPI = regexp.MustCompile(`^([a-zA-Z0-9-_/]+)\.git/info/lfs/objects/batch$`)
525var reObjUpload = regexp.MustCompile(`^([a-zA-Z0-9-_/]+)\.git/info/lfs/objects/([0-9a-f]{2})/([0-9a-f]{2})/([0-9a-f]{64})$`)
526
527type requestID struct{}
528
529var requestIDKey requestID
530
531// TODO: make a shared package for this
532type lfsAuthGitolfs3Claims struct {
533 Type string `json:"type"`
534 Repository string `json:"repository"`
535 Permission operation `json:"permission"`
536}
537
538type lfsAuthCustomClaims struct {
539 Gitolfs3 lfsAuthGitolfs3Claims `json:"gitolfs3"`
540 *jwt.RegisteredClaims
541}
542
543// Request to perform <operation> in <repository> [on reference <refspec>]
544type operationRequest struct {
545 operation operation
546 repository string
547 refspec *string
548}
549
550func (h *handler) getGitoliteAccess(repo, user, gitolitePerm string, refspec *string) (bool, error) {
551 // gitolite access -q: returns only exit code
552 gitoliteArgs := []string{"access", "-q", repo, user, gitolitePerm}
553 if refspec != nil {
554 gitoliteArgs = append(gitoliteArgs, *refspec)
555 }
556 cmd := exec.Command(h.gitolitePath, gitoliteArgs...)
557 err := cmd.Run()
558 if err != nil {
559 var exitErr *exec.ExitError
560 if !errors.As(err, &exitErr) {
561 return false, fmt.Errorf("(running %s): %w", cmd, err)
562 }
563 return false, nil
564 }
565 return true, nil
566}
567
568func (h *handler) authorizeBatchAPI(w http.ResponseWriter, r *http.Request, or operationRequest) bool {
569 user := h.anonUser
570 ctx := r.Context()
571
572 if or.operation == operationDownload {
573 // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host
574 forwardedHost := r.Header.Get("X-Forwarded-Host")
575 if forwardedHost != "" && slices.Contains(h.exportAllForwardedHosts, forwardedHost) {
576 // This is a forwarded host for which all repositories are exported,
577 // regardless of ownership configuration in Gitolite.
578 return true
579 }
580 }
581
582 if authz := r.Header.Get("Authorization"); authz != "" {
583 if !strings.HasPrefix(authz, "Bearer ") {
584 makeRespError(ctx, w, "Invalid Authorization header", http.StatusBadRequest)
585 return false
586 }
587 authz = strings.TrimPrefix(authz, "Bearer ")
588
589 var claims lfsAuthCustomClaims
590 _, err := jwt.ParseWithClaims(authz, &claims, func(token *jwt.Token) (any, error) {
591 if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok {
592 return nil, fmt.Errorf("expected signing method EdDSA, got %s", token.Header["alg"])
593 }
594 return h.privateKey.Public(), nil
595 })
596 if err != nil {
597 makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized)
598 return false
599 }
600
601 if claims.Gitolfs3.Type != "batch-api" {
602 makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized)
603 return false
604 }
605 if claims.Gitolfs3.Repository != or.repository {
606 makeRespError(ctx, w, "Invalid token", http.StatusUnauthorized)
607 return false
608 }
609 if claims.Gitolfs3.Permission == operationDownload && or.operation == operationUpload {
610 makeRespError(ctx, w, "Forbidden", http.StatusForbidden)
611 return false
612 }
613
614 user = claims.Subject
615 }
616
617 readAccess, err := h.getGitoliteAccess(or.repository, user, "R", or.refspec)
618 if err != nil {
619 reqlog(ctx, "Error checking access info: %s", err)
620 makeRespError(ctx, w, "Failed to query access information", http.StatusInternalServerError)
621 return false
622 }
623 if !readAccess {
624 makeRespError(ctx, w, "Repository not found", http.StatusNotFound)
625 return false
626 }
627 if or.operation == operationUpload {
628 writeAccess, err := h.getGitoliteAccess(or.repository, user, "W", or.refspec)
629 if err != nil {
630 reqlog(ctx, "Error checking access info: %s", err)
631 makeRespError(ctx, w, "Failed to query access information", http.StatusInternalServerError)
632 return false
633 }
634 // User has read access but no write access
635 if !writeAccess {
636 makeRespError(ctx, w, "Forbidden", http.StatusForbidden)
637 return false
638 }
639 }
640
641 return true
642}
643
644func (h *handler) handleBatchAPI(w http.ResponseWriter, r *http.Request, repo string) {
645 ctx := r.Context()
646
647 if !slices.ContainsFunc(r.Header.Values("Accept"), isLFSMediaType) {
648 makeRespError(ctx, w, "Expected "+lfsMIME+" (with UTF-8 charset) in list of acceptable response media types", http.StatusNotAcceptable)
649 return
650 }
651 if !isLFSMediaType(r.Header.Get("Content-Type")) {
652 makeRespError(ctx, w, "Expected request Content-Type to be "+lfsMIME+" (with UTF-8 charset)", http.StatusUnsupportedMediaType)
653 return
654 }
655
656 var body batchRequest
657 if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
658 makeRespError(ctx, w, "Failed to parse request body as JSON", http.StatusBadRequest)
659 return
660 }
661 if body.Operation != operationDownload && body.Operation != operationUpload {
662 makeRespError(ctx, w, "Invalid operation specified", http.StatusBadRequest)
663 return
664 }
665
666 or := operationRequest{
667 operation: body.Operation,
668 repository: repo,
669 }
670 if body.Ref != nil {
671 or.refspec = &body.Ref.Name
672 }
673 if !h.authorizeBatchAPI(w, r.WithContext(ctx), or) {
674 return
675 }
676
677 if body.HashAlgo != hashAlgoSHA256 {
678 makeRespError(ctx, w, "Unsupported hash algorithm specified", http.StatusConflict)
679 return
680 }
681
682 if len(body.Transfers) != 0 && !slices.Contains(body.Transfers, transferAdapterBasic) {
683 makeRespError(ctx, w, "Unsupported transfer adapter specified (supported: basic)", http.StatusConflict)
684 return
685 }
686
687 var objects []parsedBatchObject
688 for _, obj := range body.Objects {
689 oid := strings.ToLower(obj.OID)
690 if !isValidSHA256Hash(oid) {
691 makeRespError(ctx, w, "Invalid hash format in object ID", http.StatusBadRequest)
692 return
693 }
694 objects = append(objects, parsedBatchObject{
695 firstByte: oid[:2],
696 secondByte: oid[2:4],
697 fullHash: oid,
698 size: obj.Size,
699 })
700 }
701
702 resp := batchResponse{
703 Transfer: transferAdapterBasic,
704 HashAlgo: hashAlgoSHA256,
705 }
706 for _, obj := range objects {
707 switch body.Operation {
708 case operationDownload:
709 resp.Objects = append(resp.Objects, h.handleDownloadObject(ctx, repo, obj))
710 case operationUpload:
711 if respObj := h.handleUploadObject(ctx, repo, obj); respObj != nil {
712 resp.Objects = append(resp.Objects, *respObj)
713 }
714 }
715 }
716
717 w.Header().Set("Content-Type", lfsMIME)
718 w.WriteHeader(http.StatusOK)
719 json.NewEncoder(w).Encode(resp)
720}
721
722func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
723 reqID := xid.New().String()
724 ctx := context.WithValue(r.Context(), requestIDKey, reqID)
725 w.Header().Set("X-Request-Id", reqID)
726
727 defer func() {
728 if r := recover(); r != nil {
729 reqlog(ctx, "Panic when serving request: %s", debug.Stack())
730 }
731 }()
732
733 reqPath := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
734
735 if submatches := reBatchAPI.FindStringSubmatch(reqPath); len(submatches) == 2 {
736 if r.Method != http.MethodPost {
737 makeRespError(ctx, w, "Method not allowed", http.StatusMethodNotAllowed)
738 return
739 }
740
741 repo := strings.TrimPrefix(path.Clean(submatches[1]), "/")
742 reqlog(ctx, "Handling batch API request for repository: %s", repo)
743
744 h.handleBatchAPI(w, r.WithContext(ctx), repo)
745 return
746 }
747
748 if submatches := reObjUpload.FindStringSubmatch(reqPath); len(submatches) == 5 {
749 oid0, oid1, oid := submatches[2], submatches[3], submatches[4]
750
751 if !isValidSHA256Hash(oid) {
752 panic("Regex should only allow valid SHA256 hashes")
753 }
754 if oid0 != oid[:2] || oid1 != oid[2:4] {
755 makeRespError(ctx, w, "Bad URL format: malformed OID pattern", http.StatusBadRequest)
756 return
757 }
758
759 repo := strings.TrimPrefix(path.Clean(submatches[1]), "/")
760 reqlog(ctx, "Handling object PUT for repository: %s, OID: %s", repo, oid)
761
762 switch r.Method {
763 case http.MethodGet:
764 h.handleGetObject(w, r.WithContext(ctx), repo, oid)
765 case http.MethodPut:
766 h.handlePutObject(w, r.WithContext(ctx), repo, oid)
767 default:
768 makeRespError(ctx, w, "Method not allowed", http.StatusMethodNotAllowed)
769 }
770
771 return
772 }
773
774 makeRespError(ctx, w, "Not found", http.StatusNotFound)
775}
776
777func reqlog(ctx context.Context, msg string, args ...any) {
778 if val := ctx.Value(requestIDKey); val != nil {
779 fmt.Fprintf(os.Stderr, "[%s] ", val.(string))
780 }
781 fmt.Fprintf(os.Stderr, msg, args...)
782 fmt.Fprint(os.Stderr, "\n")
783}
784
785func log(msg string, args ...any) {
786 fmt.Fprintf(os.Stderr, msg, args...)
787 fmt.Fprint(os.Stderr, "\n")
788}
789
790func die(msg string, args ...any) {
791 log("Environment variables: (dying)")
792 for _, s := range os.Environ() {
793 log(" %s", s)
794 }
795 log(msg, args...)
796 os.Exit(1)
797}
798
799func loadPrivateKey(path string) ed25519.PrivateKey {
800 raw, err := os.ReadFile(path)
801 if err != nil {
802 die("Failed to open specified public key: %s", err)
803 }
804 raw = bytes.TrimSpace(raw)
805
806 if hex.DecodedLen(len(raw)) != ed25519.SeedSize {
807 die("Specified public key file does not contain key (seed) of appropriate length")
808 }
809 decoded := make([]byte, hex.DecodedLen(len(raw)))
810 if _, err = hex.Decode(decoded, raw); err != nil {
811 die("Failed to decode specified public key: %s", err)
812 }
813 return ed25519.NewKeyFromSeed(decoded)
814}
815
816func wipe(b []byte) {
817 for i := range b {
818 b[i] = 0
819 }
820}
821
822func main() {
823 anonUser := os.Getenv("GITOLFS3_ANON_USER")
824 privateKeyPath := os.Getenv("GITOLFS3_PRIVATE_KEY_PATH")
825 endpoint := os.Getenv("GITOLFS3_S3_ENDPOINT")
826 bucket := os.Getenv("GITOLFS3_S3_BUCKET")
827 accessKeyIDFile := os.Getenv("GITOLFS3_S3_ACCESS_KEY_ID_FILE")
828 secretAccessKeyFile := os.Getenv("GITOLFS3_S3_SECRET_ACCESS_KEY_FILE")
829 gitolitePath := os.Getenv("GITOLFS3_GITOLITE_PATH")
830 baseURLStr := os.Getenv("GITOLFS3_BASE_URL")
831 listenHost := os.Getenv("GITOLFS3_LISTEN_HOST")
832 listenPort := os.Getenv("GITOLFS3_LISTEN_PORT")
833 exportAllForwardedHostsStr := os.Getenv("GITOLFS3_EXPORT_ALL_FORWARDED_HOSTS")
834
835 listenAddr := net.JoinHostPort(listenHost, listenPort)
836 exportAllForwardedHosts := strings.Split(exportAllForwardedHostsStr, ",")
837
838 if gitolitePath == "" {
839 gitolitePath = "gitolite"
840 }
841
842 if anonUser == "" {
843 die("Fatal: expected environment variable GITOLFS3_ANON_USER to be set")
844 }
845 if privateKeyPath == "" {
846 die("Fatal: expected environment variable GITOLFS3_PRIVATE_KEY_PATH to be set")
847 }
848 if listenPort == "" {
849 die("Fatal: expected environment variable GITOLFS3_LISTEN_PORT to be set")
850 }
851 if baseURLStr == "" {
852 die("Fatal: expected environment variable GITOLFS3_BASE_URL to be set")
853 }
854 if endpoint == "" {
855 die("Fatal: expected environment variable GITOLFS3_S3_ENDPOINT to be set")
856 }
857 if bucket == "" {
858 die("Fatal: expected environment variable GITOLFS3_S3_BUCKET to be set")
859 }
860
861 if accessKeyIDFile == "" {
862 die("Fatal: expected environment variable GITOLFS3_S3_ACCESS_KEY_ID_FILE to be set")
863 }
864 if secretAccessKeyFile == "" {
865 die("Fatal: expected environment variable GITOLFS3_S3_SECRET_ACCESS_KEY_FILE to be set")
866 }
867
868 accessKeyID, err := os.ReadFile(accessKeyIDFile)
869 if err != nil {
870 die("Fatal: failed to read access key ID from specified file: %s", err)
871 }
872 secretAccessKey, err := os.ReadFile(secretAccessKeyFile)
873 if err != nil {
874 die("Fatal: failed to read secret access key from specified file: %s", err)
875 }
876
877 privateKey := loadPrivateKey(privateKeyPath)
878 defer wipe(privateKey)
879
880 baseURL, err := url.Parse(baseURLStr)
881 if err != nil {
882 die("Fatal: provided BASE_URL has bad format: %s", err)
883 }
884
885 mc, err := minio.New(endpoint, &minio.Options{
886 Creds: credentials.NewStaticV4(string(accessKeyID), string(secretAccessKey), ""),
887 Secure: true,
888 })
889 if err != nil {
890 die("Fatal: failed to create S3 client: %s", err)
891 }
892
893 h := &handler{mc, bucket, anonUser, gitolitePath, privateKey, baseURL, exportAllForwardedHosts}
894 if err = http.ListenAndServe(listenAddr, h); err != nil {
895 die("Fatal: failed to serve CGI: %s", err)
896 }
897}
diff --git a/cmd/gitolfs3-gen-ed25519-key/main.go b/cmd/gitolfs3-gen-ed25519-key/main.go
deleted file mode 100644
index 8288fd1..0000000
--- a/cmd/gitolfs3-gen-ed25519-key/main.go
+++ /dev/null
@@ -1,31 +0,0 @@
1package main
2
3import (
4 "crypto/ed25519"
5 "crypto/rand"
6 "encoding/hex"
7 "fmt"
8 "os"
9)
10
11func wipe(b []byte) {
12 for i := range b {
13 b[i] = 0
14 }
15}
16
17func main() {
18 publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
19 if err != nil {
20 fmt.Fprintf(os.Stderr, "Failed to generate ED25519 key: %s", err)
21 os.Exit(1)
22 }
23 defer wipe(privateKey)
24
25 enc := hex.NewEncoder(os.Stdout)
26 print("Public ")
27 enc.Write(publicKey)
28 print("\nPrivate ")
29 enc.Write(privateKey.Seed())
30 println()
31}
diff --git a/rs/common/Cargo.toml b/common/Cargo.toml
index 20d9bdd..20d9bdd 100644
--- a/rs/common/Cargo.toml
+++ b/common/Cargo.toml
diff --git a/rs/common/src/lib.rs b/common/src/lib.rs
index 995352d..995352d 100644
--- a/rs/common/src/lib.rs
+++ b/common/src/lib.rs
diff --git a/flake.nix b/flake.nix
index db5fc93..458ecac 100644
--- a/flake.nix
+++ b/flake.nix
@@ -17,19 +17,14 @@
17 17
18 craneLib = crane.lib.${system}; 18 craneLib = crane.lib.${system};
19 19
20 gitolfs3 = pkgs.buildGoModule { 20 gitolfs3 = craneLib.buildPackage {
21 name = "gitolfs3"; 21 pname = "gitolfs3";
22 src = ./.; 22 version = "0.1.0";
23 vendorHash = "sha256-3JfeOHbqcgv4D3r/W4FwrXRs1raiQeOxifhO7qH5Wnc="; 23 src = craneLib.cleanCargoSource (craneLib.path ./.);
24 }; 24 };
25 in 25 in
26 { 26 {
27 packages.gitolfs3 = gitolfs3; 27 packages.gitolfs3 = gitolfs3;
28 packages.gitolfs3-rs = craneLib.buildPackage {
29 pname = "gitolfs3";
30 version = "0.1.0";
31 src = craneLib.cleanCargoSource (craneLib.path ./rs);
32 };
33 packages.default = self.packages.${system}.gitolfs3; 28 packages.default = self.packages.${system}.gitolfs3;
34 29
35 devShells.default = pkgs.mkShell { 30 devShells.default = pkgs.mkShell {
diff --git a/rs/git-lfs-authenticate/Cargo.toml b/git-lfs-authenticate/Cargo.toml
index 217250f..217250f 100644
--- a/rs/git-lfs-authenticate/Cargo.toml
+++ b/git-lfs-authenticate/Cargo.toml
diff --git a/rs/git-lfs-authenticate/src/main.rs b/git-lfs-authenticate/src/main.rs
index 36d7818..36d7818 100644
--- a/rs/git-lfs-authenticate/src/main.rs
+++ b/git-lfs-authenticate/src/main.rs
diff --git a/go.mod b/go.mod
deleted file mode 100644
index f6e0916..0000000
--- a/go.mod
+++ /dev/null
@@ -1,27 +0,0 @@
1module git.fautchen.eu/gitolfs3
2
3go 1.21.4
4
5require (
6 github.com/golang-jwt/jwt/v5 v5.2.0
7 github.com/minio/minio-go/v7 v7.0.66
8 github.com/rs/xid v1.5.0
9)
10
11require (
12 github.com/dustin/go-humanize v1.0.1 // indirect
13 github.com/google/uuid v1.5.0 // indirect
14 github.com/json-iterator/go v1.1.12 // indirect
15 github.com/klauspost/compress v1.17.4 // indirect
16 github.com/klauspost/cpuid/v2 v2.2.6 // indirect
17 github.com/minio/md5-simd v1.1.2 // indirect
18 github.com/minio/sha256-simd v1.0.1 // indirect
19 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
20 github.com/modern-go/reflect2 v1.0.2 // indirect
21 github.com/sirupsen/logrus v1.9.3 // indirect
22 golang.org/x/crypto v0.16.0 // indirect
23 golang.org/x/net v0.19.0 // indirect
24 golang.org/x/sys v0.15.0 // indirect
25 golang.org/x/text v0.14.0 // indirect
26 gopkg.in/ini.v1 v1.67.0 // indirect
27)
diff --git a/go.sum b/go.sum
deleted file mode 100644
index 2097c78..0000000
--- a/go.sum
+++ /dev/null
@@ -1,53 +0,0 @@
1github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
3github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
5github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
6github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
7github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
8github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
9github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
10github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
11github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
12github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
13github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
14github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
15github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
16github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
17github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
18github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
19github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
20github.com/minio/minio-go/v7 v7.0.66 h1:bnTOXOHjOqv/gcMuiVbN9o2ngRItvqE774dG9nq0Dzw=
21github.com/minio/minio-go/v7 v7.0.66/go.mod h1:DHAgmyQEGdW3Cif0UooKOyrT3Vxs82zNdV6tkKhRtbs=
22github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
23github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
24github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
25github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
26github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
27github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
28github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
29github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
30github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
31github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
32github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
33github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
34github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
35github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
36github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
37github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
38github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
39golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY=
40golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
41golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
42golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
43golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
44golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
45golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
46golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
47golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
48golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
49gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
50gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
51gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
52gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
53gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/rs/.gitignore b/rs/.gitignore
deleted file mode 100644
index b83d222..0000000
--- a/rs/.gitignore
+++ /dev/null
@@ -1 +0,0 @@
1/target/
diff --git a/rs/server/Cargo.toml b/server/Cargo.toml
index edb76d8..edb76d8 100644
--- a/rs/server/Cargo.toml
+++ b/server/Cargo.toml
diff --git a/rs/server/src/main.rs b/server/src/main.rs
index 8baa0d6..8baa0d6 100644
--- a/rs/server/src/main.rs
+++ b/server/src/main.rs
diff --git a/rs/shell/Cargo.toml b/shell/Cargo.toml
index 0dcb6d6..0dcb6d6 100644
--- a/rs/shell/Cargo.toml
+++ b/shell/Cargo.toml
diff --git a/rs/shell/src/main.rs b/shell/src/main.rs
index ef0ef48..ef0ef48 100644
--- a/rs/shell/src/main.rs
+++ b/shell/src/main.rs