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 }