Compare commits
3 Commits
1.0.0
...
bdbe1a9416
| Author | SHA1 | Date | |
|---|---|---|---|
| bdbe1a9416 | |||
| e6801007ee | |||
| a3162a3e5f |
@@ -29,10 +29,7 @@ Key product constraints:
|
|||||||
## Build and test
|
## Build and test
|
||||||
|
|
||||||
Use `make help` as the source of truth for available build/test targets and descriptions.
|
Use `make help` as the source of truth for available build/test targets and descriptions.
|
||||||
Always build and test via make unless it doesn't include targets for that.
|
Format, Test, Build are the required steps before being done with any coding if there are more then one target for any of these then run them all before considering that step complete.
|
||||||
Also in the event there is a test without race checking and one with do both.
|
|
||||||
Otherwise just whats available.
|
|
||||||
To verify your changes you must run tests and do a build.
|
|
||||||
|
|
||||||
## Coding conventions
|
## Coding conventions
|
||||||
|
|
||||||
|
|||||||
@@ -18,26 +18,26 @@ help:
|
|||||||
@echo " make fmt - Format Go code"
|
@echo " make fmt - Format Go code"
|
||||||
@echo " make clean - Remove built artifacts"
|
@echo " make clean - Remove built artifacts"
|
||||||
|
|
||||||
run: dev
|
run: dev # Run the application alias for dev.
|
||||||
|
|
||||||
dev: build
|
dev: build # Run the application.
|
||||||
$(BIN) server --config $(CONFIG)
|
$(BIN) server --config $(CONFIG)
|
||||||
|
|
||||||
build: clean
|
build: clean # Build the application.
|
||||||
npm --prefix ./web/ui run build
|
npm --prefix ./web/ui run build
|
||||||
mkdir -p $(BIN_DIR)
|
mkdir -p $(BIN_DIR)
|
||||||
CGO_ENABLED=0 go build -o $(BIN) $(CMD_DIR)
|
CGO_ENABLED=0 go build -o $(BIN) $(CMD_DIR)
|
||||||
|
|
||||||
docker:
|
docker: # Build the Docker image.
|
||||||
docker build -t $(DOCKER_IMAGE) .
|
docker build -t $(DOCKER_IMAGE) .
|
||||||
|
|
||||||
test:
|
test: # Run the tests.
|
||||||
go test -timeout 30s -shuffle=on ./...
|
go test -timeout 30s -shuffle=on ./...
|
||||||
|
|
||||||
test-race:
|
test-race: # Run the tests with the race detector.
|
||||||
go test -race -timeout 30s -shuffle=on ./...
|
go test -race -timeout 30s -shuffle=on ./...
|
||||||
|
|
||||||
fmt:
|
fmt: # Format the code using gofmt.
|
||||||
go fmt ./...
|
go fmt ./...
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
|
|||||||
@@ -294,4 +294,4 @@ Scratchbox storage is designed for a single process instance per `storage.data_d
|
|||||||
- Do not run multiple scratchbox processes against the same data directory.
|
- Do not run multiple scratchbox processes against the same data directory.
|
||||||
- Metadata/index writes are process-local and are not coordinated with cross-process locking.
|
- Metadata/index writes are process-local and are not coordinated with cross-process locking.
|
||||||
- Scratch payload files and metadata index are encrypted at rest.
|
- Scratch payload files and metadata index are encrypted at rest.
|
||||||
- Scratch IDs are derived from the SHA-256 of the stored blob (encrypted+gzip payload on disk).
|
- Public scratch IDs are short random aliases; stored blobs still use SHA-256-derived internal blob IDs on disk.
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ const (
|
|||||||
indexFilename = "metadata"
|
indexFilename = "metadata"
|
||||||
filesDirName = "scratches"
|
filesDirName = "scratches"
|
||||||
encryptedChunkSize = 64 * 1024
|
encryptedChunkSize = 64 * 1024
|
||||||
|
publicIDLength = 12
|
||||||
|
publicIDAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
|
publicIDMaxAttempts = 8
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrNotFound = errors.New("scratch not found")
|
var ErrNotFound = errors.New("scratch not found")
|
||||||
@@ -33,6 +36,7 @@ var encryptedIndexMagic = [4]byte{'S', 'M', 'D', '1'}
|
|||||||
|
|
||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
BlobID string `json:"blob_id,omitempty"`
|
||||||
FilePath string `json:"file_path"`
|
FilePath string `json:"file_path"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
@@ -146,8 +150,8 @@ func (s *FilesystemStore) CreateWithOriginalName(ctx context.Context, body io.Re
|
|||||||
return Metadata{}, fmt.Errorf("close temp file: %w", closeErr)
|
return Metadata{}, fmt.Errorf("close temp file: %w", closeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
id := hex.EncodeToString(blobHash.Sum(nil))
|
blobID := hex.EncodeToString(blobHash.Sum(nil))
|
||||||
finalPath := filepath.Join(s.filesDir, id)
|
finalPath := filepath.Join(s.filesDir, blobID)
|
||||||
if _, statErr := os.Stat(finalPath); statErr == nil {
|
if _, statErr := os.Stat(finalPath); statErr == nil {
|
||||||
_ = os.Remove(tempFile.Name())
|
_ = os.Remove(tempFile.Name())
|
||||||
return Metadata{}, errors.New("scratch id collision for content hash")
|
return Metadata{}, errors.New("scratch id collision for content hash")
|
||||||
@@ -161,8 +165,17 @@ func (s *FilesystemStore) CreateWithOriginalName(ctx context.Context, body io.Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
publicID, err := reservePublicID(s.metadata)
|
||||||
|
if err != nil {
|
||||||
|
s.mu.Unlock()
|
||||||
|
_ = os.Remove(finalPath)
|
||||||
|
return Metadata{}, err
|
||||||
|
}
|
||||||
meta := Metadata{
|
meta := Metadata{
|
||||||
ID: id,
|
ID: publicID,
|
||||||
|
BlobID: blobID,
|
||||||
FilePath: finalPath,
|
FilePath: finalPath,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
ExpiresAt: now.Add(ttl),
|
ExpiresAt: now.Add(ttl),
|
||||||
@@ -170,16 +183,9 @@ func (s *FilesystemStore) CreateWithOriginalName(ctx context.Context, body io.Re
|
|||||||
OriginalName: normalizeOriginalName(originalName),
|
OriginalName: normalizeOriginalName(originalName),
|
||||||
Size: size,
|
Size: size,
|
||||||
}
|
}
|
||||||
|
s.metadata[publicID] = meta
|
||||||
s.mu.Lock()
|
|
||||||
if _, exists := s.metadata[id]; exists {
|
|
||||||
s.mu.Unlock()
|
|
||||||
_ = os.Remove(finalPath)
|
|
||||||
return Metadata{}, errors.New("scratch id collision in metadata index")
|
|
||||||
}
|
|
||||||
s.metadata[id] = meta
|
|
||||||
if err := s.persistLocked(); err != nil {
|
if err := s.persistLocked(); err != nil {
|
||||||
delete(s.metadata, id)
|
delete(s.metadata, publicID)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
_ = os.Remove(finalPath)
|
_ = os.Remove(finalPath)
|
||||||
return Metadata{}, err
|
return Metadata{}, err
|
||||||
@@ -314,6 +320,15 @@ func (s *FilesystemStore) loadIndex() error {
|
|||||||
if doc.Scratches == nil {
|
if doc.Scratches == nil {
|
||||||
doc.Scratches = map[string]Metadata{}
|
doc.Scratches = map[string]Metadata{}
|
||||||
}
|
}
|
||||||
|
for key, meta := range doc.Scratches {
|
||||||
|
if strings.TrimSpace(meta.ID) == "" {
|
||||||
|
meta.ID = key
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(meta.BlobID) == "" {
|
||||||
|
meta.BlobID = filepath.Base(meta.FilePath)
|
||||||
|
}
|
||||||
|
doc.Scratches[key] = meta
|
||||||
|
}
|
||||||
s.metadata = doc.Scratches
|
s.metadata = doc.Scratches
|
||||||
if ttlRaw := strings.TrimSpace(doc.DefaultTTL); ttlRaw != "" {
|
if ttlRaw := strings.TrimSpace(doc.DefaultTTL); ttlRaw != "" {
|
||||||
ttl, parseErr := time.ParseDuration(ttlRaw)
|
ttl, parseErr := time.ParseDuration(ttlRaw)
|
||||||
@@ -582,6 +597,31 @@ func equalBytes(a, b []byte) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reservePublicID(existing map[string]Metadata) (string, error) {
|
||||||
|
for i := 0; i < publicIDMaxAttempts; i++ {
|
||||||
|
id, err := generatePublicID()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, exists := existing[id]; !exists {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errors.New("unable to reserve unique public id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatePublicID() (string, error) {
|
||||||
|
buf := make([]byte, publicIDLength)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", fmt.Errorf("generate public id bytes: %w", err)
|
||||||
|
}
|
||||||
|
alphabetLen := byte(len(publicIDAlphabet))
|
||||||
|
for i := range buf {
|
||||||
|
buf[i] = publicIDAlphabet[int(buf[i]%alphabetLen)]
|
||||||
|
}
|
||||||
|
return string(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
func encryptIndexPayload(plaintext []byte, aead cipher.AEAD) ([]byte, error) {
|
func encryptIndexPayload(plaintext []byte, aead cipher.AEAD) ([]byte, error) {
|
||||||
nonce := make([]byte, aead.NonceSize())
|
nonce := make([]byte, aead.NonceSize())
|
||||||
if _, err := rand.Read(nonce); err != nil {
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
|||||||
@@ -123,9 +123,12 @@ func TestCreateEncryptedStoreDoesNotExposeGzipHeaderOnDisk(t *testing.T) {
|
|||||||
t.Fatalf("ReadFile(encrypted) error = %v", err)
|
t.Fatalf("ReadFile(encrypted) error = %v", err)
|
||||||
}
|
}
|
||||||
sum := sha256.Sum256(filePayload)
|
sum := sha256.Sum256(filePayload)
|
||||||
wantID := hex.EncodeToString(sum[:])
|
wantBlobID := hex.EncodeToString(sum[:])
|
||||||
if meta.ID != wantID {
|
if meta.BlobID != wantBlobID {
|
||||||
t.Fatalf("meta.ID = %q, want SHA-256 of encrypted blob %q", meta.ID, wantID)
|
t.Fatalf("meta.BlobID = %q, want SHA-256 of encrypted blob %q", meta.BlobID, wantBlobID)
|
||||||
|
}
|
||||||
|
if meta.ID == wantBlobID {
|
||||||
|
t.Fatalf("meta.ID should be a short public id, got blob hash id %q", meta.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user