Compare commits
1 Commits
1.0.0
...
a3162a3e5f
| Author | SHA1 | Date | |
|---|---|---|---|
| a3162a3e5f |
@@ -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.
|
||||||
|
|||||||
@@ -21,9 +21,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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