package git import ( "context" "crypto/rand" "errors" "fmt" "io" "path" "path/filepath" "strconv" "time" "charm.land/log/v2" "github.com/charmbracelet/git-lfs-transfer/transfer" "github.com/charmbracelet/soft-serve/pkg/config" "github.com/charmbracelet/soft-serve/pkg/db" "github.com/charmbracelet/soft-serve/pkg/db/models" "github.com/charmbracelet/soft-serve/pkg/lfs" "github.com/charmbracelet/soft-serve/pkg/proto" "github.com/charmbracelet/soft-serve/pkg/storage" "github.com/charmbracelet/soft-serve/pkg/store" ) // lfsTransfer implements transfer.Backend. type lfsTransfer struct { ctx context.Context cfg *config.Config dbx *db.DB store store.Store logger *log.Logger storage storage.Storage repo proto.Repository } var _ transfer.Backend = &lfsTransfer{} // errInvalidOid is returned when a client sends a malformed object ID. Object // IDs are interpolated into storage paths, so anything outside the SHA-256 // alphabet is a path traversal attempt. // // It wraps transfer.ErrParseError so the processor answers with a 400 instead // of falling through to a generic internal error, and lfs.ErrInvalidOIDFormat // so callers can still test for the cause. var errInvalidOid = fmt.Errorf("%w: %w", transfer.ErrParseError, lfs.ErrInvalidOIDFormat) // LFSTransfer is a Git LFS transfer service handler. // ctx is expected to have proto.User, *backend.Backend, *log.Logger, // *config.Config, *db.DB, and store.Store. // The first arg in cmd.Args should be the repo path. // The second arg in cmd.Args should be the LFS operation (download or upload). func LFSTransfer(ctx context.Context, cmd ServiceCommand) error { if len(cmd.Args) < 2 { return errors.New("missing args") } op := cmd.Args[1] if op != lfs.OperationDownload && op != lfs.OperationUpload { return errors.New("invalid operation") } logger := log.FromContext(ctx).WithPrefix("lfs-transfer") handler := transfer.NewPktline(cmd.Stdin, cmd.Stdout, &lfsLogger{logger}) repo := proto.RepositoryFromContext(ctx) if repo == nil { logger.Error("no repository in context") return proto.ErrRepoNotFound } // Advertise capabilities. for _, cap := range transfer.Capabilities { if err := handler.WritePacketText(cap); err != nil { logger.Errorf("error sending capability: %s: %v", cap, err) return err } } if err := handler.WriteFlush(); err != nil { logger.Error("error sending flush", "err", err) return err } repoID := strconv.FormatInt(repo.ID(), 10) cfg := config.FromContext(ctx) processor := transfer.NewProcessor(handler, &lfsTransfer{ ctx: ctx, cfg: cfg, dbx: db.FromContext(ctx), store: store.FromContext(ctx), logger: logger, storage: storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID)), repo: repo, }, &lfsLogger{logger}) return processor.ProcessCommands(op) } // Batch implements transfer.Backend. func (t *lfsTransfer) Batch(_ string, pointers []transfer.BatchItem, _ transfer.Args) ([]transfer.BatchItem, error) { for i := range pointers { p := transfer.Pointer{Oid: pointers[i].Oid, Size: pointers[i].Size} if !p.IsValid() { return pointers, errInvalidOid } } for i := range pointers { obj, err := t.store.GetLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), pointers[i].Oid) if err != nil && !errors.Is(err, db.ErrRecordNotFound) { return pointers, db.WrapError(err) } pointers[i].Present, err = t.storage.Exists(path.Join("objects", pointers[i].RelativePath())) if err != nil { return pointers, err } if pointers[i].Present && obj.ID == 0 { if err := t.store.CreateLFSObject(t.ctx, t.dbx, t.repo.ID(), pointers[i].Oid, pointers[i].Size); err != nil { return pointers, db.WrapError(err) } } } return pointers, nil } // Download implements transfer.Backend. func (t *lfsTransfer) Download(oid string, _ transfer.Args) (io.ReadCloser, int64, error) { p := transfer.Pointer{Oid: oid} if !p.IsValid() { return nil, 0, errInvalidOid } cfg := config.FromContext(t.ctx) repoID := strconv.FormatInt(t.repo.ID(), 10) strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID)) pointer := transfer.Pointer{Oid: oid} obj, err := strg.Open(path.Join("objects", pointer.RelativePath())) if err != nil { return nil, 0, err } stat, err := obj.Stat() if err != nil { return nil, 0, err } return obj, stat.Size(), nil } // Upload implements transfer.Backend. func (t *lfsTransfer) Upload(oid string, size int64, r io.Reader, _ transfer.Args) error { p := transfer.Pointer{Oid: oid} if !p.IsValid() { return errInvalidOid } if r == nil { return fmt.Errorf("no reader: %w", transfer.ErrMissingData) } tempDir := "incomplete" randBytes := make([]byte, 12) if _, err := rand.Read(randBytes); err != nil { return err } tempName := fmt.Sprintf("%s%x", oid, randBytes) tempName = path.Join(tempDir, tempName) written, err := t.storage.Put(tempName, r) if err != nil { t.logger.Errorf("error putting object: %v", err) return err } pointer := transfer.Pointer{ Oid: oid, } if size > 0 { pointer.Size = size } else { pointer.Size = written } if err := t.store.CreateLFSObject(t.ctx, t.dbx, t.repo.ID(), pointer.Oid, pointer.Size); err != nil { return db.WrapError(err) } // Rename takes names relative to the storage root, not the absolute path // the temp file happens to live at. expectedPath := path.Join("objects", pointer.RelativePath()) if err := t.storage.Rename(tempName, expectedPath); err != nil { t.logger.Errorf("error renaming object: %v", err) _ = t.store.DeleteLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), pointer.Oid) return err } return nil } // Verify implements transfer.Backend. func (t *lfsTransfer) Verify(oid string, size int64, _ transfer.Args) (transfer.Status, error) { p := transfer.Pointer{Oid: oid} if !p.IsValid() { return transfer.NewStatus(transfer.StatusConflict, "invalid OID format"), nil } obj, err := t.store.GetLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), oid) if err != nil { if errors.Is(err, db.ErrRecordNotFound) { return transfer.NewStatus(transfer.StatusNotFound, "object not found"), nil } t.logger.Errorf("error getting object: %v", err) return nil, err } if obj.Size != size { t.logger.Errorf("size mismatch: %d != %d", obj.Size, size) return transfer.NewStatus(transfer.StatusConflict, "size mismatch"), nil } return transfer.SuccessStatus(), nil } type lfsLockBackend struct { *lfsTransfer args map[string]string user proto.User } var _ transfer.LockBackend = (*lfsLockBackend)(nil) // LockBackend implements transfer.Backend. func (t *lfsTransfer) LockBackend(args transfer.Args) transfer.LockBackend { user := proto.UserFromContext(t.ctx) if user == nil { t.logger.Errorf("no user in context while creating lock backend, repo %s", t.repo.Name()) return nil } return &lfsLockBackend{t, args, user} } // Create implements transfer.LockBackend. func (l *lfsLockBackend) Create(path string, refname string) (transfer.Lock, error) { var lock LFSLock if err := l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error { if err := l.store.CreateLFSLockForUser(l.ctx, tx, l.repo.ID(), l.user.ID(), path, refname); err != nil { return db.WrapError(err) } var err error lock.lock, err = l.store.GetLFSLockForUserPath(l.ctx, tx, l.repo.ID(), l.user.ID(), path) if err != nil { return db.WrapError(err) } lock.owner, err = l.store.GetUserByID(l.ctx, tx, lock.lock.UserID) return db.WrapError(err) }); err != nil { // Return conflict (409) if the lock already exists. if errors.Is(err, db.ErrDuplicateKey) { return nil, transfer.ErrConflict } l.logger.Errorf("error creating lock: %v", err) return nil, err } lock.backend = l return &lock, nil } // FromID implements transfer.LockBackend. func (l *lfsLockBackend) FromID(id string) (transfer.Lock, error) { var lock LFSLock iid, err := strconv.ParseInt(id, 10, 64) if err != nil { return nil, err } if err := l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error { var err error lock.lock, err = l.store.GetLFSLockForUserByID(l.ctx, tx, l.repo.ID(), l.user.ID(), iid) if err != nil { return db.WrapError(err) } lock.owner, err = l.store.GetUserByID(l.ctx, tx, lock.lock.UserID) return db.WrapError(err) }); err != nil { if errors.Is(err, db.ErrRecordNotFound) { return nil, transfer.ErrNotFound } l.logger.Errorf("error getting lock: %v", err) return nil, err } lock.backend = l return &lock, nil } // FromPath implements transfer.LockBackend. func (l *lfsLockBackend) FromPath(path string) (transfer.Lock, error) { var lock LFSLock if err := l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error { var err error lock.lock, err = l.store.GetLFSLockForUserPath(l.ctx, tx, l.repo.ID(), l.user.ID(), path) if err != nil { return db.WrapError(err) } lock.owner, err = l.store.GetUserByID(l.ctx, tx, lock.lock.UserID) return db.WrapError(err) }); err != nil { if errors.Is(err, db.ErrRecordNotFound) { return nil, transfer.ErrNotFound } l.logger.Errorf("error getting lock: %v", err) return nil, err } lock.backend = l return &lock, nil } // Range implements transfer.LockBackend. func (l *lfsLockBackend) Range(cursor string, limit int, fn func(transfer.Lock) error) (string, error) { var nextCursor string var locks []*LFSLock page, _ := strconv.Atoi(cursor) if page <= 0 { page = 1 } if limit <= 0 { limit = lfs.DefaultLocksLimit } else if limit > 100 { limit = 100 } if err := l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error { l.logger.Debug("getting locks", "limit", limit, "page", page) mlocks, err := l.store.GetLFSLocks(l.ctx, tx, l.repo.ID(), page, limit) if err != nil { return db.WrapError(err) } if len(mlocks) == limit { nextCursor = strconv.Itoa(page + 1) } users := make(map[int64]models.User, 0) for _, mlock := range mlocks { owner, ok := users[mlock.UserID] if !ok { owner, err = l.store.GetUserByID(l.ctx, tx, mlock.UserID) if err != nil { return db.WrapError(err) } users[mlock.UserID] = owner } locks = append(locks, &LFSLock{lock: mlock, owner: owner, backend: l}) } return nil }); err != nil { return "", err } for _, lock := range locks { if err := fn(lock); err != nil { return "", err } } return nextCursor, nil } // Unlock implements transfer.LockBackend. func (l *lfsLockBackend) Unlock(lock transfer.Lock) error { id, err := strconv.ParseInt(lock.ID(), 10, 64) if err != nil { return err } err = l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error { return db.WrapError( l.store.DeleteLFSLockForUserByID(l.ctx, tx, l.repo.ID(), l.user.ID(), id), ) }) if err != nil { if errors.Is(err, db.ErrRecordNotFound) { return transfer.ErrNotFound } l.logger.Error("error unlocking lock", "err", err) return err } return nil } // LFSLock is a Git LFS lock object. // It implements transfer.Lock. type LFSLock struct { lock models.LFSLock owner models.User backend *lfsLockBackend } var _ transfer.Lock = (*LFSLock)(nil) // AsArguments implements transfer.Lock. func (l *LFSLock) AsArguments() []string { return []string{ fmt.Sprintf("id=%s", l.ID()), fmt.Sprintf("path=%s", l.Path()), fmt.Sprintf("locked-at=%s", l.FormattedTimestamp()), fmt.Sprintf("ownername=%s", l.OwnerName()), } } // AsLockSpec implements transfer.Lock. func (l *LFSLock) AsLockSpec(ownerID bool) ([]string, error) { id := l.ID() spec := []string{ fmt.Sprintf("lock %s", id), fmt.Sprintf("path %s %s", id, l.Path()), fmt.Sprintf("locked-at %s %s", id, l.FormattedTimestamp()), fmt.Sprintf("ownername %s %s", id, l.OwnerName()), } if ownerID { who := "theirs" if l.lock.UserID == l.owner.ID { who = "ours" } spec = append(spec, fmt.Sprintf("owner %s %s", id, who)) } return spec, nil } // FormattedTimestamp implements transfer.Lock. func (l *LFSLock) FormattedTimestamp() string { return l.lock.CreatedAt.Format(time.RFC3339) } // ID implements transfer.Lock. func (l *LFSLock) ID() string { return strconv.FormatInt(l.lock.ID, 10) } // OwnerName implements transfer.Lock. func (l *LFSLock) OwnerName() string { return l.owner.Username } // Path implements transfer.Lock. func (l *LFSLock) Path() string { return l.lock.Path } // Unlock implements transfer.Lock. func (l *LFSLock) Unlock() error { return l.backend.Unlock(l) }