// SPDX-License-Identifier: AGPL-3.0-only package eventlog import ( "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "regexp" "strings" "syscall" "time" ) const Version = 1 var safeValue = regexp.MustCompile(`^[A-Za-z0-9._:/@+-]{1,256}$`) var hexDigest = regexp.MustCompile(`^[0-9a-f]{64}$`) var gitCommit = regexp.MustCompile(`^[0-9a-f]{40}$`) var operationID = regexp.MustCompile(`^[0-9a-f]{32}$`) type Event struct { Version int `json:"version"` OperationID string `json:"operation_id"` Service string `json:"service"` ArtifactDigest string `json:"artifact_digest"` Commit string `json:"commit"` ReleaseVersion string `json:"release_version"` Phase string `json:"phase"` Slot string `json:"slot,omitempty"` DurationMillis int64 `json:"duration_ms"` Outcome string `json:"outcome"` ObservedAt string `json:"observed_at"` } func OperationID() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", errors.New("cryptographic randomness unavailable") } return hex.EncodeToString(b), nil } func Append(path string, event Event) error { if err := event.validate(); err != nil { return err } if !filepath.IsAbs(path) || filepath.Clean(path) != path { return errors.New("event log path must be absolute and clean") } if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return fmt.Errorf("create event directory: %w", err) } if info, err := os.Lstat(path); err == nil { if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0o022 != 0 { return errors.New("event log must be a non-writable regular non-symlink file") } } else if !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("inspect event log: %w", err) } b, err := json.Marshal(event) if err != nil { return fmt.Errorf("encode deployment event: %w", err) } if len(b) > 4096 { return errors.New("deployment event exceeds bound") } b = append(b, '\n') fd, err := syscall.Open(path, syscall.O_WRONLY|syscall.O_APPEND|syscall.O_CREAT|syscall.O_CLOEXEC|syscall.O_NOFOLLOW, 0o640) if err != nil { return fmt.Errorf("open event log: %w", err) } file := os.NewFile(uintptr(fd), path) if file == nil { _ = syscall.Close(fd) return errors.New("open event log file") } defer file.Close() n, err := file.Write(b) if err != nil || n != len(b) { return errors.New("write complete deployment event") } if err := file.Sync(); err != nil { return fmt.Errorf("sync deployment event: %w", err) } return nil } func (e Event) validate() error { if e.Version != Version || !operationID.MatchString(e.OperationID) { return errors.New("deployment event identity is invalid") } if !hexDigest.MatchString(e.ArtifactDigest) || !gitCommit.MatchString(e.Commit) { return errors.New("deployment event provenance is invalid") } for label, value := range map[string]string{"service": e.Service, "artifact_digest": e.ArtifactDigest, "commit": e.Commit, "release_version": e.ReleaseVersion, "phase": e.Phase, "outcome": e.Outcome} { if !safeValue.MatchString(value) || strings.ContainsRune(value, '\x00') { return fmt.Errorf("deployment event %s is invalid", label) } } if e.Slot != "" && !safeValue.MatchString(e.Slot) { return errors.New("deployment event slot is invalid") } if e.DurationMillis < 0 { return errors.New("deployment event duration is invalid") } if _, err := time.Parse(time.RFC3339Nano, e.ObservedAt); err != nil { return errors.New("deployment event timestamp is invalid") } return nil }