1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package ciartifacts_test
4
5import (
6 "crypto/sha256"
7 "encoding/hex"
8 "errors"
9 "io"
10 "os"
11 "path/filepath"
12 "strings"
13 "testing"
14
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"
17
18 "bnema.dev/ermite/internal/adapters/ciartifacts"
19 "bnema.dev/ermite/internal/domain"
20 "bnema.dev/ermite/internal/ports"
21)
22
23var ref = ports.CIArtifactRef{RunID: 7, ID: 42}
24
25func digestOf(s string) string {
26 sum := sha256.Sum256([]byte(s))
27 return "sha256:" + hex.EncodeToString(sum[:])
28}
29
30func entries(t *testing.T, dir string) []string {
31 t.Helper()
32 es, err := os.ReadDir(dir)
33 if errors.Is(err, os.ErrNotExist) {
34 return nil
35 }
36 require.NoError(t, err)
37 var out []string
38 for _, e := range es {
39 out = append(out, e.Name())
40 }
41 return out
42}
43
44func TestBlocksAssembleInTheOrderOfTheList(t *testing.T) {
45 ctx := t.Context()
46 root := t.TempDir()
47 s := ciartifacts.New(root)
48
49 // Uploaded out of order; the list decides.
50 for file, data := range map[string]string{"02.aa": "world", "01.bb": "hello ", "03.cc": "!"} {
51 n, err := s.PutBlock(ctx, ref, file, strings.NewReader(data))
52 require.NoError(t, err)
53 assert.EqualValues(t, len(data), n)
54 }
55 size, digest, err := s.Assemble(ctx, ref, []string{"01.bb", "02.aa", "03.cc", "03.cc"})
56 require.NoError(t, err)
57 assert.EqualValues(t, len("hello world!!"), size)
58 assert.Equal(t, digestOf("hello world!!"), digest, "a repeated id is repeated content")
59
60 // The documented layout, named by ids only.
61 b, err := os.ReadFile(filepath.Join(root, "00000000000000000007", "00000000000000000042"))
62 require.NoError(t, err)
63 assert.Equal(t, "hello world!!", string(b))
64 for _, e := range entries(t, filepath.Join(root, "00000000000000000007")) {
65 assert.False(t, strings.HasPrefix(e, ".tmp-"), "no temporary file left: %s", e)
66 }
67
68 // Offset reads.
69 r, err := s.Open(ctx, ref, 6)
70 require.NoError(t, err)
71 rest, err := io.ReadAll(r)
72 require.NoError(t, err)
73 require.NoError(t, r.Close())
74 assert.Equal(t, "world!!", string(rest))
75
76 // A new assembly replaces the content whole.
77 size, digest, err = s.Assemble(ctx, ref, nil)
78 require.NoError(t, err)
79 assert.Zero(t, size, "an empty list is an empty content")
80 assert.Equal(t, digestOf(""), digest)
81
82 ids, err := s.ArtifactIDs(ctx, 7)
83 require.NoError(t, err)
84 assert.Equal(t, []int64{42}, ids)
85 runs, err := s.RunIDs(ctx)
86 require.NoError(t, err)
87 assert.Equal(t, []int64{7}, runs)
88
89 require.NoError(t, s.RemoveBlocks(ctx, ref))
90 assert.Equal(t, []string{"00000000000000000042"}, entries(t, filepath.Join(root, "00000000000000000007")))
91 require.NoError(t, s.Remove(ctx, ref))
92 assert.Empty(t, entries(t, filepath.Join(root, "00000000000000000007")))
93 _, err = s.Open(ctx, ref, 0)
94 assert.ErrorIs(t, err, domain.ErrNotFound)
95 require.NoError(t, s.RemoveRuns(ctx, []int64{7}))
96 assert.Empty(t, entries(t, root))
97}
98
99func TestAMissingBlockIsNotFoundAndLeavesNoContent(t *testing.T) {
100 ctx := t.Context()
101 root := t.TempDir()
102 s := ciartifacts.New(root)
103 _, err := s.PutBlock(ctx, ref, "01.aa", strings.NewReader("x"))
104 require.NoError(t, err)
105 _, _, err = s.Assemble(ctx, ref, []string{"01.aa", "02.aa"})
106 assert.ErrorIs(t, err, domain.ErrNotFound)
107 _, err = s.Open(ctx, ref, 0)
108 assert.ErrorIs(t, err, domain.ErrNotFound, "no content appears")
109 for _, e := range entries(t, filepath.Join(root, "00000000000000000007")) {
110 assert.False(t, strings.HasPrefix(e, ".tmp-"), e)
111 }
112}
113
114type failingReader struct{ n int }
115
116func (f *failingReader) Read(p []byte) (int, error) {
117 if f.n == 0 {
118 return 0, errors.New("stream broken")
119 }
120 f.n--
121 p[0] = 'x'
122 return 1, nil
123}
124
125func TestABrokenSourceLeavesNoBlock(t *testing.T) {
126 ctx := t.Context()
127 root := t.TempDir()
128 s := ciartifacts.New(root)
129 _, err := s.PutBlock(ctx, ref, "01.aa", &failingReader{n: 3})
130 require.EqualError(t, err, "stream broken", "the source's error as it is")
131 assert.Empty(t, entries(t, filepath.Join(root, "00000000000000000007", "00000000000000000042.blocks")))
132
133 // A file name is never reused: the second put of a name fails.
134 _, err = s.PutBlock(ctx, ref, "01.aa", strings.NewReader("a"))
135 require.NoError(t, err)
136 _, err = s.PutBlock(ctx, ref, "01.aa", strings.NewReader("b"))
137 assert.Error(t, err)
138}
139
140func TestNamesOutsideTheRulesNeverReachTheFileSystem(t *testing.T) {
141 ctx := t.Context()
142 root := t.TempDir()
143 s := ciartifacts.New(filepath.Join(root, "store"))
144 for _, name := range []string{"", "../x", "a/b", ".hidden", "..", "ZZ", "01.aa/../../x", strings.Repeat("a", 201)} {
145 _, err := s.PutBlock(ctx, ref, name, strings.NewReader("x"))
146 assert.ErrorIs(t, err, domain.ErrInvalid, "%q", name)
147 _, _, err = s.Assemble(ctx, ref, []string{name})
148 assert.Error(t, err, "%q", name)
149 }
150 for _, bad := range []ports.CIArtifactRef{{RunID: 0, ID: 1}, {RunID: 1, ID: -1}} {
151 _, err := s.PutBlock(ctx, bad, "01.aa", strings.NewReader("x"))
152 assert.ErrorIs(t, err, domain.ErrInvalid)
153 _, err = s.Open(ctx, bad, 0)
154 assert.ErrorIs(t, err, domain.ErrNotFound)
155 }
156 for _, e := range entries(t, root) {
157 assert.Equal(t, "store", e, "nothing written beside the store")
158 }
159}
160
161func TestOrphanListingSeesEveryKindOfFile(t *testing.T) {
162 ctx := t.Context()
163 root := t.TempDir()
164 s := ciartifacts.New(root)
165 run := filepath.Join(root, "00000000000000000009")
166 require.NoError(t, os.MkdirAll(filepath.Join(run, "00000000000000000003.blocks"), 0o700))
167 require.NoError(t, os.WriteFile(filepath.Join(run, "00000000000000000001"), nil, 0o600))
168 require.NoError(t, os.WriteFile(filepath.Join(run, ".tmp-00000000000000000002-123"), nil, 0o600))
169 require.NoError(t, os.WriteFile(filepath.Join(run, "junk"), nil, 0o600))
170 require.NoError(t, os.MkdirAll(filepath.Join(root, "not-a-run"), 0o700))
171
172 ids, err := s.ArtifactIDs(ctx, 9)
173 require.NoError(t, err)
174 assert.Equal(t, []int64{1, 2, 3}, ids)
175 runs, err := s.RunIDs(ctx)
176 require.NoError(t, err)
177 assert.Equal(t, []int64{9}, runs)
178
179 // Remove takes the assembly in progress too.
180 require.NoError(t, s.Remove(ctx, ports.CIArtifactRef{RunID: 9, ID: 2}))
181 ids, _ = s.ArtifactIDs(ctx, 9)
182 assert.Equal(t, []int64{1, 3}, ids)
183}