diff --git a/internal/scanner/ebook.go b/internal/scanner/ebook.go
index a057a23a..bdecb41a 100644
--- a/internal/scanner/ebook.go
+++ b/internal/scanner/ebook.go
@@ -117,6 +117,13 @@ func ebookOPFSidecarPath(ebookPath string) string {
func parseEbookOPFSidecar(sidecarPath string) (parsedEbook, error) {
var book parsedEbook
+ leafInfo, err := os.Lstat(sidecarPath)
+ if err != nil {
+ return book, fmt.Errorf("stat ebook OPF sidecar %s: %w", sidecarPath, err)
+ }
+ if !leafInfo.Mode().IsRegular() {
+ return book, fmt.Errorf("ebook OPF sidecar is not a regular file: %s", sidecarPath)
+ }
file, err := os.Open(sidecarPath)
if err != nil {
return book, fmt.Errorf("open ebook OPF sidecar %s: %w", sidecarPath, err)
@@ -127,6 +134,13 @@ func parseEbookOPFSidecar(sidecarPath string) (parsedEbook, error) {
if err != nil {
return book, fmt.Errorf("stat ebook OPF sidecar %s: %w", sidecarPath, err)
}
+ // Close the symlink-swap window: os.Open follows symlinks, so a leaf swapped
+ // to a symlink between the Lstat above and this Open would be followed to its
+ // target. Reject unless the opened handle is the exact file Lstat inspected,
+ // so metadata can never be ingested from outside the library root.
+ if !os.SameFile(leafInfo, info) {
+ return book, fmt.Errorf("ebook OPF sidecar is not a regular file: %s", sidecarPath)
+ }
if info.Size() > maxEPUBMetadataEntrySize {
return book, fmt.Errorf("ebook OPF sidecar too large: %s", sidecarPath)
}
diff --git a/internal/scanner/ebook_scan.go b/internal/scanner/ebook_scan.go
index ec874c8f..3c96d002 100644
--- a/internal/scanner/ebook_scan.go
+++ b/internal/scanner/ebook_scan.go
@@ -1304,8 +1304,17 @@ func insertEbookISBNProviderID(ctx context.Context, exec ebookSQLExecutor, conte
_, err := exec.Exec(ctx, `
INSERT INTO media_item_provider_ids (content_id, provider, provider_id, item_type)
VALUES ($1, 'isbn', $2, 'ebook')
- ON CONFLICT DO NOTHING
+ ON CONFLICT (content_id, provider) DO UPDATE SET
+ provider_id = EXCLUDED.provider_id,
+ updated_at = NOW()
+ WHERE media_item_provider_ids.provider_id IS DISTINCT FROM EXCLUDED.provider_id
`, contentID, isbn)
+ // A different item may already own this ISBN under the (provider, provider_id)
+ // unique constraint; duplicate copies must not fail the scan.
+ var pgErr *pgconn.PgError
+ if errors.As(err, &pgErr) && pgErr.Code == "23505" {
+ return nil
+ }
return err
}
diff --git a/internal/scanner/ebook_test.go b/internal/scanner/ebook_test.go
index 4495f425..a6e538dd 100644
--- a/internal/scanner/ebook_test.go
+++ b/internal/scanner/ebook_test.go
@@ -372,6 +372,62 @@ func TestParseEbookFileAppliesExternalOPFSidecar(t *testing.T) {
}
}
+func TestParseEbookOPFSidecarRejectsSymlinkedSidecar(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "outside-library.opf")
+ sidecar := `
+
+ Smuggled
+`
+ if err := os.WriteFile(target, []byte(sidecar), 0o644); err != nil {
+ t.Fatalf("write target: %v", err)
+ }
+ link := filepath.Join(dir, "Dune.opf")
+ if err := os.Symlink(target, link); err != nil {
+ t.Fatalf("symlink: %v", err)
+ }
+
+ if _, err := parseEbookOPFSidecar(link); err == nil {
+ t.Fatal("parseEbookOPFSidecar accepted a symlinked sidecar; a leaf swapped to a symlink after the Lstat gate would be followed to its target")
+ }
+}
+
+func TestInsertEbookISBNProviderIDReplacesStaleISBN(t *testing.T) {
+ exec := &recordingEbookExecutor{}
+ if err := insertEbookISBNProviderID(context.Background(), exec, "book-1", "9780441172719"); err != nil {
+ t.Fatalf("insertEbookISBNProviderID: %v", err)
+ }
+ if len(exec.queries) != 1 {
+ t.Fatalf("queries = %d, want 1", len(exec.queries))
+ }
+ query := strings.Join(strings.Fields(exec.queries[0]), " ")
+ if !strings.Contains(query, "ON CONFLICT (content_id, provider) DO UPDATE") ||
+ !strings.Contains(query, "provider_id = EXCLUDED.provider_id") {
+ t.Fatalf("ISBN insert does not replace a stale value on rescan:\n%s", query)
+ }
+}
+
+func TestInsertEbookISBNProviderIDToleratesISBNOwnedByAnotherItem(t *testing.T) {
+ exec := &erroringEbookExecutor{err: &pgconn.PgError{Code: "23505"}}
+ if err := insertEbookISBNProviderID(context.Background(), exec, "book-1", "9780441172719"); err != nil {
+ t.Fatalf("unique-violation on the (provider, provider_id) constraint must not fail the scan, got %v", err)
+ }
+
+ boom := errors.New("connection lost")
+ exec = &erroringEbookExecutor{err: boom}
+ if err := insertEbookISBNProviderID(context.Background(), exec, "book-1", "9780441172719"); !errors.Is(err, boom) {
+ t.Fatalf("non-unique-violation error = %v, want %v", err, boom)
+ }
+}
+
+type erroringEbookExecutor struct {
+ err error
+}
+
+func (e *erroringEbookExecutor) Exec(_ context.Context, _ string, _ ...any) (pgconn.CommandTag, error) {
+ return pgconn.CommandTag{}, e.err
+}
+
func TestParseEbookEPUBMetadataAllowsXMLVersion11(t *testing.T) {
path := writeTestEPUBWithOPFBytes(t, []byte(`