diff --git a/CHANGELOG.md b/CHANGELOG.md index 75241247..c25150f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Report module build metadata for source-installed binaries instead of a stale hard-coded release version. - Keep Discord Gateway tails fail-closed while durably spooling and replaying timed-out message creates, updates, and deletes. Thanks @hannesrudolph. - Reject attachment redirects whose final URL leaves Discord's allowlisted CDN hosts. Thanks @GrantTheAssistant. +- Reject malformed message tombstone timestamps before either SQLite snapshot import path mutates the archive. Thanks @GrantTheAssistant. ## 0.11.5 - 2026-07-09 diff --git a/internal/share/share.go b/internal/share/share.go index 78fc44e6..03d57c58 100644 --- a/internal/share/share.go +++ b/internal/share/share.go @@ -500,7 +500,13 @@ func Import(ctx context.Context, s *store.Store, opts Options) (Manifest, error) }) }, Filter: func(table string, row map[string]any) (bool, error) { - return !isDirectMessageSnapshotRow(table, row), nil + if isDirectMessageSnapshotRow(table, row) { + return false, nil + } + if err := validateSnapshotRow(table, row); err != nil { + return false, err + } + return true, nil }, BeforeImport: func(ctx context.Context, tx *sql.Tx) error { var err error @@ -1659,6 +1665,9 @@ func importTableFile(ctx context.Context, stmt *sql.Stmt, repoPath string, table if isDirectMessageSnapshotRow(table.Name, row) { continue } + if err := validateSnapshotRow(table.Name, row); err != nil { + return count, fmt.Errorf("validate %s: %w", rel, err) + } values := make([]any, len(columns)) for i, column := range columns { values[i] = importValue(row[column]) @@ -1671,6 +1680,31 @@ func importTableFile(ctx context.Context, stmt *sql.Stmt, repoPath string, table return count, nil } +func validateSnapshotRow(table string, row map[string]any) error { + if table != "messages" { + return nil + } + raw, ok := row["deleted_at"] + if !ok || raw == nil { + return nil + } + value, ok := raw.(string) + if !ok { + return errors.New("messages.deleted_at must be a string or null") + } + value = strings.TrimSpace(value) + if value == "" { + row["deleted_at"] = nil + return nil + } + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return fmt.Errorf("messages.deleted_at must be RFC3339: %w", err) + } + row["deleted_at"] = parsed.UTC().Format(time.RFC3339Nano) + return nil +} + func repairImportedGuildIDs(ctx context.Context, tx *sql.Tx) error { repairs := []struct { table string diff --git a/internal/share/share_test.go b/internal/share/share_test.go index e0e955b2..b4921aa9 100644 --- a/internal/share/share_test.go +++ b/internal/share/share_test.go @@ -2275,6 +2275,45 @@ func TestLegacyManifestFileImportAndEmbeddingDecodeErrors(t *testing.T) { require.NoError(t, tx.Rollback()) } +func TestValidateSnapshotRowRejectsMalformedDeletedAtBeforeImport(t *testing.T) { + require.NoError(t, validateSnapshotRow("messages", map[string]any{"deleted_at": nil})) + blank := map[string]any{"deleted_at": " "} + require.NoError(t, validateSnapshotRow("messages", blank)) + require.Nil(t, blank["deleted_at"]) + require.NoError(t, validateSnapshotRow("messages", map[string]any{"deleted_at": "2026-07-14T12:00:00.123456789Z"})) + padded := map[string]any{"deleted_at": " 2026-07-14T12:00:00Z "} + require.NoError(t, validateSnapshotRow("messages", padded)) + require.Equal(t, "2026-07-14T12:00:00Z", padded["deleted_at"]) + require.ErrorContains(t, validateSnapshotRow("messages", map[string]any{"deleted_at": "not-a-timestamp"}), "must be RFC3339") + require.ErrorContains(t, validateSnapshotRow("messages", map[string]any{"deleted_at": json.Number("123")}), "must be a string or null") + require.NoError(t, validateSnapshotRow("guilds", map[string]any{"deleted_at": "not-a-timestamp"})) + + ctx := context.Background() + s, err := store.Open(ctx, filepath.Join(t.TempDir(), "discrawl.db")) + require.NoError(t, err) + defer func() { _ = s.Close() }() + repo := t.TempDir() + rel := filepath.ToSlash(filepath.Join("tables", "messages", "tombstones.jsonl.gz")) + require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(repo, filepath.FromSlash(rel))), 0o755)) + writeGzipJSONLines(t, filepath.Join(repo, filepath.FromSlash(rel)), []string{ + `{"id":"m1","guild_id":"g1","channel_id":"c1","author_id":null,"message_type":0,"created_at":"2026-07-14T12:00:00Z","edited_at":null,"deleted_at":null,"content":"one","normalized_content":"one","reply_to_message_id":null,"pinned":0,"has_attachments":0,"raw_json":"{}","updated_at":"2026-07-14T12:00:00Z"}`, + `{"id":"m2","guild_id":"g1","channel_id":"c1","author_id":null,"message_type":0,"created_at":"2026-07-14T12:00:00Z","edited_at":null,"deleted_at":"not-a-timestamp","content":"two","normalized_content":"two","reply_to_message_id":null,"pinned":0,"has_attachments":0,"raw_json":"{}","updated_at":"2026-07-14T12:00:00Z"}`, + }) + tx, err := s.DB().BeginTx(ctx, nil) + require.NoError(t, err) + err = importTable(ctx, tx, Options{RepoPath: repo}, TableManifest{ + Name: "messages", File: rel, + Columns: []string{"id", "guild_id", "channel_id", "author_id", "message_type", "created_at", "edited_at", "deleted_at", "content", "normalized_content", "reply_to_message_id", "pinned", "has_attachments", "raw_json", "updated_at"}, + }) + require.ErrorContains(t, err, "messages.deleted_at must be RFC3339") + var count int + require.NoError(t, tx.QueryRowContext(ctx, `select count(*) from messages`).Scan(&count)) + require.Equal(t, 1, count) + require.NoError(t, tx.Rollback()) + require.NoError(t, s.DB().QueryRowContext(ctx, `select count(*) from messages`).Scan(&count)) + require.Zero(t, count) +} + func TestImportEmbeddingsRejectsUnsafeManifestFiles(t *testing.T) { t.Parallel()