|
- package rating
-
- import (
- "encoding/binary"
- "errors"
- "fmt"
- "io"
- "os"
- )
-
- // errPatchRefused signals that the in-place patcher cannot (or should not)
- // apply the change. The caller falls back to a full rewrite. Real I/O errors
- // are returned as-is so we don't silently retry corrupt-disk situations.
- var errPatchRefused = errors.New("rating: in-place patch refused")
-
- // popmEmail is the identifier we write into new POPM frames (Winamp convention).
- const popmEmail = "rating@winamp.com"
-
- // popmCounterBytes — how many bytes of zero "play counter" we append after the
- // rating byte when inserting a fresh frame. 0 is spec-legal but some readers
- // (notably old WMP builds) expect ≥4. 4 zero bytes costs us nothing and is
- // what bogem/id3v2 emits by default for a fresh frame.
- const popmCounterBytes = 4
-
- // tryPatchPOPM attempts to update (or insert) the POPM rating with the
- // smallest possible write — ideally one byte. It never grows the tag area:
- // when there is no existing POPM and no room in padding it refuses
- // (returns errPatchRefused) so the caller can fall back to a full rewrite.
- //
- // stars=0 with no existing POPM is a no-op success (file already unrated).
- // stars=0 with an existing POPM rewrites the rating byte to 0 (the spec's
- // "unknown" value); the frame remains but Get() returns 0.
- //
- // Safety invariants enforced:
- // - Only ID3v2.3 / v2.4 with no header-level flags (no unsync, no extended
- // header, no footer, no experimental).
- // - All writes must land strictly inside the tag area
- // (offset ∈ [10, 10+tagSize)).
- // - Padding region must verify as all-zero before we write into it.
- // - POPM frame flags must be zero (no compression / encryption / data-length
- // indicator that would change how we'd interpret the rating offset).
- // - Frame walking respects each frame's declared size; any inconsistency
- // (size out of bounds, invalid frame ID) aborts via errPatchRefused.
- func tryPatchPOPM(path string, stars int) error {
- if stars < 0 || stars > 5 {
- return fmt.Errorf("tryPatchPOPM: stars out of range: %d", stars)
- }
-
- f, err := os.OpenFile(path, os.O_RDWR, 0)
- if err != nil {
- return err
- }
- defer f.Close()
-
- // ── Parse 10-byte ID3v2 header ────────────────────────────────────────────
- var hdr [10]byte
- if _, err := io.ReadFull(f, hdr[:]); err != nil {
- return errPatchRefused
- }
- if string(hdr[:3]) != "ID3" {
- return errPatchRefused
- }
- ver := hdr[3]
- if ver != 3 && ver != 4 {
- return errPatchRefused
- }
- // Any header-level flag bit set → refuse. Includes unsynchronisation,
- // extended header, experimental, and (v2.4) footer present.
- if hdr[5] != 0 {
- return errPatchRefused
- }
- // Synchsafe size: each of the 4 bytes must have its high bit clear.
- if hdr[6]&0x80 != 0 || hdr[7]&0x80 != 0 || hdr[8]&0x80 != 0 || hdr[9]&0x80 != 0 {
- return errPatchRefused
- }
- tagSize := int(uint32(hdr[6])<<21 | uint32(hdr[7])<<14 | uint32(hdr[8])<<7 | uint32(hdr[9]))
- if tagSize <= 0 || tagSize > 64*1024*1024 {
- return errPatchRefused
- }
-
- body := make([]byte, tagSize)
- if _, err := io.ReadFull(f, body); err != nil {
- return errPatchRefused
- }
-
- // ── Walk frames, look for existing POPM ───────────────────────────────────
- off := 0
- for off+10 <= tagSize {
- // First byte zero where a frame ID would start → padding region begins.
- if body[off] == 0 {
- break
- }
- id := string(body[off : off+4])
- if !validFrameID(id) {
- return errPatchRefused
- }
-
- var frameSize int
- switch ver {
- case 3:
- frameSize = int(binary.BigEndian.Uint32(body[off+4 : off+8]))
- case 4:
- b := body[off+4 : off+8]
- if b[0]&0x80 != 0 || b[1]&0x80 != 0 || b[2]&0x80 != 0 || b[3]&0x80 != 0 {
- return errPatchRefused
- }
- frameSize = int(uint32(b[0])<<21 | uint32(b[1])<<14 | uint32(b[2])<<7 | uint32(b[3]))
- }
- if frameSize <= 0 || off+10+frameSize > tagSize {
- return errPatchRefused
- }
-
- if id == "POPM" {
- // Reject if frame has any flags — they could change data layout
- // (compression, encryption, v2.4 data-length indicator).
- if body[off+8] != 0 || body[off+9] != 0 {
- return errPatchRefused
- }
- data := body[off+10 : off+10+frameSize]
-
- // Find the null terminator of the email field.
- nullIdx := -1
- for i, c := range data {
- if c == 0 {
- nullIdx = i
- break
- }
- }
- if nullIdx < 0 || nullIdx+1 >= len(data) {
- return errPatchRefused
- }
-
- ratingFileOffset := int64(10 + off + 10 + nullIdx + 1)
- // Strict bounds check: must be strictly inside the tag region.
- if ratingFileOffset < 10 || ratingFileOffset >= int64(10+tagSize) {
- return errPatchRefused
- }
-
- newRating := starsToPOPM(stars)
- if _, err := f.WriteAt([]byte{newRating}, ratingFileOffset); err != nil {
- return err
- }
- return f.Sync()
- }
-
- off += 10 + frameSize
- }
-
- // ── No POPM frame in the tag ──────────────────────────────────────────────
- if stars == 0 {
- return nil // already unrated, nothing to write
- }
-
- padStart := off
- // Verify the entire remaining tag region is zero. If anything else is
- // hiding there (some other tool's data, a malformed frame), refuse.
- for i := padStart; i < tagSize; i++ {
- if body[i] != 0 {
- return errPatchRefused
- }
- }
- paddingLen := tagSize - padStart
-
- frame := buildPOPMFrame(ver, stars)
- if len(frame) > paddingLen {
- return errPatchRefused
- }
-
- writeOffset := int64(10 + padStart)
- // Strict bounds: write must end at or before the end of the tag region.
- if writeOffset < 10 || writeOffset+int64(len(frame)) > int64(10+tagSize) {
- return errPatchRefused
- }
-
- if _, err := f.WriteAt(frame, writeOffset); err != nil {
- return err
- }
- return f.Sync()
- }
-
- // validFrameID returns true if id is a 4-character ASCII frame identifier
- // using only [A-Z0-9], per ID3v2.3/2.4. Anything else and we're not looking
- // at a real frame.
- func validFrameID(id string) bool {
- if len(id) != 4 {
- return false
- }
- for i := 0; i < 4; i++ {
- c := id[i]
- if !((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) {
- return false
- }
- }
- return true
- }
-
- // buildPOPMFrame returns the full bytes of a fresh POPM frame:
- //
- // "POPM" | size(4) | flags(2) | email | 0x00 | rating | counter(4 zero bytes)
- //
- // Size encoding depends on the ID3v2 minor version (3 = plain BE uint32,
- // 4 = synchsafe).
- func buildPOPMFrame(ver byte, stars int) []byte {
- dataLen := len(popmEmail) + 1 + 1 + popmCounterBytes
- out := make([]byte, 10+dataLen)
- copy(out[0:4], "POPM")
- switch ver {
- case 3:
- binary.BigEndian.PutUint32(out[4:8], uint32(dataLen))
- case 4:
- s := uint32(dataLen)
- out[4] = byte((s >> 21) & 0x7F)
- out[5] = byte((s >> 14) & 0x7F)
- out[6] = byte((s >> 7) & 0x7F)
- out[7] = byte(s & 0x7F)
- }
- // flags out[8], out[9] are zero
- copy(out[10:], popmEmail)
- out[10+len(popmEmail)] = 0 // null terminator
- out[10+len(popmEmail)+1] = starsToPOPM(stars)
- // counter bytes already zero
- return out
- }
|