Skip to content

Commit b836080

Browse files
committed
feat(artifact)!: introduce blob store abstraction with logging and security improvements
Adds BlobStore interface separating artifact metadata persistence from filesystem operations. Includes path traversal protection in local FS, connection pooling fixes, MIME detection refactoring, and structured logging wrappers for debugging cloud storage operations. BREAKING CHANGE: introduce blob store abstraction with logging and security improvements
1 parent 5d0cbc7 commit b836080

16 files changed

Lines changed: 601 additions & 117 deletions

File tree

artifact/blob_store.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package artifact
2+
3+
import (
4+
"bytes"
5+
"crypto/sha256"
6+
"encoding/hex"
7+
"fmt"
8+
"io"
9+
10+
"github.com/flanksource/duty/models"
11+
"github.com/google/uuid"
12+
"gorm.io/gorm"
13+
)
14+
15+
type BlobStore interface {
16+
Write(data Data, artifact *models.Artifact) (*models.Artifact, error)
17+
Read(artifactID uuid.UUID) (*Data, error)
18+
io.Closer
19+
}
20+
21+
type blobStore struct {
22+
fs FilesystemRW
23+
db *gorm.DB
24+
backend string
25+
}
26+
27+
func NewBlobStore(fs FilesystemRW, db *gorm.DB, backend string) BlobStore {
28+
return &blobStore{fs: fs, db: db, backend: backend}
29+
}
30+
31+
func (s *blobStore) Write(data Data, a *models.Artifact) (*models.Artifact, error) {
32+
if data.Content == nil {
33+
return nil, fmt.Errorf("artifact data content is nil")
34+
}
35+
defer func() { _ = data.Content.Close() }()
36+
37+
checksum := sha256.New()
38+
mimeReader := io.TeeReader(data.Content, checksum)
39+
40+
mw := &mimeWriter{Max: maxBytesForMimeDetection}
41+
fileReader := io.TeeReader(mimeReader, mw)
42+
43+
info, err := s.fs.Write(s.db.Statement.Context, data.Filename, fileReader)
44+
if err != nil {
45+
return nil, fmt.Errorf("writing artifact %s: %w", data.Filename, err)
46+
}
47+
48+
if data.ContentType == "" {
49+
data.ContentType = mw.Detect().String()
50+
}
51+
52+
// For inline store, the artifact already has content set
53+
if inlineArt := InlineArtifact(info); inlineArt != nil {
54+
a.Content = inlineArt.Content
55+
a.CompressionType = inlineArt.CompressionType
56+
}
57+
58+
a.Path = data.Filename
59+
a.Filename = info.Name()
60+
a.Size = info.Size()
61+
a.ContentType = data.ContentType
62+
a.Checksum = hex.EncodeToString(checksum.Sum(nil))
63+
64+
if err := s.db.Create(a).Error; err != nil {
65+
return nil, fmt.Errorf("saving artifact to db: %w", err)
66+
}
67+
68+
return a, nil
69+
}
70+
71+
func (s *blobStore) Read(artifactID uuid.UUID) (*Data, error) {
72+
var a models.Artifact
73+
if err := s.db.Where("id = ?", artifactID).First(&a).Error; err != nil {
74+
return nil, fmt.Errorf("finding artifact %s: %w", artifactID, err)
75+
}
76+
77+
if a.IsInline() {
78+
content, err := a.GetContent()
79+
if err != nil {
80+
return nil, fmt.Errorf("decompressing inline artifact %s: %w", artifactID, err)
81+
}
82+
return &Data{
83+
Content: io.NopCloser(bytes.NewReader(content)),
84+
ContentLength: a.Size,
85+
Checksum: a.Checksum,
86+
ContentType: a.ContentType,
87+
Filename: a.Filename,
88+
}, nil
89+
}
90+
91+
r, err := s.fs.Read(s.db.Statement.Context, a.Path)
92+
if err != nil {
93+
return nil, fmt.Errorf("reading artifact %s from %s: %w", artifactID, a.Path, err)
94+
}
95+
96+
return &Data{
97+
Content: r,
98+
ContentLength: a.Size,
99+
Checksum: a.Checksum,
100+
ContentType: a.ContentType,
101+
Filename: a.Filename,
102+
}, nil
103+
}
104+
105+
func (s *blobStore) Close() error {
106+
return s.fs.Close()
107+
}

artifact/clients/aws/fileinfo.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ type S3FileInfo struct {
1717
}
1818

1919
func (obj S3FileInfo) Name() string {
20+
if obj.Object.Key == nil {
21+
return ""
22+
}
2023
return *obj.Object.Key
2124
}
2225

