package syncer import ( "crypto/sha256" "encoding/hex" "fmt" "os" "path" "path/filepath" "sort" "strings" "time" ) // 分类常量:决定用哪套合并规则(凭证看 expiresAt,普通数据看 mtime)。 const ( catAuth = "auth" catData = "data" ) // FileState 是一个文件在某一端的可比较状态。 // // Content 只在需要时填充(本地端天然有;远端端按需下载)。比较优先用 // SHA256,避免为了判断"是否相同"而多跑一次下载。 type FileState struct { Path string `json:"path"` Size int64 `json:"size"` SHA256 string `json:"sha256"` Mtime time.Time `json:"mtime"` // Content 是文件原始字节,**绝不能被序列化出去**。 // // 它只用于本地合并与上传;而 Action 会被 `wb2api-sync -json` 整个打印出来, // 一旦让 Content 进 JSON,`status -json` 就会把 accessToken / refreshToken // 明文(base64)灌进终端、CI 日志或 issue 附件里——等于自己泄露凭证。 Content []byte `json:"-"` // 仅 auth 文件填充:这三项是"谁的凭证更新"的判据。 IsAuth bool `json:"is_auth,omitempty"` UID string `json:"uid,omitempty"` Nickname string `json:"nickname,omitempty"` ExpiresAt int64 `json:"expires_at,omitempty"` HasRefresh bool `json:"has_refresh,omitempty"` Realm string `json:"realm,omitempty"` } // hasContent 报告该状态是否已带内容。 func (f *FileState) hasContent() bool { return f.Content != nil } // scanLocal 扫描本地待同步文件,key 为桶内相对路径(如 auths/workbuddy-x.json)。 // // 只扫顶层 *.json: // - auths/ —— 与网关 auth.LoadDir 的口径一致(它也只解析一层); // - data/ —— 只取 IncludeData 白名单。 // // 子目录(如 data/request-logs/)一律不扫:请求归档是本机观测数据, // 互相搬运既无意义又会把桶撑大。 func (e *Engine) scanLocal() (map[string]*FileState, error) { out := map[string]*FileState{} if e.cfg.AuthDir != "" { files, err := readTopLevelJSON(e.cfg.AuthDir) if err != nil { return nil, err } for _, name := range files { fp := filepath.Join(e.cfg.AuthDir, name) st, err := statLocalFile(fp, "auths/"+name, catAuth) if err != nil { // 单个坏文件不阻断整体同步(与 auth.LoadDir 的容错口径一致)。 continue } out[st.Path] = st } } for _, name := range e.cfg.IncludeData { if e.cfg.DataDir == "" || name == "" || strings.ContainsAny(name, `/\`) { continue } fp := filepath.Join(e.cfg.DataDir, name) st, err := statLocalFile(fp, "data/"+name, catData) if err != nil { continue // 本地还没有这个文件是正常态(首跑) } out[st.Path] = st } return out, nil } // statLocalFile 读取并按 category 解析一个本地文件。 func statLocalFile(fp, relPath, category string) (*FileState, error) { raw, err := os.ReadFile(fp) if err != nil { return nil, err } info, err := os.Stat(fp) if err != nil { return nil, err } st := newFileState(relPath, raw, info.ModTime(), category) return st, nil } // newFileState 由内容 + mtime + 分类构造 FileState。 func newFileState(relPath string, raw []byte, mtime time.Time, category string) *FileState { sum := sha256.Sum256(raw) st := &FileState{ Path: relPath, Size: int64(len(raw)), SHA256: hex.EncodeToString(sum[:]), Mtime: mtime, Content: raw, } if category == catAuth { annotateAuth(st, raw) } return st } // annotateAuth 给 auth 文件标注比较维度(uid / expiresAt / refreshToken 有无)。 // // 用自带的容错解析而不是网关的 auth.Parse:启动器要做成独立 exe,不能和网关 // 内部包绑死(详见 cred.go 的说明)。解析不出来就退化成纯字节比较,不是错误。 func annotateAuth(st *FileState, raw []byte) { c, ok := parseCredential(raw) if !ok { return } st.IsAuth = true st.UID = c.UID st.Nickname = c.Nickname st.ExpiresAt = c.ExpiresAt st.HasRefresh = c.HasRefresh st.Realm = c.Realm } // readTopLevelJSON 返回 dir 下顶层的 *.json 文件名(升序,结果稳定)。 // dir 不存在返回空集而非错误——本地首次运行时 auths/ 还没建。 func readTopLevelJSON(dir string) ([]string, error) { entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } var out []string for _, ent := range entries { if ent.IsDir() || !strings.HasSuffix(strings.ToLower(ent.Name()), ".json") { continue } out = append(out, ent.Name()) } sort.Strings(out) return out, nil } // --------------------------------------------------------------------------- // 写入 // --------------------------------------------------------------------------- // writeLocalAtomic 原子写入本地文件(tmp + rename),与网关自身的落盘约定一致。 // // 权限 0600:auths/ 里是明文 refreshToken,不能给组/其他用户读。 func writeLocalAtomic(fp string, content []byte, mtime time.Time) error { dir := filepath.Dir(fp) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("创建目录 %s 失败: %w", dir, err) } tmp := fp + ".tmp" if err := os.WriteFile(tmp, content, 0o600); err != nil { return fmt.Errorf("写入 %s 失败: %w", tmp, err) } if err := os.Rename(tmp, fp); err != nil { _ = os.Remove(tmp) return fmt.Errorf("替换 %s 失败: %w", fp, err) } // 保留远端 mtime:下一轮比较靠它判断谁更新,丢了会导致对端反复重传。 if !mtime.IsZero() { _ = os.Chtimes(fp, mtime, mtime) } return nil } // localPath 把桶内相对路径映射为本地绝对路径。 func (e *Engine) localPath(relPath string) (string, error) { clean := path.Clean("/" + strings.ReplaceAll(relPath, `\`, "/")) switch { case strings.HasPrefix(clean, "/auths/"): name := path.Base(clean) if name == "" || name == "." || name == "/" { return "", fmt.Errorf("非法 auths 路径: %q", relPath) } return filepath.Join(e.cfg.AuthDir, name), nil case strings.HasPrefix(clean, "/data/"): name := path.Base(clean) if name == "" || name == "." || name == "/" { return "", fmt.Errorf("非法 data 路径: %q", relPath) } return filepath.Join(e.cfg.DataDir, name), nil default: return "", fmt.Errorf("同步路径不在白名单内: %q(只允许 auths/ 与 data/ 顶层 json)", relPath) } } // PathAllowed 报告一个桶内相对路径是否属于同步白名单。 // 面板接口与本地写入都过这一关,杜绝 ../ 穿越与任意路径写盘。 func PathAllowed(relPath string, includeData []string) bool { p := strings.ReplaceAll(relPath, `\`, "/") if strings.Contains(p, "..") { return false } clean := path.Clean("/" + p) if strings.HasPrefix(clean, "/auths/") { name := path.Base(clean) return name != "." && name != "/" && strings.HasSuffix(strings.ToLower(name), ".json") && path.Dir(clean) == "/auths" } if strings.HasPrefix(clean, "/data/") { if path.Dir(clean) != "/data" { return false } name := path.Base(clean) for _, allowed := range includeData { if name == allowed { return true } } return false } return false }