审计修复:后端(安全 / 数据一致性 / 性能)
audit-2026-09-21.md 里的后端条目落地。每条都配了回归守卫,并逐条做过变异验证 (注入原 bug → 确认按正确理由变红 → 还原 → 变绿)。 安全 - CSRF 只加同源 Origin/Referer 守卫(csrf.go),不动模板 - 会话 cookie 的 Secure 按请求判断;两步验证两闸(loginGuard + 验证码一次性) - 跳转收口到 safeInternalPath;clientIP 只认 XFF 最后一段 - GPX 按属主圈定且查 RowsAffected;下载名走 downloadDisposition - 上传口统一 MaxBytesReader;落盘后的失败路径清掉半成品 数据一致性 - 区域判定两端各过一遍再 AND(apiTrack 与 movementsWhere 对齐) - Filter.Verdict 全域一致(下推到 visitsWhere);聚合刷新必须返回 error - 附属数据改挂坐标键,Go 与 SQL 两套表达式逐位一致 - 脏备份的伪时间/越界坐标不再进领域模型;rows 循环统一 drainRows - 合并与导出:writeRawRow 上抛错误、悬空引用清理、原生列补全 性能 - placeCols 相关子查询带 dataset_id 前导列;findSourceArchive 先按大小筛 - 批量勾选改 ANY($3::bigint[]);fillupCache 加上限;同一查询不再查两遍 另:新增「全新库」冒烟测试 —— 空库跑完 Migrate 再用应用侧读查询真打一遍, 迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
This commit is contained in:
@@ -110,6 +110,29 @@ func PlaceCoordKey(lat, lon float64) string {
|
||||
|
||||
func coordStr(v float64) string { return fmt.Sprintf("%.6f", v) }
|
||||
|
||||
// RawFloat8 返回「把 entity_raw 里的 JSON 值安全地转成 float8」的 SQL 表达式,
|
||||
// 值不是合法的数字就给 NULL,而不是抛错。
|
||||
//
|
||||
// 留档是原样存下来的 JSON,值不一定是数字:空串、字符串形式的 "1.2.3"、
|
||||
// "NaN" 都可能出现。直接 `::float8` 会抛 invalid input syntax,代价是**编辑页
|
||||
// 整页 500** 或**整次导入失败 / 启动自愈失败**,而且只在特定那一行数据上复现。
|
||||
//
|
||||
// 两个实现细节都有原因,别简化:
|
||||
// - 写成 CASE 而不是「正则 AND cast」:PG 不保证 AND/OR 各操作数的求值顺序,
|
||||
// 正则没挡住时照样会炸;CASE 只求值命中的那个分支。
|
||||
// - 正则把**指数限在两位**,于是最大幅度约 1e109,本身就进不了 float8 的溢出区
|
||||
// (实测 `'1e999'` 这类值在 CASE 里一样会抛 out of range)。这样不依赖
|
||||
// PG 版本——`pg_input_is_valid` 要 PG 16+,本项目的库版本没有写死。
|
||||
// 坐标用不上 1e109 以上的写法,也不会小到 1e-100 以下。
|
||||
//
|
||||
// RawFloatPattern 是同一个正则(不带引号),给「只想判合不合法、不取数值」的地方用:
|
||||
// 光一个 `~` 判断不会抛错,所以那种地方可以直接串它。
|
||||
const RawFloatPattern = `^[+-]?([0-9]{1,10}(\.[0-9]*)?|\.[0-9]{1,10})([eE][+-]?[0-9]{1,2})?$`
|
||||
|
||||
func RawFloat8(expr string) string {
|
||||
return `CASE WHEN ` + expr + ` ~ '` + RawFloatPattern + `' THEN (` + expr + `)::float8 END`
|
||||
}
|
||||
|
||||
// DiffVisits 按 UUID 对齐两边的到访。
|
||||
//
|
||||
// 真机偶尔有条目没写 UUID(实测 824 行到访里有 2 行),只按 UUID 配对会让同一条既算
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package rond
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func nullF(v float64) sql.NullFloat64 { return sql.NullFloat64{Float64: v, Valid: true} }
|
||||
|
||||
// 脏值必须判成「装不下」(nil)。旧实现直接 int64(sec),超范围是回绕不是饱和:
|
||||
// 实测 NaN/±Inf/1e30 恰好回绕到基准点(2001-01-01)、1e12 落到 2123 年。
|
||||
func TestAppleTimeRejectsDirtyValues(t *testing.T) {
|
||||
dirty := map[string]float64{
|
||||
"NaN": math.NaN(),
|
||||
"+Inf": math.Inf(1),
|
||||
"-Inf": math.Inf(-1),
|
||||
"1e30": 1e30,
|
||||
"-1e30": -1e30,
|
||||
"1e12": 1e12, // 会落到 2123 年
|
||||
"-1e12": -1e12,
|
||||
"早于窗口": appleMinSec - 86400,
|
||||
"晚于窗口": appleMaxSec + 86400,
|
||||
}
|
||||
for name, v := range dirty {
|
||||
if tm := appleTime(nullF(v)); tm != nil {
|
||||
t.Errorf("%s 应当被拒(nil),实际得到 %s", name, tm.Format(time.RFC3339Nano))
|
||||
}
|
||||
}
|
||||
if appleTime(sql.NullFloat64{}) != nil {
|
||||
t.Error("NULL 应当返回 nil")
|
||||
}
|
||||
}
|
||||
|
||||
// 合法值要原样保留,尤其是微秒精度——整数/小数分开算就是为了它。
|
||||
func TestAppleTimeKeepsLegitValues(t *testing.T) {
|
||||
for _, want := range []time.Time{
|
||||
time.Date(1995, 7, 1, 12, 30, 45, 123456000, time.UTC),
|
||||
time.Date(2018, 3, 9, 21, 4, 5, 987654000, time.UTC),
|
||||
time.Date(2026, 9, 21, 10, 19, 9, 1000, time.UTC),
|
||||
} {
|
||||
got := appleTime(nullF(want.Sub(appleEpoch).Seconds()))
|
||||
if got == nil {
|
||||
t.Fatalf("%s 被误拒", want.Format(time.RFC3339Nano))
|
||||
}
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("时间失真: 期望 %s 得到 %s", want.Format(time.RFC3339Nano), got.Format(time.RFC3339Nano))
|
||||
}
|
||||
}
|
||||
for _, edge := range []float64{appleMinSec, appleMaxSec} {
|
||||
if appleTime(nullF(edge)) == nil {
|
||||
t.Errorf("窗口边界 %.0f 不该被拒", edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidCoord(t *testing.T) {
|
||||
for _, c := range [][2]float64{
|
||||
{math.NaN(), 0}, {0, math.NaN()}, {math.Inf(1), 0}, {0, math.Inf(-1)},
|
||||
{91, 0}, {-91, 0}, {0, 181}, {0, -181}, {999, 999},
|
||||
} {
|
||||
if validCoord(c[0], c[1]) {
|
||||
t.Errorf("无效坐标 (%v,%v) 被判成可用", c[0], c[1])
|
||||
}
|
||||
}
|
||||
for _, c := range [][2]float64{{0, 0}, {24.1, 109.1}, {90, 180}, {-90, -180}, {39.9042, 116.4074}} {
|
||||
if !validCoord(c[0], c[1]) {
|
||||
t.Errorf("合法坐标 (%v,%v) 被误拒", c[0], c[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 函数再对,没接上去也白搭:读 ZLOCATION 的循环必须过一遍 validCoord。
|
||||
func TestParseSanitizesLocationCoords(t *testing.T) {
|
||||
_, self, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("拿不到测试文件路径")
|
||||
}
|
||||
src, err := os.ReadFile(filepath.Join(filepath.Dir(self), "parse.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(src)
|
||||
i := strings.Index(text, "FROM ZLOCATION")
|
||||
if i < 0 {
|
||||
t.Fatal("parse.go 里找不到 ZLOCATION 查询")
|
||||
}
|
||||
j := strings.Index(text[i:], "b.Locations = append")
|
||||
if j < 0 {
|
||||
t.Fatal("找不到了 ZLOCATION 循环的收尾")
|
||||
}
|
||||
if !strings.Contains(text[i:i+j], "validCoord(") {
|
||||
t.Error("读 ZLOCATION 的循环没有调用 validCoord 归一化坐标")
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:脏备份走一遍真实的 Parse,不许把伪时间/越界坐标带进领域模型。
|
||||
func TestParseRejectsDirtyTimeAndCoords(t *testing.T) {
|
||||
good := time.Date(2026, 9, 20, 8, 0, 0, 0, time.UTC)
|
||||
goodSec := good.Sub(appleEpoch).Seconds()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "Backup.sqlite")
|
||||
sdb, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer sdb.Close()
|
||||
for _, stmt := range []string{
|
||||
`CREATE TABLE ZTAGGROUP (Z_PK INTEGER PRIMARY KEY, ZNAME_ TEXT)`,
|
||||
`CREATE TABLE ZTAG (Z_PK INTEGER PRIMARY KEY, ZNAME_ TEXT, ZCOLOR_ TEXT, ZGROUP INTEGER)`,
|
||||
`CREATE TABLE ZACTIVITY (Z_PK INTEGER PRIMARY KEY, ZNAME_ TEXT, ZCOLOR_ TEXT, ZICON_ TEXT,
|
||||
ZISHOME INTEGER, ZISWORK INTEGER, ZISEXCLUDED INTEGER, ZISARCHIVED INTEGER, ZUID_ BLOB)`,
|
||||
`CREATE TABLE ZTRANSPORT (Z_PK INTEGER PRIMARY KEY, ZNAME_ TEXT, ZCOLOR_ TEXT, ZICON_ TEXT)`,
|
||||
`CREATE TABLE ZLOCATION (Z_PK INTEGER PRIMARY KEY, ZNAME_ TEXT, ZCATEGORY_ TEXT,
|
||||
ZLATITUDE REAL, ZLONGITUDE REAL, ZISOCOUNTRYCODE TEXT, ZADMINISTRATIVEAREA TEXT,
|
||||
ZLOCALITY TEXT, ZSUBLOCALITY TEXT, ZTHOROUGHFARE TEXT, ZSUBTHOROUGHFARE TEXT, ZTIMEZONE TEXT)`,
|
||||
`CREATE TABLE ZVISIT (Z_PK INTEGER PRIMARY KEY, ZLOCATION INTEGER, ZACTIVITY_ INTEGER,
|
||||
ZARRIVALDATE_ REAL, ZDEPARTUREDATE_ REAL, ZBOOKMARKED INTEGER, ZUSERADDED INTEGER,
|
||||
ZREMARK_ TEXT, ZEMOJINAME_ TEXT, ZWEATHERSYMBOL_ TEXT, ZIDENTIFIER_ BLOB)`,
|
||||
`CREATE TABLE Z_11VISITS_ (Z_18VISITS_ INTEGER, Z_11TAGS_5 INTEGER)`,
|
||||
`CREATE TABLE ZMOVEMENT (Z_PK INTEGER PRIMARY KEY, ZTRANSPORT_ INTEGER, ZVISITFROM_ INTEGER,
|
||||
ZVISITTO_ INTEGER, ZSTART_ REAL, ZEND_ REAL)`,
|
||||
`CREATE TABLE ZHOURLYWEATHER (Z_PK INTEGER PRIMARY KEY, ZVISIT INTEGER, ZDATE_ REAL,
|
||||
ZISDAYLIGHT INTEGER, ZTEMPERATURE_ REAL, ZAPPARENTTEMPERATURE_ REAL, ZHUMIDITY_ REAL,
|
||||
ZPRECIPITATIONAMOUNT_ REAL, ZPRECIPITATIONCHANCE_ REAL, ZWINDSPEED_ REAL, ZVISIBILITY_ REAL,
|
||||
ZUVINDEXVALUE_ INTEGER, ZCONDITION_ TEXT, ZSYMBOLNAME_ TEXT, ZUVINDEXCATEGORY_ TEXT,
|
||||
ZWINDCOMPASSDIRECTION_ TEXT)`,
|
||||
} {
|
||||
if _, err := sdb.Exec(stmt); err != nil {
|
||||
t.Fatalf("建表失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := sdb.Exec(q, args...); err != nil {
|
||||
t.Fatalf("插入失败: %v", err)
|
||||
}
|
||||
}
|
||||
exec(`INSERT INTO ZACTIVITY (Z_PK, ZNAME_, ZCOLOR_, ZICON_, ZISHOME, ZISWORK, ZISEXCLUDED, ZISARCHIVED)
|
||||
VALUES (1,'通勤','#fff','🚇',0,0,0,0)`)
|
||||
// 1=正常;2=坐标越界;3=坐标量级离谱(模拟 NaN 入库前的形态)
|
||||
exec(`INSERT INTO ZLOCATION (Z_PK, ZNAME_, ZLATITUDE, ZLONGITUDE) VALUES (1,'正常',24.1,109.1)`)
|
||||
exec(`INSERT INTO ZLOCATION (Z_PK, ZNAME_, ZLATITUDE, ZLONGITUDE) VALUES (2,'越界',999,-999)`)
|
||||
exec(`INSERT INTO ZLOCATION (Z_PK, ZNAME_, ZLATITUDE, ZLONGITUDE) VALUES (3,'离谱',1e30,0)`)
|
||||
// 1=正常时间;2=ZARRIVALDATE_ 落到 2123 年;3=量级离谱(旧实现回绕成 2001)
|
||||
exec(`INSERT INTO ZVISIT (Z_PK, ZLOCATION, ZACTIVITY_, ZARRIVALDATE_, ZDEPARTUREDATE_)
|
||||
VALUES (1,1,1,?,?)`, goodSec, goodSec+3600)
|
||||
exec(`INSERT INTO ZVISIT (Z_PK, ZLOCATION, ZACTIVITY_, ZARRIVALDATE_) VALUES (2,2,1,1e12)`)
|
||||
exec(`INSERT INTO ZVISIT (Z_PK, ZLOCATION, ZACTIVITY_, ZARRIVALDATE_) VALUES (3,3,1,-1e30)`)
|
||||
// 位移:时间坏了保留「有行、无时间」,不能写成伪时间
|
||||
exec(`INSERT INTO ZMOVEMENT (Z_PK, ZTRANSPORT_, ZVISITFROM_, ZVISITTO_, ZSTART_, ZEND_)
|
||||
VALUES (1,1,1,1,?,?)`, goodSec, goodSec+600)
|
||||
exec(`INSERT INTO ZMOVEMENT (Z_PK, ZTRANSPORT_, ZVISITFROM_, ZVISITTO_, ZSTART_, ZEND_)
|
||||
VALUES (2,1,1,1,1e12,1e12)`)
|
||||
|
||||
b, err := Parse(sdb)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
byLoc := map[int]Location{}
|
||||
for _, l := range b.Locations {
|
||||
byLoc[l.SrcPK] = l
|
||||
}
|
||||
if got := byLoc[1]; got.Lat != 24.1 || got.Lon != 109.1 {
|
||||
t.Errorf("正常坐标被改动了: (%v,%v)", got.Lat, got.Lon)
|
||||
}
|
||||
if got := byLoc[2]; got.Lat != 0 || got.Lon != 0 {
|
||||
t.Errorf("越界坐标应归零,实际 (%v,%v)", got.Lat, got.Lon)
|
||||
}
|
||||
if got := byLoc[3]; got.Lat != 0 || got.Lon != 0 {
|
||||
t.Errorf("量级离谱的坐标应归零,实际 (%v,%v)", got.Lat, got.Lon)
|
||||
}
|
||||
|
||||
byVisit := map[int]Visit{}
|
||||
for _, v := range b.Visits {
|
||||
byVisit[v.SrcPK] = v
|
||||
}
|
||||
if v := byVisit[1]; v.Arrival == nil || !v.Arrival.Equal(good) {
|
||||
t.Errorf("正常到访时间丢了: %v", v.Arrival)
|
||||
}
|
||||
for _, pk := range []int{2, 3} {
|
||||
if v := byVisit[pk]; v.Arrival != nil {
|
||||
t.Errorf("到访 #%d 的脏时间应当被拒,实际 %s", pk, v.Arrival.Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
if len(b.Movements) != 2 {
|
||||
t.Fatalf("位移条数不对: %d", len(b.Movements))
|
||||
}
|
||||
if m := b.Movements[0]; m.Start == nil || !m.Start.Equal(good) {
|
||||
t.Errorf("正常位移时间丢了: %v", m.Start)
|
||||
}
|
||||
if m := b.Movements[1]; m.Start != nil || m.End != nil {
|
||||
t.Errorf("脏位移时间应当为空而不是伪时间: start=%v end=%v", m.Start, m.End)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package rond
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCopyLimitedRejectsOverrun 守住备份包解压的体积上限。
|
||||
// zip 头里的 UncompressedSize 是攻击者可控字段,不能只信声明——必须在读的时候掐住。
|
||||
func TestCopyLimitedRejectsOverrun(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := copyLimited(&buf, strings.NewReader(strings.Repeat("a", 100)), 99); err == nil {
|
||||
t.Error("超过上限应当报错")
|
||||
}
|
||||
|
||||
// 恰好等于上限要放过,内容也不能被截断
|
||||
buf.Reset()
|
||||
if err := copyLimited(&buf, strings.NewReader(strings.Repeat("a", 100)), 100); err != nil {
|
||||
t.Errorf("恰好等于上限不该报错: %v", err)
|
||||
}
|
||||
if buf.Len() != 100 {
|
||||
t.Errorf("正常内容被截断了: %d 字节(应为 100)", buf.Len())
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
if err := copyLimited(&buf, strings.NewReader("短"), 100); err != nil {
|
||||
t.Errorf("正常内容不该报错: %v", err)
|
||||
}
|
||||
if buf.String() != "短" {
|
||||
t.Errorf("内容被改动了: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnpackArchiveEnforcesLimit 确认上限真的接在 Open 的解压路径上,
|
||||
// 而不只是 copyLimited 孤零零地正确。上限抽成参数就是为了能在这里用小值验一遍。
|
||||
func TestUnpackArchiveEnforcesLimit(t *testing.T) {
|
||||
// 真上限(2 GiB)没法用一个小包走一遍,退一步把它圈在「挡得住 zip bomb、
|
||||
// 又不至于误伤真备份」的区间里——顺手拦住把常量改成 1<<62 这类手滑。
|
||||
if maxDBBytes < 1<<30 || maxDBBytes > 8<<30 {
|
||||
t.Errorf("maxDBBytes=%d 超出合理区间 [1GiB, 8GiB]", maxDBBytes)
|
||||
}
|
||||
|
||||
const payload = 4096
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for _, name := range []string{"Backup.sqlite", "Backup.sqlite-wal"} {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(bytes.Repeat([]byte("s"), payload)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
// 上限低于条目体积:必须报错,而且不留半个文件在盘上
|
||||
if _, err := unpackArchive(zr, dir, payload-1); err == nil {
|
||||
t.Error("条目超过上限时应当报错")
|
||||
} else {
|
||||
t.Logf("已拒绝: %v", err)
|
||||
}
|
||||
if left, _ := filepathGlob(dir); len(left) != 0 {
|
||||
t.Errorf("被拒绝的条目留下了残留文件: %v", left)
|
||||
}
|
||||
|
||||
// 放宽到够用:正常解出主库路径
|
||||
dbPath, err := unpackArchive(zr, dir, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("上限够用时不该报错: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(dbPath, "Backup.sqlite") {
|
||||
t.Errorf("返回的主库路径不对: %s", dbPath)
|
||||
}
|
||||
}
|
||||
|
||||
// filepathGlob 列出 dir 下的文件(测试辅助,避免为上一条再引一个包)。
|
||||
func filepathGlob(dir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
+124
-28
@@ -24,19 +24,44 @@ import (
|
||||
// Core Data 时间戳基准:2001-01-01 UTC
|
||||
var appleEpoch = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// 允许的时间窗口 [1970, 2100)。脏备份里会出现 NaN / ±Inf / 量级离谱的值,
|
||||
// 而 int64 转换对超范围值是回绕而非饱和——实测 1e30 恰好回绕成 0(=基准点 2001)、
|
||||
// 1e12 变成 2123 年,都是「看着正常」的伪时间,再被当成 first/last_visit_at
|
||||
// 污染整条时间轴。必须先卡输入秒数:回绕后的结果可能又落回合法区间,只查结果挡不住。
|
||||
var (
|
||||
appleMinSec = time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC).Sub(appleEpoch).Seconds()
|
||||
appleMaxSec = time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC).Sub(appleEpoch).Seconds()
|
||||
)
|
||||
|
||||
func appleTime(v sql.NullFloat64) *time.Time {
|
||||
if !v.Valid {
|
||||
if !v.Valid || math.IsNaN(v.Float64) || math.IsInf(v.Float64, 0) {
|
||||
return nil
|
||||
}
|
||||
sec := math.Floor(v.Float64)
|
||||
if sec < appleMinSec || sec > appleMaxSec {
|
||||
return nil
|
||||
}
|
||||
// 秒的整数部分和小数部分分开算:8 亿量级的秒乘 1e6/1e9 已超出 float64 的
|
||||
// 精确整数范围(2^53),一次性相乘再取整会系统性吃掉最后 1 微秒,
|
||||
// 往返导出后时间戳就对不上了。
|
||||
sec := math.Floor(v.Float64)
|
||||
us := int64(sec)*1_000_000 + int64(math.Round((v.Float64-sec)*1e6))
|
||||
t := appleEpoch.Add(time.Duration(us) * time.Microsecond).Local()
|
||||
return &t
|
||||
}
|
||||
|
||||
// validCoord 判定坐标是否可用。SQLite 的 REAL 列可能塞进 NaN,备份也可能带出
|
||||
// ±90 / ±180 之外的值;这类值会让地图与迷雾索引的网格算出 NaN 或越界格。
|
||||
// 无效就按「无坐标」处理——与管线里 COALESCE(NULL,0) 的既有约定一致。
|
||||
func validCoord(lat, lon float64) bool {
|
||||
if math.IsNaN(lat) || math.IsInf(lat, 0) || lat < -90 || lat > 90 {
|
||||
return false
|
||||
}
|
||||
if math.IsNaN(lon) || math.IsInf(lon, 0) || lon < -180 || lon > 180 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type Activity struct {
|
||||
SrcPK int
|
||||
Name string
|
||||
@@ -165,6 +190,23 @@ type Backup struct {
|
||||
ActivityUUID map[int]string
|
||||
}
|
||||
|
||||
// maxDBBytes 是备份包内单个 SQLite 文件解压后的上限。真机的库是几十 MB 量级,
|
||||
// 2 GiB 足够宽松,但足以挡住 zip bomb——压缩比上千时几 MB 的包能解出 TB 级。
|
||||
const maxDBBytes = 2 << 30
|
||||
|
||||
// copyLimited 把 r 拷进 w,超过 limit 就报错。
|
||||
// zip 头里的 UncompressedSize 是攻击者可控字段,不能只信声明,必须在读的时候掐住。
|
||||
func copyLimited(w io.Writer, r io.Reader, limit int64) error {
|
||||
n, err := io.Copy(w, io.LimitReader(r, limit+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > limit {
|
||||
return fmt.Errorf("解压后超过 %d 字节,疑似异常备份包", limit)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Open 解压备份包到 dir,返回可用的 SQLite 连接。
|
||||
// 调用方负责 Close 与删除 dir。
|
||||
func Open(archivePath, dir string) (*sql.DB, error) {
|
||||
@@ -177,6 +219,28 @@ func Open(archivePath, dir string) (*sql.DB, error) {
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
dbPath, err := unpackArchive(&zr.Reader, dir, maxDBBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(dbPath)+"?_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
// 把 WAL 合并回主库,避免后续读取时依赖 -shm 状态
|
||||
if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("合并 WAL 失败: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// unpackArchive 把包里的 Core Data 主库及其 WAL / SHM 解压到 dir,返回主库路径。
|
||||
// limit 是单个条目解压后的字节上限;抽成参数是为了让上限本身可测
|
||||
// (真上限 2 GiB,测试里没法造出一个 2 GiB 的 zip bomb)。
|
||||
func unpackArchive(zr *zip.Reader, dir string, limit int64) (string, error) {
|
||||
var dbPath string
|
||||
for _, f := range zr.File {
|
||||
base := filepath.Base(f.Name)
|
||||
@@ -191,38 +255,39 @@ func Open(archivePath, dir string) (*sql.DB, error) {
|
||||
dst := filepath.Join(dir, base)
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
_, cerr := io.Copy(out, rc)
|
||||
cerr := copyLimited(out, rc, limit)
|
||||
rc.Close()
|
||||
out.Close()
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
os.Remove(dst)
|
||||
return "", fmt.Errorf("解压 %s: %w", base, cerr)
|
||||
}
|
||||
if strings.HasSuffix(low, ".sqlite") {
|
||||
dbPath = dst
|
||||
}
|
||||
}
|
||||
if dbPath == "" {
|
||||
return nil, fmt.Errorf("备份包内未找到 .sqlite 数据库文件")
|
||||
return "", fmt.Errorf("备份包内未找到 .sqlite 数据库文件")
|
||||
}
|
||||
return dbPath, nil
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(dbPath)+"?_pragma=busy_timeout(5000)")
|
||||
// drainRows 收尾 rows 循环。只 Close 不查 Err:驱动或连接中途出错时 Next() 只会
|
||||
// 返回 false、从不报错,半张表就被当成完整的交出去——数据静默变少,日志一片空白。
|
||||
func drainRows(rows *sql.Rows, what string) error {
|
||||
err := rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return fmt.Errorf("%s: %w", what, err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
// 把 WAL 合并回主库,避免后续读取时依赖 -shm 状态
|
||||
if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("合并 WAL 失败: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse 读取 Core Data 库,转换为领域模型。
|
||||
@@ -244,7 +309,9 @@ func Parse(db *sql.DB) (*Backup, error) {
|
||||
}
|
||||
b.TagGroups[pk] = name
|
||||
}
|
||||
rows.Close()
|
||||
if err = drainRows(rows, "读取标签分组"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 标签(含所属分组关系 Z_11TAGS_ 的逆向:ZTAG.ZGROUP -> ZTAGGROUP)
|
||||
tagGroup := map[int]int{}
|
||||
@@ -260,7 +327,9 @@ func Parse(db *sql.DB) (*Backup, error) {
|
||||
}
|
||||
tagGroup[pk] = g
|
||||
}
|
||||
grows.Close()
|
||||
if err = drainRows(grows, "读取标签分组关系"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,'') FROM ZTAG`)
|
||||
if err != nil {
|
||||
@@ -275,7 +344,9 @@ func Parse(db *sql.DB) (*Backup, error) {
|
||||
t.GroupName = b.TagGroups[tagGroup[t.SrcPK]]
|
||||
b.Tags = append(b.Tags, t)
|
||||
}
|
||||
trows.Close()
|
||||
if err = drainRows(trows, "读取标签"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 类型 / 活动
|
||||
arows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,''), COALESCE(ZICON_,''),
|
||||
@@ -293,7 +364,9 @@ func Parse(db *sql.DB) (*Backup, error) {
|
||||
a.IsHome, a.IsWork, a.IsExcluded, a.IsArchived = h == 1, w == 1, e == 1, ar == 1
|
||||
b.Activities = append(b.Activities, a)
|
||||
}
|
||||
arows.Close()
|
||||
if err = drainRows(arows, "读取活动类型"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 交通方式
|
||||
trrows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCOLOR_,''), COALESCE(ZICON_,'') FROM ZTRANSPORT`)
|
||||
@@ -308,7 +381,9 @@ func Parse(db *sql.DB) (*Backup, error) {
|
||||
}
|
||||
b.Transports = append(b.Transports, t)
|
||||
}
|
||||
trrows.Close()
|
||||
if err = drainRows(trrows, "读取交通方式"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 地点
|
||||
lrows, err := db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZCATEGORY_,''),
|
||||
@@ -327,9 +402,14 @@ func Parse(db *sql.DB) (*Backup, error) {
|
||||
lrows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if !validCoord(l.Lat, l.Lon) {
|
||||
l.Lat, l.Lon = 0, 0
|
||||
}
|
||||
b.Locations = append(b.Locations, l)
|
||||
}
|
||||
lrows.Close()
|
||||
if err = drainRows(lrows, "读取地点"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 访问记录
|
||||
vrows, err := db.Query(`SELECT Z_PK, ZLOCATION, ZACTIVITY_, ZARRIVALDATE_, ZDEPARTUREDATE_,
|
||||
@@ -363,7 +443,9 @@ func Parse(db *sql.DB) (*Backup, error) {
|
||||
visitIdx[v.SrcPK] = len(b.Visits)
|
||||
b.Visits = append(b.Visits, v)
|
||||
}
|
||||
vrows.Close()
|
||||
if err = drainRows(vrows, "读取访问记录"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 访问 → 标签
|
||||
vtrows, err := db.Query(`SELECT Z_18VISITS_, Z_11TAGS_5 FROM Z_11VISITS_`)
|
||||
@@ -380,7 +462,9 @@ func Parse(db *sql.DB) (*Backup, error) {
|
||||
b.Visits[i].TagSrcPKs = append(b.Visits[i].TagSrcPKs, tid)
|
||||
}
|
||||
}
|
||||
vtrows.Close()
|
||||
if err = drainRows(vtrows, "读取访问标签"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 位移
|
||||
mrows, err := db.Query(`SELECT Z_PK, ZTRANSPORT_, ZVISITFROM_, ZVISITTO_, ZSTART_, ZEND_ FROM ZMOVEMENT`)
|
||||
@@ -411,7 +495,9 @@ func Parse(db *sql.DB) (*Backup, error) {
|
||||
m.End = appleTime(en)
|
||||
b.Movements = append(b.Movements, m)
|
||||
}
|
||||
mrows.Close()
|
||||
if err = drainRows(mrows, "读取位移记录"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 小时级天气
|
||||
wrows, err := db.Query(`SELECT Z_PK, ZVISIT, ZDATE_, COALESCE(ZISDAYLIGHT,0),
|
||||
@@ -453,7 +539,9 @@ func Parse(db *sql.DB) (*Backup, error) {
|
||||
}
|
||||
b.Weather = append(b.Weather, w)
|
||||
}
|
||||
wrows.Close()
|
||||
if err = drainRows(wrows, "读取天气记录"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.QueryRow(`SELECT count(*) FROM ZRAWVISIT`).Scan(&b.RawVisitNum); err != nil {
|
||||
b.RawVisitNum = 0
|
||||
@@ -645,7 +733,9 @@ func captureMeta(db *sql.DB) (*CoreDataMeta, error) {
|
||||
m.Entities[name] = EntityInfo{Ent: ent, Max: max}
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err = drainRows(rows, "读取 Z_PRIMARYKEY"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
drows, err := db.Query(`SELECT name, sql FROM sqlite_master WHERE type='table' AND name LIKE 'Z%' AND sql IS NOT NULL`)
|
||||
if err != nil {
|
||||
@@ -659,7 +749,9 @@ func captureMeta(db *sql.DB) (*CoreDataMeta, error) {
|
||||
}
|
||||
m.DDL[name] = ddl
|
||||
}
|
||||
drows.Close()
|
||||
if err = drainRows(drows, "读取建表 DDL"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 访问↔标签关联表:表名形如 Z_<tagEnt>VISITS_,两列分别指向 Visit / Tag 实体。
|
||||
visitEnt, okV := m.Entities["Visit"]
|
||||
@@ -692,7 +784,11 @@ func captureMeta(db *sql.DB) (*CoreDataMeta, error) {
|
||||
tcol = cname
|
||||
}
|
||||
}
|
||||
cerr := crows.Err()
|
||||
crows.Close()
|
||||
if cerr != nil {
|
||||
continue
|
||||
}
|
||||
if vcol != "" && tcol != "" {
|
||||
m.JoinTable = JoinTableInfo{Name: tname, VisitCol: vcol, TagCol: tcol}
|
||||
break
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package rond
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// D3 回归:parse.go 里每个 rows 循环的收尾都必须查 rows.Err()。
|
||||
//
|
||||
// 只 Close 不查 Err 是「不报错也不算错」的那一类:驱动或连接中途出错时
|
||||
// Next() 只返回 false、从不报错,读到一半的表会被当成完整的交出去——
|
||||
// 表现成导入后到访/位移莫名变少,日志里一个字都没有。
|
||||
//
|
||||
// 两种合格写法:循环后 `if err = drainRows(x, "…")`,或自己 `x.Err()`。
|
||||
func TestParseDrainsEveryRowsLoop(t *testing.T) {
|
||||
_, self, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("定位测试文件失败")
|
||||
}
|
||||
src, err := os.ReadFile(filepath.Join(filepath.Dir(self), "parse.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("读 parse.go 失败: %v", err)
|
||||
}
|
||||
text := string(src)
|
||||
|
||||
lits := regexp.MustCompile(`for (\w+)\.Next\(\)`).FindAllStringSubmatch(text, -1)
|
||||
seen := map[string]bool{}
|
||||
for _, m := range lits {
|
||||
name := m[1]
|
||||
if seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
if !strings.Contains(text, name+".Err()") && !strings.Contains(text, "drainRows("+name+",") {
|
||||
t.Errorf("parse.go 里 %s 的循环收尾没查 %s.Err()(只 Close 会把读一半的表当完整的交出去)", name, name)
|
||||
}
|
||||
}
|
||||
if len(seen) < 10 {
|
||||
t.Fatalf("只认出 %d 个 rows 循环变量(期望 ≥10),判据可能被改写架空,请同步更新本用例", len(seen))
|
||||
}
|
||||
|
||||
// 收尾助手自己必须真的读 Err,否则上面那条判据能被一个空壳 drainRows 骗过去
|
||||
body := between(text, "func drainRows(", "\nfunc ")
|
||||
if body == "" {
|
||||
t.Fatal("找不到 drainRows 的实现")
|
||||
}
|
||||
if !strings.Contains(body, "Err()") || !strings.Contains(body, "Close()") {
|
||||
t.Errorf("drainRows 必须既查 Err() 又 Close(),实际:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func between(s, start, end string) string {
|
||||
i := strings.Index(s, start)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
rest := s[i+len(start):]
|
||||
if j := strings.Index(rest, end); j >= 0 {
|
||||
return rest[:j]
|
||||
}
|
||||
return rest
|
||||
}
|
||||
Reference in New Issue
Block a user