artifact/clients/sftp/sftp.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ import (
55
"golang.org/x/crypto/ssh"
66
)
77

8+
// SSHConnect creates an SFTP client connection.
9+
// NOTE: Uses InsecureIgnoreHostKey because artifact storage targets are
10+
// configured by admins via trusted connection objects, not user input.
811
func SSHConnect(host, user, password string) (*sftp.Client, error) {
912
config := &ssh.ClientConfig{
1013
User: user,
1114
Auth: []ssh.AuthMethod{
1215
ssh.Password(password),
1316
},
14-
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
17+
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec
1518
}
1619

1720
conn, err := ssh.Dial("tcp", host, config)
@@ -21,6 +24,7 @@ func SSHConnect(host, user, password string) (*sftp.Client, error) {
2124

2225
client, err := sftp.NewClient(conn)
2326
if err != nil {
27+
conn.Close()
2428
return nil, err
2529
}
2630

artifact/clients/smb/smb.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,14 @@ func SMBConnect(server string, port, share string, auth types.Authentication) (*
3535

3636
s, err := d.Dial(conn)
3737
if err != nil {
38+
conn.Close()
3839
return nil, err
3940
}
4041
smb.Session = s
4142
fs, err := s.Mount(share)
4243
if err != nil {
44+
_ = s.Logoff()
45+
conn.Close()
4346
return nil, err
4447
}
4548

artifact/data.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package artifact
2+
3+
import (
4+
"fmt"
5+
"io"
6+
7+
"github.com/flanksource/clicky"
8+
"github.com/flanksource/clicky/api"
9+
"github.com/gabriel-vasile/mimetype"
10+
)
11+
12+
type Data struct {
13+
Content io.ReadCloser
14+
ContentLength int64
15+
Checksum string
16+
ContentType string
17+
Filename string
18+
}
19+
20+
func (d Data) Pretty() api.Text {
21+
s := clicky.Text(d.Filename, "font-bold")
22+
if d.ContentType != "" {
23+
s = s.AddText(" "+d.ContentType, "text-gray-500")
24+
}
25+
if d.ContentLength > 0 {
26+
s = s.AddText(fmt.Sprintf(" (%s)", formatBytes(d.ContentLength)), "text-gray-400")
27+
}
28+
if d.Checksum != "" {
29+
short := d.Checksum
30+
if len(short) > 8 {
31+
short = short[:8]
32+
}
33+
s = s.AddText(" sha:"+short, "text-yellow-500")
34+
}
35+
return s
36+
}
37+
38+
func formatBytes(b int64) string {
39+
switch {
40+
case b >= 1<<20:
41+
return fmt.Sprintf("%.1f MB", float64(b)/(1<<20))
42+
case b >= 1<<10:
43+
return fmt.Sprintf("%.1f KB", float64(b)/(1<<10))
44+
default:
45+
return fmt.Sprintf("%d B", b)
46+
}
47+
}
48+
49+
const maxBytesForMimeDetection = 512 * 1024
50+
51+
type mimeWriter struct {
52+
buffer []byte
53+
Max int
54+
}
55+
56+
func (t *mimeWriter) Write(bb []byte) (n int, err error) {
57+
if len(t.buffer) < t.Max {
58+
rem := t.Max - len(t.buffer)
59+
if rem > len(bb) {
60+
rem = len(bb)
61+
}
62+
t.buffer = append(t.buffer, bb[:rem]...)
63+
}
64+
return len(bb), nil
65+
}
66+
67+
func (t *mimeWriter) Detect() *mimetype.MIME {
68+
return mimetype.Detect(t.buffer)
69+
}

artifact/fs/local.go

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,22 @@ import (
1212
"github.com/flanksource/duty/artifact"
1313
)
1414

15+
func (t *localFS) safePath(path string) (string, error) {
16+
full := filepath.Join(t.base, path)
17+
abs, err := filepath.Abs(full)
18+
if err != nil {
19+
return "", fmt.Errorf("resolving path: %w", err)
20+
}
21+
baseAbs, err := filepath.Abs(t.base)
22+
if err != nil {
23+
return "", fmt.Errorf("resolving base: %w", err)
24+
}
25+
if !strings.HasPrefix(abs, baseAbs) {
26+
return "", fmt.Errorf("path %q escapes base directory", path)
27+
}
28+
return full, nil
29+
}
30+
1531
type localFS struct {
1632
base string
1733
}
@@ -80,28 +96,38 @@ func (t *localFS) ReadDirGlob(name string) ([]artifact.FileInfo, error) {
8096
}
8197

8298
func (t *localFS) Stat(name string) (os.FileInfo, error) {
83-
return os.Stat(filepath.Join(t.base, name))
99+
p, err := t.safePath(name)
100+
if err != nil {
101+
return nil, err
102+
}
103+
return os.Stat(p)
84104
}
85105

86106
func (t *localFS) Read(_ gocontext.Context, path string) (io.ReadCloser, error) {
87-
return os.Open(filepath.Join(t.base, path))
107+
p, err := t.safePath(path)
108+
if err != nil {
109+
return nil, err
110+
}
111+
return os.Open(p)
88112
}
89113

90114
func (t *localFS) Write(_ gocontext.Context, path string, data io.Reader) (os.FileInfo, error) {
91-
fullpath := filepath.Join(t.base, path)
92-
93-
err := os.MkdirAll(filepath.Dir(fullpath), os.ModePerm)
115+
fullpath, err := t.safePath(path)
94116
if err != nil {
117+
return nil, err
118+
}
119+
120+
if err := os.MkdirAll(filepath.Dir(fullpath), os.ModePerm); err != nil {
95121
return nil, fmt.Errorf("error creating base directory: %w", err)
96122
}
97123

98124
f, err := os.Create(fullpath)
99125
if err != nil {
100126
return nil, err
101127
}
128+
defer f.Close()
102129

103-
_, err = io.Copy(f, data)
104-
if err != nil {
130+
if _, err = io.Copy(f, data); err != nil {
105131
return nil, err
106132
}
107133

artifact/fs/save.go

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,14 @@ type MIMEWriter struct {
1818
}
1919

2020
func (t *MIMEWriter) Write(bb []byte) (n int, err error) {
21-
if len(t.buffer) > t.Max {
22-
return 0, nil
21+
if len(t.buffer) < t.Max {
22+
rem := t.Max - len(t.buffer)
23+
if rem > len(bb) {
24+
rem = len(bb)
25+
}
26+
t.buffer = append(t.buffer, bb[:rem]...)
2327
}
24-
25-
rem := t.Max - len(t.buffer)
26-
if rem > len(bb) {
27-
rem = len(bb)
28-
}
29-
t.buffer = append(t.buffer, bb[:rem]...)
30-
return rem, nil
28+
return len(bb), nil
3129
}
3230

3331
func (t *MIMEWriter) Detect() *mimetype.MIME {

artifact/fs/smb.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ func NewSMBFS(server string, port, share string, auth types.Authentication) (*sm
4141
return &smbFS{SMBSession: session}, nil
4242
}
4343

44+
func (s *smbFS) Close() error {
45+
return s.SMBSession.Close()
46+
}
47+
4448
func (s *smbFS) Read(_ gocontext.Context, path string) (io.ReadCloser, error) {
4549
return s.Open(path)
4650
}
@@ -50,6 +54,7 @@ func (s *smbFS) Write(_ gocontext.Context, path string, data io.Reader) (os.File
5054
if err != nil {
5155
return nil, err
5256
}
57+
defer f.Close()
5358

5459
if _, err = io.Copy(f, data); err != nil {
5560
return nil, fmt.Errorf("error writing file: %w", err)
@@ -90,7 +95,7 @@ func (t *smbFS) ReadDirGlob(name string) ([]artifact.FileInfo, error) {
9095
if err != nil {
9196
return nil, err
9297
}
93-
output = append(output, &SMBFileInfo{Base: name, FileInfo: info})
98+
output = append(output, &SMBFileInfo{Base: base, FileInfo: info})
9499
}
95100

96101
return output, nil

artifact/fs/ssh.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ func (s *sshFS) Read(_ gocontext.Context, path string) (io.ReadCloser, error) {
8989
return s.Open(path)
9090
}
9191

92+
func (s *sshFS) Close() error {
93+
return s.Client.Close()
94+
}
95+
9296
func (s *sshFS) Write(_ gocontext.Context, path string, data io.Reader) (os.FileInfo, error) {
9397
dir := filepath.Dir(path)
9498
if err := s.MkdirAll(dir); err != nil {
@@ -99,6 +103,7 @@ func (s *sshFS) Write(_ gocontext.Context, path string, data io.Reader) (os.File
99103
if err != nil {
100104
return nil, fmt.Errorf("error creating file: %w", err)
101105
}
106+
defer f.Close()
102107

103108
if _, err = io.Copy(f, data); err != nil {
104109
return nil, fmt.Errorf("error writing to file: %w", err)

0 commit comments

Comments
 (0)