Skip to content

Commit 39bfdd0

Browse files
steipeteBrendan
andcommitted
fix(share): validate imported tombstones
Co-authored-by: Brendan <brendan@grant.lan>
1 parent 91aa80d commit 39bfdd0

3 files changed

Lines changed: 75 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
- Show setup guidance instead of a raw missing-file error when configuration-dependent commands cannot find `config.toml`. Thanks @0xdevalias.
1212
- Report module build metadata for source-installed binaries instead of a stale hard-coded release version.
13+
- Reject malformed message tombstone timestamps before either SQLite snapshot import path mutates the archive. Thanks @GrantTheAssistant.
1314

1415
## 0.11.5 - 2026-07-09
1516

internal/share/share.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,7 +500,13 @@ func Import(ctx context.Context, s *store.Store, opts Options) (Manifest, error)
500500
})
501501
},
502502
Filter: func(table string, row map[string]any) (bool, error) {
503-
return !isDirectMessageSnapshotRow(table, row), nil
503+
if isDirectMessageSnapshotRow(table, row) {
504+
return false, nil
505+
}
506+
if err := validateSnapshotRow(table, row); err != nil {
507+
return false, err
508+
}
509+
return true, nil
504510
},
505511
BeforeImport: func(ctx context.Context, tx *sql.Tx) error {
506512
var err error
@@ -1659,6 +1665,9 @@ func importTableFile(ctx context.Context, stmt *sql.Stmt, repoPath string, table
16591665
if isDirectMessageSnapshotRow(table.Name, row) {
16601666
continue
16611667
}
1668+
if err := validateSnapshotRow(table.Name, row); err != nil {
1669+
return count, fmt.Errorf("validate %s: %w", rel, err)
1670+
}
16621671
values := make([]any, len(columns))
16631672
for i, column := range columns {
16641673
values[i] = importValue(row[column])
@@ -1671,6 +1680,31 @@ func importTableFile(ctx context.Context, stmt *sql.Stmt, repoPath string, table
16711680
return count, nil
16721681
}
16731682

1683+
func validateSnapshotRow(table string, row map[string]any) error {
1684+
if table != "messages" {
1685+
return nil
1686+
}
1687+
raw, ok := row["deleted_at"]
1688+
if !ok || raw == nil {
1689+
return nil
1690+
}
1691+
value, ok := raw.(string)
1692+
if !ok {
1693+
return errors.New("messages.deleted_at must be a string or null")
1694+
}
1695+
value = strings.TrimSpace(value)
1696+
if value == "" {
1697+
row["deleted_at"] = nil
1698+
return nil
1699+
}
1700+
parsed, err := time.Parse(time.RFC3339Nano, value)
1701+
if err != nil {
1702+
return fmt.Errorf("messages.deleted_at must be RFC3339: %w", err)
1703+
}
1704+
row["deleted_at"] = parsed.UTC().Format(time.RFC3339Nano)
1705+
return nil
1706+
}
1707+
16741708
func repairImportedGuildIDs(ctx context.Context, tx *sql.Tx) error {
16751709
repairs := []struct {
16761710
table string

internal/share/share_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2275,6 +2275,45 @@ func TestLegacyManifestFileImportAndEmbeddingDecodeErrors(t *testing.T) {
22752275
require.NoError(t, tx.Rollback())
22762276
}
22772277

2278+
func TestValidateSnapshotRowRejectsMalformedDeletedAtBeforeImport(t *testing.T) {
2279+
require.NoError(t, validateSnapshotRow("messages", map[string]any{"deleted_at": nil}))
2280+
blank := map[string]any{"deleted_at": " "}
2281+
require.NoError(t, validateSnapshotRow("messages", blank))
2282+
require.Nil(t, blank["deleted_at"])
2283+
require.NoError(t, validateSnapshotRow("messages", map[string]any{"deleted_at": "2026-07-14T12:00:00.123456789Z"}))
2284+
padded := map[string]any{"deleted_at": " 2026-07-14T12:00:00Z "}
2285+
require.NoError(t, validateSnapshotRow("messages", padded))
2286+
require.Equal(t, "2026-07-14T12:00:00Z", padded["deleted_at"])
2287+
require.ErrorContains(t, validateSnapshotRow("messages", map[string]any{"deleted_at": "not-a-timestamp"}), "must be RFC3339")
2288+
require.ErrorContains(t, validateSnapshotRow("messages", map[string]any{"deleted_at": json.Number("123")}), "must be a string or null")
2289+
require.NoError(t, validateSnapshotRow("guilds", map[string]any{"deleted_at": "not-a-timestamp"}))
2290+
2291+
ctx := context.Background()
2292+
s, err := store.Open(ctx, filepath.Join(t.TempDir(), "discrawl.db"))
2293+
require.NoError(t, err)
2294+
defer func() { _ = s.Close() }()
2295+
repo := t.TempDir()
2296+
rel := filepath.ToSlash(filepath.Join("tables", "messages", "tombstones.jsonl.gz"))
2297+
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(repo, filepath.FromSlash(rel))), 0o755))
2298+
writeGzipJSONLines(t, filepath.Join(repo, filepath.FromSlash(rel)), []string{
2299+
`{"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"}`,
2300+
`{"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"}`,
2301+
})
2302+
tx, err := s.DB().BeginTx(ctx, nil)
2303+
require.NoError(t, err)
2304+
err = importTable(ctx, tx, Options{RepoPath: repo}, TableManifest{
2305+
Name: "messages", File: rel,
2306+
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"},
2307+
})
2308+
require.ErrorContains(t, err, "messages.deleted_at must be RFC3339")
2309+
var count int
2310+
require.NoError(t, tx.QueryRowContext(ctx, `select count(*) from messages`).Scan(&count))
2311+
require.Equal(t, 1, count)
2312+
require.NoError(t, tx.Rollback())
2313+
require.NoError(t, s.DB().QueryRowContext(ctx, `select count(*) from messages`).Scan(&count))
2314+
require.Zero(t, count)
2315+
}
2316+
22782317
func TestImportEmbeddingsRejectsUnsafeManifestFiles(t *testing.T) {
22792318
t.Parallel()
22802319

0 commit comments

Comments
 (0)