diff --git a/html.go b/html.go
index 7113659..db36ac8 100644
--- a/html.go
+++ b/html.go
@@ -1,3 +1,4 @@
+// SPDX-Licence-Identifier: EUPL-1.2
package session
import (
diff --git a/parser.go b/parser.go
index 1390d92..dc877e4 100644
--- a/parser.go
+++ b/parser.go
@@ -1,3 +1,4 @@
+// SPDX-Licence-Identifier: EUPL-1.2
package session
import (
@@ -238,7 +239,7 @@ func (s *Session) IsExpired(maxAge time.Duration) bool {
// It ensures the ID does not contain path traversal characters.
func FetchSession(projectsDir, id string) (*Session, *ParseStats, error) {
if strings.Contains(id, "..") || strings.ContainsAny(id, `/\`) {
- return nil, nil, coreerr.E("FetchSession", "invalid session id")
+ return nil, nil, coreerr.E("FetchSession", "invalid session id", nil)
}
path := filepath.Join(projectsDir, id+".jsonl")
diff --git a/parser_test.go b/parser_test.go
index 2f5c68d..7ec5326 100644
--- a/parser_test.go
+++ b/parser_test.go
@@ -1330,3 +1330,117 @@ func TestListSessions_TruncatedFile_Good(t *testing.T) {
// End time should reflect the last valid timestamp.
assert.True(t, sessions[0].EndTime.After(sessions[0].StartTime))
}
+
+// --- PruneSessions tests ---
+
+func TestPruneSessions_DeletesOld_Good(t *testing.T) {
+ dir := t.TempDir()
+ writeJSONL(t, dir, "old-session.jsonl", userTextEntry(ts(0), "old"))
+ writeJSONL(t, dir, "new-session.jsonl", userTextEntry(ts(0), "new"))
+
+ // Touch old-session to make it appear old (1 hour ago).
+ oldPath := filepath.Join(dir, "old-session.jsonl")
+ past := time.Now().Add(-2 * time.Hour)
+ require.NoError(t, os.Chtimes(oldPath, past, past))
+
+ deleted, err := PruneSessions(dir, 1*time.Hour)
+ require.NoError(t, err)
+ assert.Equal(t, 1, deleted)
+
+ // Only new-session should remain.
+ sessions, err := ListSessions(dir)
+ require.NoError(t, err)
+ require.Len(t, sessions, 1)
+ assert.Equal(t, "new-session", sessions[0].ID)
+}
+
+func TestPruneSessions_NoneExpired_Good(t *testing.T) {
+ dir := t.TempDir()
+ writeJSONL(t, dir, "fresh.jsonl", userTextEntry(ts(0), "fresh"))
+
+ deleted, err := PruneSessions(dir, 24*time.Hour)
+ require.NoError(t, err)
+ assert.Equal(t, 0, deleted)
+
+ sessions, err := ListSessions(dir)
+ require.NoError(t, err)
+ require.Len(t, sessions, 1)
+}
+
+func TestPruneSessions_EmptyDir_Good(t *testing.T) {
+ dir := t.TempDir()
+
+ deleted, err := PruneSessions(dir, 1*time.Hour)
+ require.NoError(t, err)
+ assert.Equal(t, 0, deleted)
+}
+
+// --- IsExpired tests ---
+
+func TestIsExpired_Expired_Good(t *testing.T) {
+ s := &Session{
+ EndTime: time.Now().Add(-2 * time.Hour),
+ }
+ assert.True(t, s.IsExpired(1*time.Hour))
+}
+
+func TestIsExpired_NotExpired_Good(t *testing.T) {
+ s := &Session{
+ EndTime: time.Now().Add(-30 * time.Minute),
+ }
+ assert.False(t, s.IsExpired(1*time.Hour))
+}
+
+func TestIsExpired_ZeroEndTime_Bad(t *testing.T) {
+ s := &Session{}
+ assert.False(t, s.IsExpired(1*time.Hour))
+}
+
+// --- FetchSession tests ---
+
+func TestFetchSession_ValidID_Good(t *testing.T) {
+ dir := t.TempDir()
+ writeJSONL(t, dir, "abc123.jsonl",
+ userTextEntry(ts(0), "Hello"),
+ assistantTextEntry(ts(1), "Hi"),
+ )
+
+ sess, stats, err := FetchSession(dir, "abc123")
+ require.NoError(t, err)
+ require.NotNil(t, sess)
+ require.NotNil(t, stats)
+ assert.Equal(t, "abc123", sess.ID)
+ assert.Len(t, sess.Events, 2)
+}
+
+func TestFetchSession_PathTraversal_Bad(t *testing.T) {
+ dir := t.TempDir()
+
+ _, _, err := FetchSession(dir, "../etc/passwd")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid session id")
+}
+
+func TestFetchSession_BackslashTraversal_Bad(t *testing.T) {
+ dir := t.TempDir()
+
+ _, _, err := FetchSession(dir, `..\\windows\\system32`)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid session id")
+}
+
+func TestFetchSession_SlashInID_Bad(t *testing.T) {
+ dir := t.TempDir()
+
+ _, _, err := FetchSession(dir, "sub/dir")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid session id")
+}
+
+func TestFetchSession_NotFound_Bad(t *testing.T) {
+ dir := t.TempDir()
+
+ _, _, err := FetchSession(dir, "nonexistent")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "open transcript")
+}
diff --git a/search.go b/search.go
index 403428a..232446c 100644
--- a/search.go
+++ b/search.go
@@ -1,3 +1,4 @@
+// SPDX-Licence-Identifier: EUPL-1.2
package session
import (
diff --git a/video.go b/video.go
index 9b525b8..24839d8 100644
--- a/video.go
+++ b/video.go
@@ -1,3 +1,4 @@
+// SPDX-Licence-Identifier: EUPL-1.2
package session
import (
@@ -12,7 +13,7 @@ import (
// RenderMP4 generates an MP4 video from session events using VHS (charmbracelet).
func RenderMP4(sess *Session, outputPath string) error {
if _, err := exec.LookPath("vhs"); err != nil {
- return coreerr.E("RenderMP4", "vhs not installed (go install github.com/charmbracelet/vhs@latest)")
+ return coreerr.E("RenderMP4", "vhs not installed (go install github.com/charmbracelet/vhs@latest)", nil)
}
tape := generateTape(sess, outputPath)