Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 44 additions & 5 deletions agent/internal/build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ type dockerfileConfig struct {
}

var managedTempArtifactPattern = regexp.MustCompile(`^(backup|restore)-[0-9a-fA-F-]{36}\.tar\.gz$|^restore-extract-[0-9a-fA-F-]{36}$`)
var windowsAbsoluteRootPattern = regexp.MustCompile(`^[A-Za-z]:[\\/]`)

func NewBuilder(dataDir string, logSender LogSender) *Builder {
return &Builder{
Expand Down Expand Up @@ -224,12 +225,11 @@ func (b *Builder) resolveCommitSha(ctx context.Context, config *Config, buildDir
}

func (b *Builder) buildAndPush(ctx context.Context, config *Config, buildDir string) error {
contextDir := buildDir
contextDir, err := resolveBuildContext(buildDir, config.RootDir)
if err != nil {
return err
}
if config.RootDir != "" {
contextDir = filepath.Join(buildDir, config.RootDir)
if _, err := os.Stat(contextDir); err != nil {
return fmt.Errorf("root directory %s does not exist: %w", config.RootDir, err)
}
b.sendLog(config, fmt.Sprintf("Using root directory: %s", config.RootDir))
}

Expand Down Expand Up @@ -346,6 +346,45 @@ func (b *Builder) buildAndPush(ctx context.Context, config *Config, buildDir str
return nil
}

func resolveBuildContext(buildDir, rootDir string) (string, error) {
if rootDir == "" {
return buildDir, nil
}

if windowsAbsoluteRootPattern.MatchString(rootDir) {
return "", fmt.Errorf("root directory %s must be relative to and inside the cloned repository", rootDir)
}
normalizedRoot := strings.ReplaceAll(rootDir, "\\", "/")
cleanedRoot := filepath.Clean(filepath.FromSlash(normalizedRoot))
if filepath.IsAbs(cleanedRoot) || cleanedRoot == ".." || strings.HasPrefix(cleanedRoot, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("root directory %s must be relative to and inside the cloned repository", rootDir)
}

contextDir := filepath.Join(buildDir, cleanedRoot)
info, err := os.Stat(contextDir)
if err != nil {
return "", fmt.Errorf("root directory %s does not exist: %w", rootDir, err)
}
if !info.IsDir() {
return "", fmt.Errorf("root directory %s is not a directory", rootDir)
}

resolvedBuildDir, err := filepath.EvalSymlinks(buildDir)
if err != nil {
return "", fmt.Errorf("failed to resolve cloned repository directory: %w", err)
}
resolvedContextDir, err := filepath.EvalSymlinks(contextDir)
if err != nil {
return "", fmt.Errorf("failed to resolve root directory %s: %w", rootDir, err)
}
relativePath, err := filepath.Rel(resolvedBuildDir, resolvedContextDir)
if err != nil || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("root directory %s must resolve inside the cloned repository", rootDir)
}

return contextDir, nil
}

func resolveDockerfile(contextDir string, secrets map[string]string) (dockerfileConfig, error) {
configuredPath, configured := secrets[dockerfilePathKey]
configuredPath = strings.TrimSpace(configuredPath)
Expand Down
57 changes: 57 additions & 0 deletions agent/internal/build/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,63 @@ func TestCloneDeepensConfiguredBranchForSelectedCommit(t *testing.T) {
}
}

func TestResolveBuildContext(t *testing.T) {
buildDir := t.TempDir()
nestedDir := filepath.Join(buildDir, "services", "api")
if err := os.MkdirAll(nestedDir, 0700); err != nil {
t.Fatal(err)
}
outsideDir := t.TempDir()

tests := []struct {
name string
rootDir string
want string
wantErr bool
}{
{name: "repository root", rootDir: "", want: buildDir},
{name: "safe nested root", rootDir: filepath.Join("services", "api"), want: nestedDir},
{name: "windows separators", rootDir: `services\api`, want: nestedDir},
{name: "absolute path", rootDir: outsideDir, wantErr: true},
{name: "windows absolute path", rootDir: `C:\repo`, wantErr: true},
{name: "UNC path", rootDir: `\\server\share`, wantErr: true},
{name: "parent escape", rootDir: "..", wantErr: true},
{name: "nested parent escape", rootDir: filepath.Join("services", "..", ".."), wantErr: true},
{name: "windows parent escape", rootDir: `services\..\..`, wantErr: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveBuildContext(buildDir, tt.rootDir)
if tt.wantErr {
if err == nil || !strings.Contains(err.Error(), "root directory") {
t.Fatalf("resolveBuildContext() error = %v, want clear root directory error", err)
}
return
}
if err != nil {
t.Fatal(err)
}
if got != tt.want {
t.Fatalf("resolveBuildContext() = %q, want %q", got, tt.want)
}
})
}
}

func TestResolveBuildContextRejectsSymlinkEscape(t *testing.T) {
buildDir := t.TempDir()
outsideDir := t.TempDir()
if err := os.Symlink(outsideDir, filepath.Join(buildDir, "outside")); err != nil {
t.Skipf("symlinks are not supported: %v", err)
}

_, err := resolveBuildContext(buildDir, "outside")
if err == nil || !strings.Contains(err.Error(), "must resolve inside the cloned repository") {
t.Fatalf("resolveBuildContext() error = %v, want symlink containment error", err)
}
}

func TestResolveDockerfile(t *testing.T) {
contextDir := t.TempDir()
if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte("FROM scratch"), 0600); err != nil {
Expand Down
28 changes: 24 additions & 4 deletions agent/internal/logs/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package logs

import (
"context"
"crypto/rand"
"fmt"
"log"
"sync"
"time"
Expand All @@ -10,20 +12,32 @@ import (
)

const (
maxBatchSize = 1000
flushInterval = 5 * time.Second
maxQueueSize = 10000
defaultSince = "1m"
maxBatchSize = 1000
flushInterval = 5 * time.Second
maxQueueSize = 10000
defaultSince = "1m"
)

type LogEntry struct {
EventID string
DeploymentID string
ServiceID string
Stream string
Message string
Timestamp string
}

func newLogEventID(now time.Time) (string, error) {
random := make([]byte, 26)
if _, err := rand.Read(random); err != nil {
return "", fmt.Errorf("generate random log event ID: %w", err)
}
for i := range random {
random[i] = 'a' + random[i]%26
}
return fmt.Sprintf("e%019d%s", now.UnixNano(), random), nil
}

type LogBatch struct {
Logs []LogEntry
}
Expand Down Expand Up @@ -143,7 +157,13 @@ func (c *Collector) collectFromContainer(ctr ContainerInfo) {

var lastTimestamp time.Time
for entry := range entryCh {
eventID, err := newLogEventID(time.Now())
if err != nil {
log.Printf("[logs] failed to identify log from container %s: %v", ctr.ContainerID, err)
continue
}
logEntry := LogEntry{
EventID: eventID,
DeploymentID: ctr.DeploymentID,
ServiceID: ctr.ServiceID,
Stream: entry.Stream,
Expand Down
70 changes: 70 additions & 0 deletions agent/internal/logs/event_id_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package logs

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
)

func TestNewLogEventIDIsSortableAndUnique(t *testing.T) {
now := time.Date(2026, time.July, 20, 12, 0, 0, 123456789, time.UTC)
first, err := newLogEventID(now)
if err != nil {
t.Fatal(err)
}
second, err := newLogEventID(now)
if err != nil {
t.Fatal(err)
}

pattern := regexp.MustCompile(`^e[0-9]{19}[a-z]{26}$`)
if !pattern.MatchString(first) {
t.Fatalf("event ID %q does not match the public cursor format", first)
}
if !pattern.MatchString(second) {
t.Fatalf("event ID %q does not match the public cursor format", second)
}
if first == second {
t.Fatalf("equal collection times generated duplicate event IDs: %q", first)
}
}

func TestVictoriaLogsSenderPreservesEventID(t *testing.T) {
const eventID = "e1784546100123456789abcdefghijklmnopqrstuvwxyz"
var body []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
body, err = io.ReadAll(r.Body)
if err != nil {
t.Error(err)
}
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()

sender := NewVictoriaLogsSender(server.URL, "server-1")
err := sender.SendLogs(&LogBatch{Logs: []LogEntry{{
EventID: eventID,
DeploymentID: "deployment-1",
ServiceID: "service-1",
Stream: "stdout",
Message: "ready",
Timestamp: "2026-07-20T12:00:00Z",
}}})
if err != nil {
t.Fatal(err)
}

var entry map[string]any
if err := json.Unmarshal([]byte(strings.TrimSpace(string(body))), &entry); err != nil {
t.Fatal(err)
}
if got := entry["event_id"]; got != eventID {
t.Fatalf("event_id = %v, want %s", got, eventID)
}
}
2 changes: 2 additions & 0 deletions agent/internal/logs/victoria.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func (v *VictoriaLogsSender) setAuthHeader(req *http.Request) {
type victoriaLogEntry struct {
Msg string `json:"_msg"`
Time string `json:"_time"`
EventID string `json:"event_id"`
DeploymentID string `json:"deployment_id"`
ServiceID string `json:"service_id"`
ServerID string `json:"server_id"`
Expand All @@ -66,6 +67,7 @@ func (v *VictoriaLogsSender) SendLogs(batch *LogBatch) error {
entry := victoriaLogEntry{
Msg: l.Message,
Time: l.Timestamp,
EventID: l.EventID,
DeploymentID: l.DeploymentID,
ServiceID: l.ServiceID,
ServerID: v.serverID,
Expand Down
1 change: 1 addition & 0 deletions cli/.mise.toml
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
[tools]
go = "1.25.5"
just = "1.56.0"
Loading
Loading