539 lines
14 KiB
Go
539 lines
14 KiB
Go
// Package recording provides session recording in asciicast v2 format.
|
|
// Recordings capture terminal I/O with timestamps, enabling playback of
|
|
// complete sessions for auditing, training, or troubleshooting.
|
|
//
|
|
// File format: https://docs.asciinema.org/manual/asciicast/v2/
|
|
//
|
|
// The header line is a JSON object with metadata (version, dimensions, etc.)
|
|
// followed by one JSON array per event: [elapsed_secs, event_type, data].
|
|
package recording
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// EventType distinguishes input from output events.
|
|
type EventType string
|
|
|
|
const (
|
|
// EventOutput represents data sent from the remote host to the terminal.
|
|
EventOutput EventType = "o"
|
|
// EventInput represents data sent from the user to the remote host.
|
|
EventInput EventType = "i"
|
|
|
|
// MaxRecordingBytes bounds a single active recording file.
|
|
MaxRecordingBytes = int64(250 * 1024 * 1024)
|
|
// MaxPlaybackBytes bounds the file size loaded for playback.
|
|
MaxPlaybackBytes = int64(100 * 1024 * 1024)
|
|
// MaxPlaybackLineBytes bounds one asciicast line during streaming playback
|
|
// parsing. Terminal events should stay well below this in normal use.
|
|
MaxPlaybackLineBytes = 8 * 1024 * 1024
|
|
)
|
|
|
|
// Header is the asciicast v2 file header (first line).
|
|
type Header struct {
|
|
Version int `json:"version"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
Timestamp int64 `json:"timestamp"`
|
|
Title string `json:"title,omitempty"`
|
|
Env map[string]string `json:"env,omitempty"`
|
|
}
|
|
|
|
// Event is a single recorded I/O event: [elapsed_seconds, type, data].
|
|
type Event struct {
|
|
Elapsed float64 `json:"-"`
|
|
Type EventType `json:"-"`
|
|
Data string `json:"-"`
|
|
}
|
|
|
|
// MarshalJSON serialises an event as the asciicast v2 array format.
|
|
func (e Event) MarshalJSON() ([]byte, error) {
|
|
return json.Marshal([]interface{}{e.Elapsed, string(e.Type), e.Data})
|
|
}
|
|
|
|
// RecordingMeta describes a saved recording without loading the full data.
|
|
type RecordingMeta struct {
|
|
ID string `json:"id"`
|
|
SessionID string `json:"sessionId"`
|
|
Title string `json:"title"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
Duration float64 `json:"duration"` // seconds
|
|
Size int64 `json:"size"` // file size in bytes
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
FilePath string `json:"filePath"`
|
|
}
|
|
|
|
// Recorder captures terminal I/O for a single session.
|
|
type Recorder struct {
|
|
mu sync.Mutex
|
|
file *os.File
|
|
startTime time.Time
|
|
header Header
|
|
eventCount int
|
|
closed bool
|
|
filePath string
|
|
bytesWritten int64
|
|
maxBytes int64
|
|
}
|
|
|
|
// NewRecorder creates a new recording file and writes the header.
|
|
func NewRecorder(dir, id, title string, cols, rows int) (*Recorder, error) {
|
|
return newRecorderWithLimit(dir, id, title, cols, rows, MaxRecordingBytes)
|
|
}
|
|
|
|
func newRecorderWithLimit(dir, id, title string, cols, rows int, maxBytes int64) (*Recorder, error) {
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
return nil, fmt.Errorf("recording: failed to create directory: %w", err)
|
|
}
|
|
|
|
filename := fmt.Sprintf("%s.cast", id)
|
|
path := filepath.Join(dir, filename)
|
|
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("recording: failed to create file: %w", err)
|
|
}
|
|
|
|
now := time.Now()
|
|
hdr := Header{
|
|
Version: 2,
|
|
Width: cols,
|
|
Height: rows,
|
|
Timestamp: now.Unix(),
|
|
Title: title,
|
|
Env: map[string]string{
|
|
"TERM": "xterm-256color",
|
|
},
|
|
}
|
|
|
|
headerLine, err := marshalJSONLine(hdr)
|
|
if err != nil {
|
|
f.Close()
|
|
os.Remove(path)
|
|
return nil, fmt.Errorf("recording: failed to encode header: %w", err)
|
|
}
|
|
n, err := f.Write(headerLine)
|
|
if err != nil {
|
|
f.Close()
|
|
os.Remove(path)
|
|
return nil, fmt.Errorf("recording: failed to write header: %w", err)
|
|
}
|
|
|
|
return &Recorder{
|
|
file: f,
|
|
startTime: now,
|
|
header: hdr,
|
|
filePath: path,
|
|
bytesWritten: int64(n),
|
|
maxBytes: maxBytes,
|
|
}, nil
|
|
}
|
|
|
|
// WriteOutput records terminal output (data from remote host).
|
|
func (r *Recorder) WriteOutput(data []byte) {
|
|
r.writeEvent(EventOutput, data)
|
|
}
|
|
|
|
// WriteInput records terminal input (data from user).
|
|
func (r *Recorder) WriteInput(data []byte) {
|
|
r.writeEvent(EventInput, data)
|
|
}
|
|
|
|
// writeEvent appends a timestamped event to the recording file.
|
|
func (r *Recorder) writeEvent(eventType EventType, data []byte) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
if r.closed || r.file == nil {
|
|
return
|
|
}
|
|
|
|
elapsed := time.Since(r.startTime).Seconds()
|
|
evt := Event{
|
|
Elapsed: elapsed,
|
|
Type: eventType,
|
|
Data: string(data),
|
|
}
|
|
|
|
line, err := marshalJSONLine(evt)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if r.maxBytes > 0 && r.bytesWritten+int64(len(line)) > r.maxBytes {
|
|
r.closed = true
|
|
_ = r.file.Sync()
|
|
_ = r.file.Close()
|
|
r.file = nil
|
|
return
|
|
}
|
|
|
|
n, err := r.file.Write(line)
|
|
if err != nil {
|
|
return
|
|
}
|
|
r.bytesWritten += int64(n)
|
|
r.eventCount++
|
|
}
|
|
|
|
// UpdateSize records a terminal resize (not part of asciicast v2 events,
|
|
// but useful for future playback fidelity). Currently a no-op placeholder.
|
|
func (r *Recorder) UpdateSize(cols, rows int) {
|
|
// asciicast v2 does not have a standard resize event. Future extension
|
|
// could use a custom event type like "r" with JSON dimensions.
|
|
}
|
|
|
|
// Close flushes and closes the recording file.
|
|
func (r *Recorder) Close() error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
if r.closed {
|
|
return nil
|
|
}
|
|
r.closed = true
|
|
|
|
if r.file != nil {
|
|
if err := r.file.Sync(); err != nil {
|
|
r.file.Close()
|
|
return fmt.Errorf("recording: sync failed: %w", err)
|
|
}
|
|
err := r.file.Close()
|
|
r.file = nil
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Duration returns the elapsed time since recording started.
|
|
func (r *Recorder) Duration() float64 {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return time.Since(r.startTime).Seconds()
|
|
}
|
|
|
|
// FilePath returns the absolute path to the recording file.
|
|
func (r *Recorder) FilePath() string {
|
|
return r.filePath
|
|
}
|
|
|
|
// EventCount returns the number of events recorded so far.
|
|
func (r *Recorder) EventCount() int {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.eventCount
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Recording store — manages listing, reading, and deleting recordings.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Store manages the recording directory.
|
|
type Store struct {
|
|
dir string
|
|
}
|
|
|
|
// NewStore creates a recording store at the given directory.
|
|
func NewStore(dir string) *Store {
|
|
return &Store{dir: dir}
|
|
}
|
|
|
|
// Dir returns the recordings directory path.
|
|
func (s *Store) Dir() string {
|
|
return s.dir
|
|
}
|
|
|
|
// List returns metadata for all recordings in the store.
|
|
func (s *Store) List() ([]RecordingMeta, error) {
|
|
if err := os.MkdirAll(s.dir, 0700); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
entries, err := os.ReadDir(s.dir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("recording: failed to read directory: %w", err)
|
|
}
|
|
|
|
var results []RecordingMeta
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".cast") {
|
|
continue
|
|
}
|
|
|
|
meta, err := s.readMeta(entry.Name())
|
|
if err != nil {
|
|
continue // skip unreadable files
|
|
}
|
|
results = append(results, *meta)
|
|
}
|
|
|
|
// Sort by creation time, newest first.
|
|
sort.Slice(results, func(i, j int) bool {
|
|
return results[i].CreatedAt.After(results[j].CreatedAt)
|
|
})
|
|
|
|
return results, nil
|
|
}
|
|
|
|
// Get reads the full recording (header + events) from a file.
|
|
func (s *Store) Get(id string) (*Header, []Event, error) {
|
|
return s.getWithLimit(id, MaxPlaybackBytes)
|
|
}
|
|
|
|
func (s *Store) getWithLimit(id string, maxBytes int64) (*Header, []Event, error) {
|
|
path, err := s.recordingPath(id)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("recording: failed to stat file: %w", err)
|
|
}
|
|
if maxBytes > 0 && info.Size() > maxBytes {
|
|
return nil, nil, fmt.Errorf("recording: file is too large for playback (max %d bytes)", maxBytes)
|
|
}
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("recording: failed to open file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
// Parse header (first line).
|
|
scanner := bufio.NewScanner(file)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), MaxPlaybackLineBytes)
|
|
if !scanner.Scan() {
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, nil, fmt.Errorf("recording: failed to scan header: %w", err)
|
|
}
|
|
return nil, nil, fmt.Errorf("recording: empty file")
|
|
}
|
|
var hdr Header
|
|
if err := json.Unmarshal([]byte(strings.TrimSpace(scanner.Text())), &hdr); err != nil {
|
|
return nil, nil, fmt.Errorf("recording: invalid header: %w", err)
|
|
}
|
|
|
|
// Parse events (remaining lines).
|
|
events := make([]Event, 0)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
var raw []json.RawMessage
|
|
if err := json.Unmarshal([]byte(line), &raw); err != nil {
|
|
continue
|
|
}
|
|
if len(raw) != 3 {
|
|
continue
|
|
}
|
|
|
|
var elapsed float64
|
|
var evtType, evtData string
|
|
if err := json.Unmarshal(raw[0], &elapsed); err != nil {
|
|
continue
|
|
}
|
|
if err := json.Unmarshal(raw[1], &evtType); err != nil {
|
|
continue
|
|
}
|
|
if err := json.Unmarshal(raw[2], &evtData); err != nil {
|
|
continue
|
|
}
|
|
|
|
events = append(events, Event{
|
|
Elapsed: elapsed,
|
|
Type: EventType(evtType),
|
|
Data: evtData,
|
|
})
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, nil, fmt.Errorf("recording: failed to scan events: %w", err)
|
|
}
|
|
|
|
return &hdr, events, nil
|
|
}
|
|
|
|
// Delete removes a recording file.
|
|
func (s *Store) Delete(id string) error {
|
|
path, err := s.recordingPath(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.Remove(path); err != nil {
|
|
return fmt.Errorf("recording: failed to delete: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// StorageInfo summarizes recording storage usage.
|
|
type StorageInfo struct {
|
|
Count int `json:"count"`
|
|
TotalBytes int64 `json:"totalBytes"`
|
|
OldestCreatedAt *time.Time `json:"oldestCreatedAt,omitempty"`
|
|
NewestCreatedAt *time.Time `json:"newestCreatedAt,omitempty"`
|
|
MaxRecordingBytes int64 `json:"maxRecordingBytes"`
|
|
MaxPlaybackBytes int64 `json:"maxPlaybackBytes"`
|
|
}
|
|
|
|
// CleanupResult describes files removed by retention cleanup.
|
|
type CleanupResult struct {
|
|
Deleted int `json:"deleted"`
|
|
FreedBytes int64 `json:"freedBytes"`
|
|
}
|
|
|
|
// StorageInfo returns aggregate metadata for recordings.
|
|
func (s *Store) StorageInfo() (StorageInfo, error) {
|
|
metas, err := s.List()
|
|
if err != nil {
|
|
return StorageInfo{}, err
|
|
}
|
|
info := StorageInfo{
|
|
Count: len(metas),
|
|
MaxRecordingBytes: MaxRecordingBytes,
|
|
MaxPlaybackBytes: MaxPlaybackBytes,
|
|
}
|
|
for i := range metas {
|
|
meta := metas[i]
|
|
info.TotalBytes += meta.Size
|
|
if info.OldestCreatedAt == nil || meta.CreatedAt.Before(*info.OldestCreatedAt) {
|
|
t := meta.CreatedAt
|
|
info.OldestCreatedAt = &t
|
|
}
|
|
if info.NewestCreatedAt == nil || meta.CreatedAt.After(*info.NewestCreatedAt) {
|
|
t := meta.CreatedAt
|
|
info.NewestCreatedAt = &t
|
|
}
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
// Cleanup removes recordings older than maxAgeDays and/or enough oldest files
|
|
// to bring total storage below maxTotalBytes. Non-positive limits are ignored.
|
|
func (s *Store) Cleanup(maxAgeDays int, maxTotalBytes int64) (CleanupResult, error) {
|
|
metas, err := s.List()
|
|
if err != nil {
|
|
return CleanupResult{}, err
|
|
}
|
|
|
|
result := CleanupResult{}
|
|
deleted := map[string]bool{}
|
|
total := int64(0)
|
|
for _, meta := range metas {
|
|
total += meta.Size
|
|
}
|
|
|
|
deleteMeta := func(meta RecordingMeta) {
|
|
if deleted[meta.ID] {
|
|
return
|
|
}
|
|
if err := s.Delete(meta.ID); err == nil {
|
|
deleted[meta.ID] = true
|
|
result.Deleted++
|
|
result.FreedBytes += meta.Size
|
|
total -= meta.Size
|
|
}
|
|
}
|
|
|
|
if maxAgeDays > 0 {
|
|
cutoff := time.Now().Add(-time.Duration(maxAgeDays) * 24 * time.Hour)
|
|
for _, meta := range metas {
|
|
if meta.CreatedAt.Before(cutoff) {
|
|
deleteMeta(meta)
|
|
}
|
|
}
|
|
}
|
|
|
|
if maxTotalBytes > 0 && total > maxTotalBytes {
|
|
sort.Slice(metas, func(i, j int) bool {
|
|
return metas[i].CreatedAt.Before(metas[j].CreatedAt)
|
|
})
|
|
for _, meta := range metas {
|
|
if total <= maxTotalBytes {
|
|
break
|
|
}
|
|
deleteMeta(meta)
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Store) recordingPath(id string) (string, error) {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" || id == "." || id == ".." || strings.ContainsAny(id, `/\`) {
|
|
return "", fmt.Errorf("recording: invalid recording ID")
|
|
}
|
|
return filepath.Join(s.dir, id+".cast"), nil
|
|
}
|
|
|
|
func marshalJSONLine(v interface{}) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
enc.SetEscapeHTML(false)
|
|
if err := enc.Encode(v); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// readMeta reads just the header from a .cast file to produce metadata.
|
|
func (s *Store) readMeta(filename string) (*RecordingMeta, error) {
|
|
path := filepath.Join(s.dir, filename)
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var hdr Header
|
|
dec := json.NewDecoder(f)
|
|
if err := dec.Decode(&hdr); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Calculate duration by scanning for last event timestamp.
|
|
var duration float64
|
|
for dec.More() {
|
|
var raw json.RawMessage
|
|
if err := dec.Decode(&raw); err != nil {
|
|
break
|
|
}
|
|
var arr []json.RawMessage
|
|
if err := json.Unmarshal(raw, &arr); err != nil || len(arr) < 1 {
|
|
continue
|
|
}
|
|
var elapsed float64
|
|
if err := json.Unmarshal(arr[0], &elapsed); err == nil {
|
|
duration = elapsed
|
|
}
|
|
}
|
|
|
|
id := strings.TrimSuffix(filename, ".cast")
|
|
|
|
return &RecordingMeta{
|
|
ID: id,
|
|
SessionID: id, // recording ID matches session ID by default
|
|
Title: hdr.Title,
|
|
Width: hdr.Width,
|
|
Height: hdr.Height,
|
|
Duration: duration,
|
|
Size: info.Size(),
|
|
CreatedAt: time.Unix(hdr.Timestamp, 0),
|
|
FilePath: path,
|
|
}, nil
|
|
}
|