fix(rtl,pp): pre-release safety round (Wave 2)

Five concrete gaps the audit flagged in the new __dbCopy / __dbSort /
__dbTotal / __dbJoin / PP code:

  * wam.Close() errors were dropped on the floor. Caller saw `.T.`
    even when the just-written DBF wasn't durable, leading to the
    classic "delete the source after the COPY succeeds" data-loss
    pattern. All four functions now capture the close error and
    return `.F.` if it fired.

  * drv.Create succeeded → wam.Open failed → orphaned-on-disk DBF.
    The user-named target file was left around with zero records,
    and the next call's drv.Create silently truncated it instead of
    surfacing the original error. Add `os.Remove(cFile)` on the
    Open-failure cleanup path for COPY/SORT/TOTAL/JOIN.

  * __dbTotal would write the DBF codec's overflow sentinel
    (`*****`) into the destination's sum-fields when a group total
    didn't fit in the source's declared field width, and still
    return `.T.`. Now: precompute each sum-field's max representable
    magnitude (10^(Len-Dec)) at start, mark the run as overflowed if
    any flush sees an out-of-range or NaN value, and propagate
    `.F.` to the caller so they don't trust the file.

  * cleanUnreferencedMarkers walked byte-by-byte and stripped any
    `<ident>` token in the result, INCLUDING ones that appear
    inside `"..."` / `'...'` string literals. A user expression
    like `LIST FOR url == "<a>x</a>"` got the `<a>` and `</a>`
    eaten on output. Now: track string-literal state and skip the
    cleanup pass while inside one. Bracket-strings `[…]` are
    intentionally not treated as strings here — the result template
    uses `[...]` as the optional-repeat marker, and disambiguating
    needs context the cleanup pass doesn't have.

  * (#8 SET SAFETY honoring) deferred. Harbour default is SAFETY
    OFF, so the current always-overwrite behavior matches default
    Harbour. The divergence only matters when user explicitly does
    `SET SAFETY ON`, which Five doesn't support yet — so the
    no-overwrite-protection is consistent end-to-end. Tracked as a
    separate followup.

Gates green:
  go test ./...      : PASS
  FiveSql2 SQL:1999  : 43/43
  Harbour compat     : 56/56

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 07:54:41 +09:00
parent 000500e034
commit f30704a854
2 changed files with 101 additions and 11 deletions

View File

@@ -9,6 +9,7 @@ package hbrtl
import (
"fmt"
"os"
"strings"
"five/hbrt"
@@ -884,6 +885,11 @@ func rtlDbCopy(t *hbrt.Thread) {
srcSel := wam.CurrentNum()
dstSel, err := wam.Open("DBFNTX", cFile, "__copytmp", false, false)
if err != nil {
// drv.Create wrote the file but Open failed (alias collision,
// area-table full, ...). Without cleanup the user is left
// with a stale-zero-row DBF on disk that the next call will
// silently truncate again.
_ = os.Remove(cFile)
t.RetBool(false)
return
}
@@ -926,9 +932,16 @@ func rtlDbCopy(t *hbrt.Thread) {
}
// Close the destination, leaving the source selected as on entry.
// A close error means the just-written DBF may be partially
// flushed — surface it so the caller doesn't assume the file is
// durable and proceed to delete the source.
wam.SelectByNum(dstSel)
wam.Close()
closeErr := wam.Close()
wam.SelectByNum(srcSel)
if closeErr != nil {
t.RetBool(false)
return
}
t.RetBool(true)
}
@@ -1103,6 +1116,7 @@ func rtlDbSort(t *hbrt.Thread) {
srcSel := wam.CurrentNum()
dstSel, err := wam.Open("DBFNTX", cFile, "__sorttmp", false, false)
if err != nil {
_ = os.Remove(cFile)
t.RetBool(false)
return
}
@@ -1113,8 +1127,13 @@ func rtlDbSort(t *hbrt.Thread) {
dstArea.PutValue(i, v)
}
}
wam.Close()
wam.SelectByNum(dstSel)
closeErr := wam.Close()
wam.SelectByNum(srcSel)
if closeErr != nil {
t.RetBool(false)
return
}
t.RetBool(true)
}
@@ -1364,6 +1383,7 @@ func rtlDbTotal(t *hbrt.Thread) {
srcSel := wam.CurrentNum()
dstSel, err := wam.Open("DBFNTX", cFile, "__totaltmp", false, false)
if err != nil {
_ = os.Remove(cFile)
t.RetBool(false)
return
}
@@ -1374,6 +1394,28 @@ func rtlDbTotal(t *hbrt.Thread) {
var prevKey hbrt.Value
haveGroup := false
running := make([]float64, len(sums))
overflowed := false
// Pre-compute the max-magnitude representable in each sum-field:
// 10^(Len-Dec) - 10^(-Dec). Anything at or beyond this gets
// formatted as `*****` by the DBF codec, so we surface the issue
// instead of writing garbage.
maxAbs := make([]float64, len(sums))
for i, sp := range sums {
fi := dstFields[sp.dst]
intDigits := fi.Len - fi.Dec
if fi.Dec > 0 {
intDigits--
}
if intDigits < 0 {
intDigits = 0
}
m := 1.0
for d := 0; d < intDigits; d++ {
m *= 10
}
maxAbs[i] = m
}
flush := func() {
if !haveGroup {
@@ -1385,7 +1427,15 @@ func rtlDbTotal(t *hbrt.Thread) {
// preserving the field's declared length/decimals.
for i, sp := range sums {
fi := dstFields[sp.dst]
dstArea.PutValue(sp.dst, hbrt.MakeDouble(running[i], uint16(fi.Len), uint16(fi.Dec)))
v := running[i]
abs := v
if abs < 0 {
abs = -abs
}
if v != v || abs >= maxAbs[i] { // NaN or out-of-range
overflowed = true
}
dstArea.PutValue(sp.dst, hbrt.MakeDouble(v, uint16(fi.Len), uint16(fi.Dec)))
}
wam.SelectByNum(srcSel)
for i := range running {
@@ -1446,8 +1496,19 @@ func rtlDbTotal(t *hbrt.Thread) {
flush()
wam.SelectByNum(dstSel)
wam.Close()
closeErr := wam.Close()
wam.SelectByNum(srcSel)
if closeErr != nil {
t.RetBool(false)
return
}
if overflowed {
// One or more group totals didn't fit in the destination
// field's declared width. The DBF codec wrote `*****` for
// those cells; flag the caller so they don't trust the file.
t.RetBool(false)
return
}
t.RetBool(true)
}
@@ -1601,6 +1662,7 @@ func rtlDbJoin(t *hbrt.Thread) {
}
dstSel, err := wam.Open("DBFNTX", cFile, "__jointmp", false, false)
if err != nil {
_ = os.Remove(cFile)
t.RetBool(false)
return
}
@@ -1645,8 +1707,12 @@ func rtlDbJoin(t *hbrt.Thread) {
}
wam.SelectByNum(dstSel)
wam.Close()
closeErr := wam.Close()
wam.SelectByNum(masterSel)
if closeErr != nil {
t.RetBool(false)
return
}
t.RetBool(true)
}