diff --git a/config.example.yaml b/config.example.yaml index aadea448..71b4ff4a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -589,3 +589,15 @@ project: fact_index_path_max_runes: 10000 fact_summary_max_runes: 24000 default_inject_deprecated: false + +# ============================================ +# 一键更新(可选) +# ============================================ +# 更新源:不配置时,更新跟随本目录自己跟踪的 git 远端;配置后按这里指定的来源更新。 +# remote 与 remote_url 二选一(页面保存时两者互斥;都手写时以 remote_url 为准)。 +# 非 git 目录(解压/打包安装)配置地址后,可在控制台「一键更新」页执行「接入更新源」, +# 把目录变成跟踪该仓库的 git 工作树,此后按正常安装一键更新。 +# update: +# remote: origin # 用已有远端名指定(mine/origin/upstream/任意名) +# remote_url: https://github.com/AIPentest/CyberStrikeAI.git # 或直接给仓库地址 +# branch: main # 要跟踪的分支;留空按 upstream/当前分支推断 diff --git a/internal/app/app.go b/internal/app/app.go index a0a8b4ea..3dffd1c9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -617,7 +617,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error updateHandler := handler.NewUpdateHandler(configDir, log.Logger, auditSvc, func() { app.Shutdown() os.Exit(0) - }) + }, configHandler.UpdateSource, configHandler.SetUpdateSource) // 设置路由(使用 App 实例以便动态获取 handler) setupRoutes( @@ -1134,12 +1134,14 @@ func setupRoutes( protected.POST("/config/test-vision", configHandler.TestVision) protected.POST("/config/list-models", configHandler.ListModels) - // 系统更新(保持本安装自身源码最新:查看、检查、应用、进度、回滚) + // 系统更新(保持本安装自身源码最新:查看、检查、应用、进度、回滚、接入、更新源) protected.GET("/system/update", updateHandler.GetStatus) protected.GET("/system/update/job", updateHandler.Job) protected.POST("/system/update/check", updateHandler.Check) protected.POST("/system/update/apply", updateHandler.Apply) protected.POST("/system/update/rollback", updateHandler.Rollback) + protected.POST("/system/update/adopt", updateHandler.Adopt) + protected.POST("/system/update/source", updateHandler.SaveSource) // 系统设置 - 终端(执行命令,提高运维效率) protected.POST("/terminal/run", terminalHandler.RunCommand) diff --git a/internal/config/config.go b/internal/config/config.go index a0d69632..807f36e4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -49,6 +49,20 @@ type Config struct { MultiAgent MultiAgentConfig `yaml:"multi_agent,omitempty" json:"multi_agent,omitempty"` Project ProjectConfig `yaml:"project,omitempty" json:"project,omitempty"` Vision VisionConfig `yaml:"vision,omitempty" json:"vision,omitempty"` + Update UpdateConfig `yaml:"update,omitempty" json:"update,omitempty"` +} + +// UpdateConfig 一键更新的更新源(可选):显式指定从哪个仓库/分支更新本安装。 +// 不配置时更新跟随安装目录自己跟踪的远端;两种来源都在本地解析,没有写死的仓库地址。 +type UpdateConfig struct { + // Remote 用安装树里已有的远端名指定更新源(mine/origin/upstream 或任意自定义名)。 + Remote string `yaml:"remote,omitempty" json:"remote,omitempty"` + // RemoteURL 直接给出仓库地址(https/http/ssh/git/file:// 或本机绝对路径),不必先 git remote add; + // 与 Remote 同时配置时以地址为准(页面保存时两者互斥)。 + RemoteURL string `yaml:"remote_url,omitempty" json:"remote_url,omitempty"` + // Branch 要跟踪的分支;留空时取当前分支的 upstream(没有 upstream 时取当前分支名; + // 非 git 目录接入时取仓库的默认分支)。 + Branch string `yaml:"branch,omitempty" json:"branch,omitempty"` } type EnsureLocalConfigResult struct { diff --git a/internal/handler/config.go b/internal/handler/config.go index e9019705..d57b8b82 100644 --- a/internal/handler/config.go +++ b/internal/handler/config.go @@ -1912,6 +1912,26 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) { }) } +// SetUpdateSource 记录一键更新的更新源(remote 与 remote_url 二选一,另有可选 branch)。 +// 走与其他设置相同的写入路径:备份旧文件、按 YAML 文档只改 update 段、原子落盘。 +func (h *ConfigHandler) SetUpdateSource(remote, remoteURL, branch string) error { + h.mu.Lock() + h.config.Update = config.UpdateConfig{ + Remote: strings.TrimSpace(remote), + RemoteURL: strings.TrimSpace(remoteURL), + Branch: strings.TrimSpace(branch), + } + h.mu.Unlock() + return h.saveConfig() +} + +// UpdateSource 读当前生效的更新源(加读锁;更新处理器每次请求现读,保存后立即生效)。 +func (h *ConfigHandler) UpdateSource() (string, string, string) { + h.mu.RLock() + defer h.mu.RUnlock() + return h.config.Update.Remote, h.config.Update.RemoteURL, h.config.Update.Branch +} + // saveConfig 保存配置到文件 func (h *ConfigHandler) saveConfig() error { configFileMu.Lock() @@ -1938,6 +1958,7 @@ func (h *ConfigHandler) saveConfig() error { updateAIConfig(root, h.config.AI) removeKeyFromMap(root.Content[0], "openai") updateVisionConfig(root, h.config.Vision) + updateUpdateSourceConfig(root, h.config.Update) updateFOFAConfig(root, h.config.FOFA) updateSpaceSearchConfig(root, "zoomeye", h.config.ZoomEye) updateSpaceSearchConfig(root, "quake", h.config.Quake) @@ -2065,6 +2086,35 @@ func updateMCPConfig(doc *yaml.Node, cfg config.MCPConfig) { setIntInMap(mcpNode, "port", cfg.Port) } +// updateUpdateSourceConfig 写入一键更新的更新源(update 段)。三项全空时把整段删掉, +// 而不是留下一个空的 update: 块——"没有配置"应当就是文件里没有这一段。 +func updateUpdateSourceConfig(doc *yaml.Node, cfg config.UpdateConfig) { + root := doc.Content[0] + remote := strings.TrimSpace(cfg.Remote) + remoteURL := strings.TrimSpace(cfg.RemoteURL) + branch := strings.TrimSpace(cfg.Branch) + if remote == "" && remoteURL == "" && branch == "" { + removeKeyFromMap(root, "update") + return + } + node := ensureMap(root, "update") + if remote != "" { + setStringInMap(node, "remote", remote) + } else { + removeKeyFromMap(node, "remote") + } + if remoteURL != "" { + setStringInMap(node, "remote_url", remoteURL) + } else { + removeKeyFromMap(node, "remote_url") + } + if branch != "" { + setStringInMap(node, "branch", branch) + } else { + removeKeyFromMap(node, "branch") + } +} + func updateVisionConfig(doc *yaml.Node, cfg config.VisionConfig) { root := doc.Content[0] visionNode := ensureMap(root, "vision") diff --git a/internal/handler/openapi.go b/internal/handler/openapi.go index ed13a735..3f9505ec 100644 --- a/internal/handler/openapi.go +++ b/internal/handler/openapi.go @@ -3418,6 +3418,61 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) { }, }, }, + "/api/system/update/source": map[string]interface{}{ + "post": map[string]interface{}{ + "tags": []string{"系统更新"}, + "summary": "配置更新源(本安装从哪个仓库更新)", + "description": "把更新源写进 config.yaml 的 update 段:remote(已有远端名)与 remote_url(仓库地址)二选一,branch 可选。不配置时更新跟随本目录自己跟踪的远端;地址白名单为 https/http/ssh/git/file:// 与本机绝对路径(git 的 ext:: 传输会执行命令,一律拒绝)。", + "operationId": "saveUpdateSource", + "requestBody": map[string]interface{}{ + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "remote": map[string]interface{}{"type": "string", "description": "安装树里已有的远端名(mine/origin/upstream/任意名)"}, + "remoteUrl": map[string]interface{}{"type": "string", "description": "仓库地址(与 remote 二选一)"}, + "branch": map[string]interface{}{"type": "string", "description": "要跟踪的分支;留空按 upstream/当前分支推断"}, + }, + }, + }, + }, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{"description": "已保存(返回生效的更新源)"}, + "400": map[string]interface{}{"description": "远端名/地址/分支不合法,或两者同时给出"}, + "401": map[string]interface{}{"description": "未授权"}, + }, + }, + }, + "/api/system/update/adopt": map[string]interface{}{ + "post": map[string]interface{}{ + "tags": []string{"系统更新"}, + "summary": "把非 git 目录接入更新源", + "description": "解压/打包安装所在的目录接入配置好的更新源:不带 confirm 做无副作用预览(在临时仓库里 fetch,列出会被目标版本替换的文件与会保留的运维者内容);confirm=true 时异步执行(202 + job_id,用 /api/system/update/job 轮询):git init、添加 origin、落地目标分支内容,运维者内容先暂存再放回,被替换的文件全部留底到 .update-backup/<时间戳>/overwritten/。已是 git 工作树时返回 409(用一键更新而不是接入)。", + "operationId": "adoptUpdateSource", + "requestBody": map[string]interface{}{ + "content": map[string]interface{}{ + "application/json": map[string]interface{}{ + "schema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "confirm": map[string]interface{}{"type": "boolean", "description": "false/缺省=只预览;true=确认执行"}, + "restart": map[string]interface{}{"type": "boolean", "description": "接入成功后退出进程交给外部守护拉起,默认 false"}, + }, + }, + }, + }, + }, + "responses": map[string]interface{}{ + "200": map[string]interface{}{"description": "预览结果(plan:将被替换/保留的文件清单与总数)"}, + "202": map[string]interface{}{"description": "接入任务已受理,返回 job_id"}, + "400": map[string]interface{}{"description": "没有配置更新源、地址不合法、分支不合法,或要求重启但没有重启钩子"}, + "409": map[string]interface{}{"description": "目录已是 git 工作树;或有任务在进行中"}, + "401": map[string]interface{}{"description": "未授权"}, + }, + }, + }, "/api/config": map[string]interface{}{ "get": map[string]interface{}{ "tags": []string{"配置管理"}, diff --git a/internal/handler/update.go b/internal/handler/update.go index 64a34682..336bf677 100644 --- a/internal/handler/update.go +++ b/internal/handler/update.go @@ -6,6 +6,7 @@ import ( "cyberstrike-ai/internal/update" "fmt" "net/http" + "strings" "sync" "time" @@ -29,6 +30,13 @@ type UpdateHandler struct { // request asked for a restart, because an unsupervised process that exits stays // exited - "I restarted you" would be a lie on most deployments. restart func() + // source reports the configured update source (config.yaml update section) as the + // operator last saved it; empty strings mean "not configured". A function rather + // than a value because the config can change while the process runs. + source func() (remote, remoteURL, branch string) + // saveSource persists a new source through the config layer; nil means this start + // has no config to write to. + saveSource func(remote, remoteURL, branch string) error mu sync.Mutex jobs map[string]*updateJob @@ -41,6 +49,7 @@ type UpdateHandler struct { // compiler finishes. type updateJob struct { ID string `json:"id"` + Kind string `json:"kind"` // apply | adopt State string `json:"state"` // running | succeeded | failed Started string `json:"started"` Finished string `json:"finished,omitempty"` @@ -61,17 +70,38 @@ func (j *updateJob) view() *updateJob { // NewUpdateHandler takes the audit service as a constructor argument rather than through // a SetAudit the assembly has to remember: forgetting here is a compile error instead of // a privileged endpoint that writes no audit records at all. -func NewUpdateHandler(root string, logger *zap.Logger, auditSvc *audit.Service, restart func()) *UpdateHandler { +func NewUpdateHandler(root string, logger *zap.Logger, auditSvc *audit.Service, restart func(), + source func() (remote, remoteURL, branch string), saveSource func(remote, remoteURL, branch string) error) *UpdateHandler { return &UpdateHandler{ - root: root, - logger: logger, - audit: auditSvc, - restart: restart, - jobs: map[string]*updateJob{}, + root: root, + logger: logger, + audit: auditSvc, + restart: restart, + source: source, + saveSource: saveSource, + jobs: map[string]*updateJob{}, } } -func (h *UpdateHandler) options() update.Options { return update.Options{Root: h.root} } +func (h *UpdateHandler) options() update.Options { + opts := update.Options{Root: h.root} + if h.source != nil { + opts.Remote, opts.RemoteURL, opts.Branch = h.source() + } + return opts +} + +// sourceView is the configured source as the page edits it (empty strings = unset). +func (h *UpdateHandler) sourceView() gin.H { + var remote, remoteURL, branch string + if h.source != nil { + remote, remoteURL, branch = h.source() + } + return gin.H{ + "remote": remote, "remoteUrl": remoteURL, "branch": branch, + "configured": remote != "" || remoteURL != "" || branch != "", + } +} // GetStatus answers GET /api/system/update: the install tree as it is on disk, without a // network round trip, so opening the page is never gated on GitHub being reachable. @@ -85,6 +115,7 @@ func (h *UpdateHandler) GetStatus(c *gin.Context) { "status": snap, "job": h.latest(), "canRestart": h.restart != nil, + "source": h.sourceView(), }) } @@ -103,6 +134,92 @@ func (h *UpdateHandler) Check(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": snap}) } +// SaveSource answers POST /api/system/update/source: the operator picks what this +// installation updates from - the official repository, their own fork, or a second +// development line - and the choice lands in the update section of config.yaml. A remote +// name and an address are two ways to say the same thing and are refused together rather +// than silently ordered. +func (h *UpdateHandler) SaveSource(c *gin.Context) { + var body struct { + Remote string `json:"remote"` + RemoteURL string `json:"remoteUrl"` + Branch string `json:"branch"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求体不是合法 JSON"}) + return + } + body.Remote = strings.TrimSpace(body.Remote) + body.RemoteURL = strings.TrimSpace(body.RemoteURL) + body.Branch = strings.TrimSpace(body.Branch) + if body.Remote != "" && body.RemoteURL != "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "远端名与远端地址二选一:要么指名已有远端,要么直接给地址"}) + return + } + if body.Remote != "" && !update.ValidName(body.Remote) { + c.JSON(http.StatusBadRequest, gin.H{"error": "远端名不合法:" + body.Remote}) + return + } + if body.RemoteURL != "" && !update.ValidRemoteURL(body.RemoteURL) { + c.JSON(http.StatusBadRequest, gin.H{"error": "地址不合法(只允许 https/http/ssh/git/file:// 或本机绝对路径)"}) + return + } + if body.Branch != "" && !update.ValidName(body.Branch) { + c.JSON(http.StatusBadRequest, gin.H{"error": "分支名不合法:" + body.Branch}) + return + } + if h.saveSource == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "本次启动没有接入配置保存,更新源改不了"}) + return + } + if err := h.saveSource(body.Remote, body.RemoteURL, body.Branch); err != nil { + h.record(c, "update.source", "failure", "保存更新源失败: "+err.Error(), map[string]interface{}{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败:" + err.Error()}) + return + } + h.record(c, "update.source", "success", "更新源已保存", map[string]interface{}{ + "remote": body.Remote, "remote_url": body.RemoteURL, "branch": body.Branch, + }) + c.JSON(http.StatusOK, gin.H{"source": h.sourceView()}) +} + +// Adopt answers POST /api/system/update/adopt. Without confirm it is a side-effect-free +// preview (the fetch happens in a throwaway repository outside the install tree); with +// confirm it starts the same kind of job an update does. +func (h *UpdateHandler) Adopt(c *gin.Context) { + var body struct { + Confirm bool `json:"confirm"` + Restart bool `json:"restart"` + } + if c.Request.ContentLength > 0 { + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请求体不是合法 JSON"}) + return + } + } + if body.Confirm && body.Restart && h.restart == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "本次启动没有提供重启钩子,接入完成后请由外部进程管理器重启服务"}) + return + } + if !body.Confirm { + plan, err := update.Preview(c.Request.Context(), h.options()) + if err != nil { + h.record(c, "update.adopt", "failure", "接入预览失败: "+err.Error(), map[string]interface{}{"error": errMessage(err)}) + c.JSON(statusForError(err), errorBody(err)) + return + } + c.JSON(http.StatusOK, gin.H{"plan": plan}) + return + } + job, started := h.startJob("adopt", body.Restart) + if !started { + c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job}) + return + } + h.record(c, "update.adopt", "started", "开始把目录接入更新源", map[string]interface{}{"job": job.ID, "restart": body.Restart}) + c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID, "state": job.State}) +} + // Apply answers POST /api/system/update/apply. It starts one job and returns immediately; // a second concurrent apply is refused, because two processes moving the same working // tree and swapping the same binary is how an installation becomes unrecoverable. @@ -121,7 +238,7 @@ func (h *UpdateHandler) Apply(c *gin.Context) { return } - job, started := h.startJob(body.Restart) + job, started := h.startJob("apply", body.Restart) if !started { c.JSON(http.StatusConflict, gin.H{"error": "已有一次更新在进行中", "job": job}) return @@ -157,13 +274,14 @@ func (h *UpdateHandler) Rollback(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"result": res}) } -func (h *UpdateHandler) startJob(restart bool) (*updateJob, bool) { +func (h *UpdateHandler) startJob(kind string, restart bool) (*updateJob, bool) { h.mu.Lock() if h.active != "" { return h.jobs[h.active].view(), false } job := &updateJob{ ID: fmt.Sprintf("upd-%d", time.Now().UnixNano()), + Kind: kind, State: "running", Started: time.Now().Format(time.RFC3339), Restart: restart, @@ -197,7 +315,11 @@ func (h *UpdateHandler) startJob(restart bool) (*updateJob, bool) { h.logger.Info("更新进度", zap.String("phase", s.Phase), zap.String("message", s.Message)) } } - res, err := update.Apply(ctx, h.options(), onStep) + var run func(context.Context, update.Options, func(update.Step)) (*update.Result, error) = update.Apply + if kind == "adopt" { + run = update.Adopt + } + res, err := run(ctx, h.options(), onStep) finish := "succeeded" if err != nil { finish = "failed" @@ -308,7 +430,7 @@ func statusForError(err error) int { return http.StatusInternalServerError } switch ue.Reason { - case "local_source_edits", "diverged", "no_state", "moved_since_update", "no_binary": + case "local_source_edits", "diverged", "no_state", "moved_since_update", "no_binary", "already_a_repo": return http.StatusConflict default: return http.StatusBadRequest diff --git a/internal/handler/update_http_test.go b/internal/handler/update_http_test.go index ac8036af..3cb7c4f5 100644 --- a/internal/handler/update_http_test.go +++ b/internal/handler/update_http_test.go @@ -16,14 +16,21 @@ import ( // the length of a compile, and refusals that stay distinguishable from accidents. func newUpdateRouter(root string, restart func()) (*gin.Engine, *UpdateHandler) { + return newUpdateRouterWithSource(root, restart, nil, nil) +} + +func newUpdateRouterWithSource(root string, restart func(), + source func() (string, string, string), save func(string, string, string) error) (*gin.Engine, *UpdateHandler) { gin.SetMode(gin.TestMode) - h := NewUpdateHandler(root, nil, nil, restart) + h := NewUpdateHandler(root, nil, nil, restart, source, save) router := gin.New() router.GET("/api/system/update", h.GetStatus) router.GET("/api/system/update/job", h.Job) router.POST("/api/system/update/check", h.Check) router.POST("/api/system/update/apply", h.Apply) router.POST("/api/system/update/rollback", h.Rollback) + router.POST("/api/system/update/adopt", h.Adopt) + router.POST("/api/system/update/source", h.SaveSource) return router, h } @@ -166,3 +173,74 @@ func TestUpdateRollbackWithoutARecordedUpdateIsAConflict(t *testing.T) { t.Errorf("reason = %v, want no_state so the page can say there is nothing to undo", body["reason"]) } } + +func TestUpdateSourceSaveValidatesBeforeWriting(t *testing.T) { + var saved []string + router, _ := newUpdateRouterWithSource(t.TempDir(), nil, + func() (string, string, string) { return "", "", "" }, + func(r, u, b string) error { saved = append(saved, r+"|"+u+"|"+b); return nil }) + + // A name and an address are two ways to say the same thing; guessing which one the + // operator meant is worse than asking again. + w := doUpdate(router, http.MethodPost, "/api/system/update/source", `{"remote":"origin","remoteUrl":"https://github.com/x/y.git"}`) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d body = %s, want 400 for remote+remoteUrl together", w.Code, w.Body) + } + // git's ext:: transport runs commands; it must never reach the config. + w = doUpdate(router, http.MethodPost, "/api/system/update/source", `{"remoteUrl":"ext::sh -c true"}`) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d body = %s, want 400 for an ext:: address", w.Code, w.Body) + } + if len(saved) != 0 { + t.Fatalf("a refused save must not reach the config layer: %v", saved) + } + + // A valid save is trimmed and lands in the config layer as three values. + w = doUpdate(router, http.MethodPost, "/api/system/update/source", `{"remoteUrl":" https://github.com/Sycun/CyberStrikeAI.git ","branch":"main"}`) + if w.Code != http.StatusOK { + t.Fatalf("status = %d body = %s, want 200", w.Code, w.Body) + } + if len(saved) != 1 || saved[0] != "|https://github.com/Sycun/CyberStrikeAI.git|main" { + t.Fatalf("saved = %v, want the trimmed address and branch", saved) + } +} + +func TestUpdateStatusCarriesTheConfiguredSource(t *testing.T) { + router, _ := newUpdateRouterWithSource(t.TempDir(), nil, + func() (string, string, string) { return "", "https://github.com/Sycun/CyberStrikeAI.git", "main" }, nil) + + w := doUpdate(router, http.MethodGet, "/api/system/update", "") + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + var body struct { + Source struct { + Remote string `json:"remote"` + RemoteURL string `json:"remoteUrl"` + Branch string `json:"branch"` + Configured bool `json:"configured"` + } `json:"source"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if !body.Source.Configured || body.Source.RemoteURL != "https://github.com/Sycun/CyberStrikeAI.git" || body.Source.Branch != "main" { + t.Fatalf("source = %+v, want the configured address and branch", body.Source) + } +} + +func TestUpdateAdoptPreviewRefusesWithoutASource(t *testing.T) { + router, _ := newUpdateRouter(t.TempDir(), nil) + + w := doUpdate(router, http.MethodPost, "/api/system/update/adopt", `{}`) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d body = %s, want 400", w.Code, w.Body) + } + var body map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["reason"] != "no_source" { + t.Fatalf("reason = %v, want no_source so the page can say what is missing", body["reason"]) + } +} diff --git a/internal/update/adopt.go b/internal/update/adopt.go new file mode 100644 index 00000000..352f012e --- /dev/null +++ b/internal/update/adopt.go @@ -0,0 +1,359 @@ +package update + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" +) + +// Adopt connects a plain directory - a tarball or zip installation, or a copy carried in by +// hand - to the configured update source, so it becomes a git work tree the one-click +// update applies to from then on. +// +// The rules are the ones an update already keeps: operator content (roles/skills/tools/ +// agents/knowledge_base/data/config.yaml) is put aside and put back, and nothing else is +// replaced without having been named first - the preview lists every local file whose +// content differs from the target, and each replaced file is kept under +// /overwritten/ so "the update ate my edit" always has an address. + +// maxAdoptList caps the file lists a plan carries over the wire; totals are separate so a +// truncated list can never be read as the whole truth. +const maxAdoptList = 50 + +// AdoptPlan is the preview of connecting a directory to a source. Producing it must not +// change the directory. +type AdoptPlan struct { + Root string `json:"root"` + Source string `json:"source"` + Branch string `json:"branch"` + Commit string `json:"commit"` + Subject string `json:"subject"` + + Incoming int `json:"incoming"` // files the target branch tracks + + OverwrittenTotal int `json:"overwrittenTotal"` + Overwritten []string `json:"overwritten"` // differing local files the target's version will replace (capped) + + ProtectedTotal int `json:"protectedTotal"` + Protected []string `json:"protected"` // operator content put aside and put back (capped) +} + +// adoptionScan is the full (uncapped) comparison an adoption is based on. +type adoptionScan struct { + target map[string]string // tracked path -> blob sha + paths []string // every tracked path, sorted + changed []string // local file differs from the target and is not protected + kept []string // local file differs from the target and is protected +} + +// Preview builds an adoption plan without touching the directory: the fetch happens in a +// throwaway bare repository outside it, so a failure cannot leave a half-adopted tree. +func Preview(ctx context.Context, opts Options) (*AdoptPlan, error) { + root, source, branch, err := adoptPreflight(ctx, opts) + if err != nil { + return nil, err + } + + tmp, err := os.MkdirTemp("", "csai-adopt-preview-") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmp) + if _, err := gitCmd(ctx, tmp, "init", "--quiet", "--bare"); err != nil { + return nil, err + } + branch, commit, subject, err := fetchSource(ctx, tmp, source, branch) + if err != nil { + return nil, err + } + scan, err := scanAdoption(ctx, root, tmp, "origin/"+branch) + if err != nil { + return nil, err + } + return scan.plan(root, source, branch, commit, subject), nil +} + +// Adopt carries the directory onto the source. It runs its own fetch inside the directory +// (the preview's throwaway fetch cannot be reused), and on any failure removes only the +// .git it created itself, so a retry starts from the same pristine state. +func Adopt(ctx context.Context, opts Options, onStep func(Step)) (*Result, error) { + if onStep == nil { + onStep = func(Step) {} + } + step := func(phase, msg string) { onStep(Step{Phase: phase, Message: msg, At: time.Now().Format(time.RFC3339)}) } + start := time.Now() + + root, source, branch, err := adoptPreflight(ctx, opts) + if err != nil { + return nil, err + } + step("preflight", fmt.Sprintf("把目录 %s 接入更新源 %s(分支 %s)", root, source, branch)) + + if _, err := gitCmd(ctx, root, "init", "--quiet"); err != nil { + return nil, &Error{Reason: "init_failed", Message: "git init 失败:" + err.Error()} + } + adopted := false + defer func() { + if !adopted { + // Only the repository git just created; the directory's own files are untouched. + _ = os.RemoveAll(filepath.Join(root, ".git")) + } + }() + if _, err := gitCmd(ctx, root, "remote", "add", "origin", source); err != nil { + return nil, &Error{Reason: "init_failed", Message: "添加远端失败:" + err.Error()} + } + + branch, commit, subject, err := fetchSource(ctx, root, source, branch) + if err != nil { + return nil, err + } + ref := "origin/" + branch + scan, err := scanAdoption(ctx, root, root, ref) + if err != nil { + return nil, err + } + step("fetch", fmt.Sprintf("%s/%s(%s %s)共 %d 个文件:%d 个本机版本会被目标版本替换,%d 个运维者文件先暂存再放回", + source, branch, commit, subject, len(scan.paths), len(scan.changed), len(scan.kept))) + + backupDir := filepath.Join(root, ".update-backup", time.Now().Format("20060102_150405")) + backupAll := append(append([]string{}, scan.changed...), scan.kept...) + for _, p := range backupAll { + abs := filepath.Join(root, filepath.FromSlash(p)) + info, statErr := os.Stat(abs) + if statErr != nil || !info.Mode().IsRegular() { + continue + } + dst := filepath.Join(backupDir, "overwritten", filepath.FromSlash(p)) + if Protected(p) { + dst = filepath.Join(backupDir, filepath.FromSlash(p)) + } + if err := copyFile(abs, dst, info.Mode()); err != nil { + return nil, &Error{Reason: "backup_failed", Message: fmt.Sprintf("备份 %s 失败:%v", p, err)} + } + } + if len(backupAll) > 0 { + step("protect", fmt.Sprintf("%d 个文件已留底(被替换的在 %s/overwritten/,运维者的会原样放回)", len(backupAll), backupDir)) + } + + if _, err := gitCmd(ctx, root, "checkout", "--force", "-B", branch, ref); err != nil { + return nil, &Error{Reason: "checkout_failed", Message: "落地目标内容失败:" + err.Error()} + } + if err := restoreProtected(backupDir, scan.kept); err != nil { + return nil, err + } + if _, err := gitCmd(ctx, root, "branch", "--set-upstream-to="+ref); err != nil { + return nil, &Error{Reason: "upstream_failed", Message: "设置分支跟踪失败:" + err.Error()} + } + // From here the directory IS a work tree tracking the source: a later build failure + // must leave it that way (the source has landed; only the binary has not), so the + // cleanup above must not remove the repository any more. + adopted = true + + res := &Result{ + ToCommit: commit, + FilesTouched: len(scan.paths), + KeptContent: scan.kept, + Overwritten: scan.changed, + BackupDir: backupDir, + Adopted: true, + NeedsRestart: true, + } + + if opts.binaryName() == "none" { + step("done", fmt.Sprintf("目录已接入 %s/%s(%s);按请求跳过重新编译", source, branch, commit)) + res.Duration = time.Since(start).Round(time.Millisecond).String() + return res, nil + } + + bin := filepath.Join(root, opts.binaryName()) + res.BinaryPath = bin + if _, canBuild := toolchain(ctx, root); !canBuild { + step("done", fmt.Sprintf("目录已接入 %s/%s(%s);本机没有 Go 工具链,二进制未重编", source, branch, commit)) + res.Duration = time.Since(start).Round(time.Millisecond).String() + return res, &Error{ + Reason: "no_toolchain", + Message: "目录已接入更新源,但本机没有 Go 工具链,无法重新编译。装好 go 后再点一次更新即可补上二进制。", + } + } + + step("build", "开始编译二进制(首次会下载依赖,可能需要几分钟)") + staging := filepath.Join(root, ".update-staging") + if err := os.MkdirAll(staging, 0o755); err != nil { + return res, &Error{Reason: "staging_failed", Message: err.Error()} + } + defer os.RemoveAll(staging) + newBin := filepath.Join(staging, opts.binaryName()) + if err := build(ctx, root, newBin); err != nil { + return res, &Error{Reason: "build_failed", Message: "编译失败,目录已接入但二进制保持原版本:\n" + err.Error()} + } + prev, err := installBinary(newBin, bin) + if err != nil { + return res, &Error{Reason: "swap_failed", Message: err.Error()} + } + res.BinaryBuilt = true + res.PrevBinary = prev + step("done", fmt.Sprintf("目录已接入 %s/%s(%s)并换好二进制", source, branch, commit)) + res.Duration = time.Since(start).Round(time.Millisecond).String() + return res, nil +} + +// adoptPreflight validates the operation's preconditions: a plain directory, a usable +// source address, and (when given) a sane branch name. It does not touch the directory. +func adoptPreflight(ctx context.Context, opts Options) (root, source, branch string, err error) { + root, err = opts.installRoot() + if err != nil { + return "", "", "", err + } + if _, err := exec.LookPath("git"); err != nil { + return "", "", "", &Error{Reason: "no_git", Message: "本机没有 git,无法把目录接入更新源"} + } + if _, err := gitCmd(ctx, root, "rev-parse", "--is-inside-work-tree"); err == nil { + return "", "", "", &Error{Reason: "already_a_repo", Message: "这个目录已经是 git 工作树:用「一键更新」,不需要接入"} + } + if fi, statErr := os.Stat(root); statErr != nil || !fi.IsDir() { + return "", "", "", &Error{Reason: "bad_root", Message: "安装目录不存在:" + root} + } + source = strings.TrimSpace(opts.RemoteURL) + if source == "" { + return "", "", "", &Error{Reason: "no_source", Message: "还没有配置更新源地址:先填入仓库地址(配置文件的 update.remote_url)再接入"} + } + if !ValidRemoteURL(source) { + return "", "", "", &Error{Reason: "bad_source", Message: "更新源地址不合法(只允许 https/http/ssh/git/file:// 或本机绝对路径)"} + } + branch = strings.TrimSpace(opts.Branch) + if branch != "" && !ValidName(branch) { + return "", "", "", &Error{Reason: "bad_branch", Message: fmt.Sprintf("分支名不合法:%q", branch)} + } + return root, source, branch, nil +} + +// fetchSource fetches one branch from source into repo and reports which branch that +// ended up being (an empty request resolves the repository's default branch), its short +// commit and its subject. Both callers fetch into a repository of their own. +func fetchSource(ctx context.Context, repo, source, branch string) (string, string, string, error) { + if branch == "" { + out, err := gitCmd(ctx, repo, "ls-remote", "--symref", source, "HEAD") + if err != nil { + return "", "", "", &Error{Reason: "fetch_failed", Message: "读取远端默认分支失败:" + err.Error()} + } + branch = parseDefaultBranch(out) + if branch == "" { + return "", "", "", &Error{Reason: "no_branch", Message: "无法确定远端默认分支,请在配置里写明分支(update.branch)"} + } + } + if _, err := gitCmd(ctx, repo, "fetch", "--quiet", "--no-tags", "--recurse-submodules=no", + source, "+refs/heads/"+branch+":refs/remotes/origin/"+branch); err != nil { + return "", "", "", &Error{Reason: "fetch_failed", Message: "拉取失败:" + err.Error()} + } + ref := "origin/" + branch + commit, err := gitCmd(ctx, repo, "rev-parse", "--short", ref) + if err != nil { + return "", "", "", &Error{Reason: "no_branch", Message: fmt.Sprintf("远端没有分支 %s", branch)} + } + subject, _ := gitCmd(ctx, repo, "log", "-1", "--format=%s", ref) + return branch, commit, subject, nil +} + +// parseDefaultBranch reads "ref: refs/heads/\tHEAD" out of ls-remote --symref output. +func parseDefaultBranch(out string) string { + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "ref: refs/heads/") { + continue + } + rest := strings.TrimPrefix(line, "ref: refs/heads/") + if i := strings.IndexAny(rest, "\t "); i >= 0 { + rest = rest[:i] + } + if ValidName(rest) { + return rest + } + } + return "" +} + +// scanAdoption compares the directory against the tracked files of ref in repo. +func scanAdoption(ctx context.Context, root, repo, ref string) (*adoptionScan, error) { + out, err := gitRaw(ctx, repo, "ls-tree", "-r", "-z", "--full-tree", ref) + if err != nil { + return nil, &Error{Reason: "diff_failed", Message: err.Error()} + } + scan := &adoptionScan{target: map[string]string{}} + for _, rec := range nulList(out) { + tab := strings.IndexByte(rec, '\t') + if tab < 0 { + continue + } + fields := strings.Fields(rec[:tab]) + if len(fields) < 3 || fields[1] != "blob" { + continue // submodules and trees are not files this comparison replaces + } + p := rec[tab+1:] + scan.target[p] = fields[2] + scan.paths = append(scan.paths, p) + } + sort.Strings(scan.paths) + for _, p := range scan.paths { + abs := filepath.Join(root, filepath.FromSlash(p)) + info, err := os.Lstat(abs) + if err != nil || !info.Mode().IsRegular() { + continue + } + hash, err := gitBlobHash(abs) + if err != nil || hash == scan.target[p] { + continue + } + if Protected(p) { + scan.kept = append(scan.kept, p) + } else { + scan.changed = append(scan.changed, p) + } + } + return scan, nil +} + +// plan renders the capped, wire-facing view of a scan. +func (s *adoptionScan) plan(root, source, branch, commit, subject string) *AdoptPlan { + p := &AdoptPlan{ + Root: root, Source: source, Branch: branch, Commit: commit, Subject: subject, + Incoming: len(s.paths), + OverwrittenTotal: len(s.changed), + ProtectedTotal: len(s.kept), + Overwritten: append([]string{}, s.changed...), + Protected: append([]string{}, s.kept...), + } + if len(p.Overwritten) > maxAdoptList { + p.Overwritten = p.Overwritten[:maxAdoptList] + } + if len(p.Protected) > maxAdoptList { + p.Protected = p.Protected[:maxAdoptList] + } + if p.Overwritten == nil { + p.Overwritten = []string{} + } + if p.Protected == nil { + p.Protected = []string{} + } + return p +} + +// gitBlobHash computes the object id git would give a file's content. It hashes the bytes +// as they are (no filters), because the comparison is against raw blobs in the tree. sha1 is +// not a security choice here - it is the blob-id algorithm of the repository format. +func gitBlobHash(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + h := sha1.New() + fmt.Fprintf(h, "blob %d\x00", len(data)) + h.Write(data) + return hex.EncodeToString(h.Sum(nil)), nil +} diff --git a/internal/update/adopt_test.go b/internal/update/adopt_test.go new file mode 100644 index 00000000..42909581 --- /dev/null +++ b/internal/update/adopt_test.go @@ -0,0 +1,162 @@ +package update + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// newAdoptFixture builds the shape a tarball installation has: the upstream project's files +// on disk with the repository removed, plus operator content and one locally edited +// product file that the adoption is expected to name (and keep a copy of). +func newAdoptFixture(t *testing.T) (*tree, string) { + t.Helper() + tr := newTree(t) + dir := filepath.Join(filepath.Dir(tr.upstream), "release") + mustGit(t, filepath.Dir(tr.upstream), "clone", "-q", tr.upstream, dir) + if err := os.RemoveAll(filepath.Join(dir, ".git")); err != nil { + t.Fatal(err) + } + // Operator content: the live config (never in the repository), their own role, and an + // edit to a shipped role. + writeFile(t, filepath.Join(dir, "config.yaml"), "server:\n port: 8088\n") + writeFile(t, filepath.Join(dir, "roles", "mine.yaml"), "name: mine\n") + writeFile(t, filepath.Join(dir, "roles", "shipped.yaml"), "name: shipped\ndescription: mine, do not touch\n") + // A locally edited product file. + writeFile(t, filepath.Join(dir, "run.sh"), "#!/bin/sh\necho hacked\n") + return tr, dir +} + +func TestPreviewNamesWhatAdoptionWouldReplaceWithoutTouchingTheDirectory(t *testing.T) { + requireGit(t) + tr, dir := newAdoptFixture(t) + + plan, err := Preview(context.Background(), Options{Root: dir, RemoteURL: tr.upstream, Branch: "main"}) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, ".git")); !os.IsNotExist(err) { + t.Fatal("a preview must not create a repository") + } + if plan.Incoming < 3 { + t.Fatalf("incoming = %d, want at least the fixture's files", plan.Incoming) + } + if !contains(plan.Overwritten, "run.sh") { + t.Errorf("the edited product file must be named: %v", plan.Overwritten) + } + if contains(plan.Overwritten, "roles/shipped.yaml") { + t.Errorf("operator content must not be listed as replaced: %v", plan.Overwritten) + } + if !contains(plan.Protected, "roles/shipped.yaml") { + t.Errorf("the edited shipped role must be listed as protected: %v", plan.Protected) + } + if got := readFile(t, filepath.Join(dir, "run.sh")); !strings.Contains(got, "hacked") { + t.Errorf("preview must not change file contents: %q", got) + } +} + +func TestAdoptConnectsTheDirectoryAndKeepsOperatorContent(t *testing.T) { + requireGit(t) + tr, dir := newAdoptFixture(t) + + res, err := Adopt(context.Background(), Options{Root: dir, RemoteURL: tr.upstream, Branch: "main", BinaryName: "none"}, nil) + if err != nil { + t.Fatalf("adopt failed: %v\nresult: %+v", err, res) + } + if !res.Adopted || res.ToCommit == "" { + t.Fatalf("res = %+v, want an adopted result", res) + } + // The directory is now a work tree tracking the chosen source. + if got := mustGit(t, dir, "rev-parse", "--abbrev-ref", "HEAD"); got != "main" { + t.Errorf("HEAD branch = %q, want main", got) + } + if got := mustGit(t, dir, "rev-parse", "--abbrev-ref", "@{upstream}"); got != "origin/main" { + t.Errorf("upstream = %q, want origin/main - from here the tree tracks its source by itself", got) + } + // The target's content replaced the local edit; the local edit survives in the backup. + if got := readFile(t, filepath.Join(dir, "run.sh")); !strings.Contains(got, "echo old") { + t.Errorf("run.sh = %q, want the target's version", got) + } + if !contains(res.Overwritten, "run.sh") { + t.Errorf("result must name the replaced file: %v", res.Overwritten) + } + if got := readFile(t, filepath.Join(res.BackupDir, "overwritten", "run.sh")); !strings.Contains(got, "hacked") { + t.Errorf("the replaced file must be kept for recovery, got %q", got) + } + // Operator content survived: the live config untouched, their own role untouched, their + // edit to a shipped role restored over the target's version. + if got := readFile(t, filepath.Join(dir, "config.yaml")); !strings.Contains(got, "8088") { + t.Errorf("config.yaml was disturbed: %q", got) + } + if got := readFile(t, filepath.Join(dir, "roles", "mine.yaml")); !strings.Contains(got, "name: mine") { + t.Errorf("the operator's own role must survive: %q", got) + } + if got := readFile(t, filepath.Join(dir, "roles", "shipped.yaml")); !strings.Contains(got, "do not touch") { + t.Errorf("the operator's edit to a shipped role must win: %q", got) + } + if !contains(res.KeptContent, "roles/shipped.yaml") { + t.Errorf("keptContent = %v, want roles/shipped.yaml", res.KeptContent) + } + + // From now on it is a normal installation: the next upstream commit updates it, and the + // first real update writes the first rollback point. + tr.upstreamCommit(t, "second: bump service", map[string]string{ + "internal_service.go": "package service\n\nconst Version = \"2\"\n", + }) + snap, err := Check(context.Background(), Options{Root: dir, BinaryName: "none"}) + if err != nil { + t.Fatal(err) + } + if snap.CheckError != "" || snap.Behind != 1 { + t.Fatalf("the adopted tree must update like any other: checkError=%q behind=%d", snap.CheckError, snap.Behind) + } + res2, err := Apply(context.Background(), Options{Root: dir, BinaryName: "none"}, nil) + if err != nil { + t.Fatalf("apply after adopt failed: %v", err) + } + if res2.ToCommit == "" || res2.Adopted { + t.Fatalf("res2 = %+v", res2) + } + if _, ok := readState(dir); !ok { + t.Error("the first real update must write a rollback point") + } + if got := readFile(t, filepath.Join(dir, "internal_service.go")); !strings.Contains(got, "Version = \"2\"") { + t.Errorf("code after the first update = %q", got) + } +} + +func TestAdoptRefusesWhatItCannotHonestlyDo(t *testing.T) { + requireGit(t) + tr := newTree(t) + // Already a work tree: that is an update, not an adoption. + if _, err := Adopt(context.Background(), Options{Root: tr.install, RemoteURL: tr.upstream, BinaryName: "none"}, nil); asReason(err) != "already_a_repo" { + t.Errorf("err = %v, want already_a_repo", err) + } + // No source configured. + plain := t.TempDir() + if _, err := Adopt(context.Background(), Options{Root: plain, BinaryName: "none"}, nil); asReason(err) != "no_source" { + t.Errorf("err = %v, want no_source", err) + } + // An address git must never be handed. + if _, err := Adopt(context.Background(), Options{Root: plain, RemoteURL: "ext::sh -c true", BinaryName: "none"}, nil); asReason(err) != "bad_source" { + t.Errorf("err = %v, want bad_source", err) + } +} + +func contains(list []string, want string) bool { + for _, v := range list { + if v == want { + return true + } + } + return false +} + +func asReason(err error) string { + if ue, ok := err.(*Error); ok { + return ue.Reason + } + return "" +} diff --git a/internal/update/apply.go b/internal/update/apply.go index ad794659..c00db2ac 100644 --- a/internal/update/apply.go +++ b/internal/update/apply.go @@ -70,13 +70,20 @@ type Result struct { // Upstream may well have changed them; saying which ones is the difference between // "my roles disappeared" and "my roles are still mine, and these upstream files did // not land". - KeptContent []string `json:"keptContent"` - BinaryPath string `json:"binaryPath"` - BinaryBuilt bool `json:"binaryBuilt"` - PrevBinary string `json:"prevBinary"` - BackupDir string `json:"backupDir"` - NeedsRestart bool `json:"needsRestart"` - Duration string `json:"duration"` + KeptContent []string `json:"keptContent"` + // Overwritten names local files whose content the target repository's version replaced + // during an adoption; copies are kept under /overwritten/ so nothing is lost + // without a way back. + Overwritten []string `json:"overwritten,omitempty"` + // Adopted marks a result that comes from connecting a plain directory to a source + // rather than from moving an existing work tree. + Adopted bool `json:"adopted,omitempty"` + BinaryPath string `json:"binaryPath"` + BinaryBuilt bool `json:"binaryBuilt"` + PrevBinary string `json:"prevBinary"` + BackupDir string `json:"backupDir"` + NeedsRestart bool `json:"needsRestart"` + Duration string `json:"duration"` } // Error is a refusal with the information needed to act on it. Callers render the @@ -107,7 +114,7 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error // A refusal is not the end of the timeline: the page renders these lines, so the first // one says what was looked at even when the answer is "nothing to do". - step("preflight", fmt.Sprintf("检查安装目录 %s(分支 %s / 远端 %s / 提交 %s)", snap.Root, snap.Branch, snap.Remote, snap.Commit)) + step("preflight", fmt.Sprintf("检查安装目录 %s(分支 %s / 远端 %s / 提交 %s)", snap.Root, snap.Branch, snap.sourceLabel(), snap.Commit)) if !snap.Installed { return nil, &Error{Reason: "not_a_repo", Message: "这个目录不是 git 工作树,无法自动更新"} @@ -140,14 +147,14 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error }, nil } - step("fetch", fmt.Sprintf("%s/%s 有 %d 个新提交:%s → %s", snap.Remote, snap.Branch, snap.Behind, snap.Commit, snap.RemoteCommit)) + step("fetch", fmt.Sprintf("%s/%s 有 %d 个新提交:%s → %s", snap.sourceLabel(), snap.Branch, snap.Behind, snap.Commit, snap.RemoteCommit)) // Put operator-owned content aside, and clear the paths the incoming change would // write to, so the fast-forward cannot be refused by "your local changes would be // overwritten". Nothing here deletes a file the operator made: every path is copied // out first and copied back after. root := snap.Root - ref := snap.Remote + "/" + snap.Branch + ref := snap.sourceRef() backupDir, kept, err := stashProtected(ctx, root, ref) if err != nil { return nil, err @@ -155,12 +162,12 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error step("protect", fmt.Sprintf("已暂存 %d 个本地内容文件(roles/skills/tools/数据/配置),更新完成后放回", len(kept))) if out, err := gitCmd(ctx, root, "merge", "--ff-only", "--no-verify", ref); err != nil { - restoreProtected(backupDir) + restoreProtected(backupDir, kept) return nil, &Error{Reason: "merge_failed", Message: fmt.Sprintf("快进合并失败:%v\n%s", err, strings.TrimSpace(out))} } // The merge wrote upstream's version of the content paths; the operator's copies win // them back, which is what "update the code, keep my work" means. - if err := restoreProtected(backupDir); err != nil { + if err := restoreProtected(backupDir, kept); err != nil { return nil, err } @@ -271,25 +278,26 @@ func stashProtected(ctx context.Context, root, ref string) (backupDir string, ke return "", nil, nil } -// restoreProtected puts the operator's copies back over whatever the merge wrote. -func restoreProtected(backupDir string) error { +// restoreProtected puts the named operator-owned copies back over whatever the merge +// wrote. The list is the one stashProtected returned: an adoption parks replaced +// non-protected files in the same backup directory (under overwritten/), and those must +// NOT be restored over the target repository's version. +func restoreProtected(backupDir string, kept []string) error { if backupDir == "" { return nil } - return filepath.WalkDir(backupDir, func(path string, d os.DirEntry, err error) error { - if err != nil || d.IsDir() { - return err - } - rel, err := filepath.Rel(backupDir, path) + root := filepath.Join(filepath.Dir(backupDir), "..") + for _, rel := range kept { + abs := filepath.Join(backupDir, filepath.FromSlash(rel)) + info, err := os.Stat(abs) if err != nil { return err } - info, err := os.Stat(path) - if err != nil { + if err := copyFile(abs, filepath.Join(root, filepath.FromSlash(rel)), info.Mode()); err != nil { return err } - return copyFile(path, filepath.Join(filepath.Dir(backupDir), "..", rel), info.Mode()) - }) + } + return nil } // untrackedUnder lists untracked files under the given repository paths - the operator's diff --git a/internal/update/source_test.go b/internal/update/source_test.go new file mode 100644 index 00000000..c261e1f2 --- /dev/null +++ b/internal/update/source_test.go @@ -0,0 +1,134 @@ +package update + +import ( + "context" + "path/filepath" + "strings" + "testing" +) + +// The explicit update source (config.yaml update.remote_url) is what lets an installation +// follow a repository that is not one of its own remotes - the official one, a fork, or a +// second-development repository somebody was told about. These tests keep that path honest: +// the address must win over remote names, must not invent a remote, and must not let git's +// ext:: transport (which executes commands) through. + +func TestValidRemoteURL(t *testing.T) { + yes := []string{ + "https://github.com/Sycun/CyberStrikeAI.git", + "http://example.com/x.git", + "ssh://git@example.com/x.git", + "git://example.com/x.git", + "file:///srv/repos/x.git", + "/Volumes/code/CyberStrikeAI/开发版-CyberStrikeAI", + } + no := []string{ + "", + "-u./evil", + "ext::sh -c whoami", + "ext::touch /tmp/x", + "a\nb", + "javascript:alert(1)", + "ftp://example.com/x", + " https://github.com/x/y.git", + strings.Repeat("a", 600), + } + for _, s := range yes { + if !ValidRemoteURL(s) { + t.Errorf("%q must be accepted as a fetch source", s) + } + } + for _, s := range no { + if ValidRemoteURL(s) { + t.Errorf("%q must be rejected before it reaches git", s) + } + } +} + +func TestCheckAndApplyFromAnExplicitURL(t *testing.T) { + requireGit(t) + tr := newTree(t) + // No remote at all: the source is purely the configured address. + mustGit(t, tr.install, "remote", "remove", "origin") + + urlOpts := Options{Root: tr.install, RemoteURL: tr.upstream, BinaryName: "none"} + + snap, err := Status(context.Background(), urlOpts) + if err != nil { + t.Fatal(err) + } + if !snap.Installed || snap.Remote != "" || snap.RemoteURL != tr.upstream { + t.Fatalf("source must resolve to the configured address: installed=%v remote=%q url=%q", + snap.Installed, snap.Remote, snap.RemoteURL) + } + // With no upstream to name a branch, the current branch is the default. + if snap.Branch != "main" { + t.Fatalf("branch = %q, want main", snap.Branch) + } + + tr.upstreamCommit(t, "second: bump service", map[string]string{ + "internal_service.go": "package service\n\nconst Version = \"2\"\n", + }) + snap, err = Check(context.Background(), urlOpts) + if err != nil { + t.Fatal(err) + } + if snap.CheckError != "" { + t.Fatalf("check failed: %s", snap.CheckError) + } + if !snap.UpdateAvailable || snap.Behind != 1 { + t.Fatalf("behind=%d available=%v, want 1/true", snap.Behind, snap.UpdateAvailable) + } + + res, err := Apply(context.Background(), urlOpts, nil) + if err != nil { + t.Fatalf("apply failed: %v\nresult: %+v", err, res) + } + if res.Commits != 1 { + t.Fatalf("res = %+v, want one commit moved", res) + } + if got := readFile(t, filepath.Join(tr.install, "internal_service.go")); !strings.Contains(got, "Version = \"2\"") { + t.Fatalf("code was not updated: %q", got) + } + // A URL source parks its ref in a namespace of its own; it must not fabricate a remote + // the operator never configured. + if remotes := strings.TrimSpace(mustGit(t, tr.install, "remote")); remotes != "" { + t.Fatalf("a URL source must not create a remote, got %q", remotes) + } +} + +func TestRemoteURLWinsOverARemoteName(t *testing.T) { + requireGit(t) + tr := newTree(t) + // A second repository with its own newer commit: the address must beat the origin remote. + other := filepath.Join(filepath.Dir(tr.upstream), "other") + mustGit(t, filepath.Dir(tr.upstream), "clone", "-q", tr.upstream, other) + writeFile(t, filepath.Join(other, "internal_service.go"), "package service\n\nconst Version = \"9\"\n") + mustGit(t, other, "add", ".") + mustGit(t, other, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "other moves") + + snap, err := Check(context.Background(), Options{ + Root: tr.install, Remote: "origin", RemoteURL: other, BinaryName: "none", + }) + if err != nil { + t.Fatal(err) + } + if snap.CheckError != "" { + t.Fatalf("check failed: %s", snap.CheckError) + } + if snap.RemoteURL != other || snap.Behind != 1 || len(snap.Incoming) != 1 || snap.Incoming[0].Subject != "other moves" { + t.Fatalf("the address must win over the remote name: %+v", snap) + } +} + +func TestInvalidRemoteURLIsAReadableStateNotAnError(t *testing.T) { + requireGit(t) + tr := newTree(t) + snap, err := Status(context.Background(), Options{Root: tr.install, RemoteURL: "ext::sh -c true"}) + if err != nil { + t.Fatalf("a bad configured source is a state the page can show, not a fault: %v", err) + } + if !strings.Contains(snap.CheckError, "不合法") { + t.Fatalf("checkError = %q, want the illegality named", snap.CheckError) + } +} diff --git a/internal/update/update.go b/internal/update/update.go index f90ce0c3..b090f5ad 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -34,6 +34,11 @@ import ( // Binary is the default executable name an update replaces. const Binary = "cyberstrike-ai" +// urlRefNamespace is where a fetch from an explicit address (config.yaml update.remote_url) +// parks its remote-tracking ref. A fixed name no remote can shadow keeps the update reading +// the same shape whether the source is a remote name or a URL. +const urlRefNamespace = "update-source" + // maxIncoming caps how many commits a status page lists; the count is reported // separately so "37 new commits, showing 50" is impossible. const maxIncoming = 50 @@ -44,6 +49,38 @@ const maxIncoming = 50 // rule instead of one that is stricter in the case nobody attacks. var namePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/@-]*$`) +// urlSchemePattern is the allowlist of fetch schemes. git also accepts "ext::" URLs, which +// run the command written after the colons, so this is an allowlist rather than "whatever +// git accepts"; every other "::" form is rejected outright. +var urlSchemePattern = regexp.MustCompile(`^(https?|ssh|git|file)://`) + +// maxRemoteURLLength caps what a config file can put on a git command line. +const maxRemoteURLLength = 512 + +// ValidRemoteURL reports whether s may be used as a fetch source. Local absolute paths are +// allowed on purpose: they are how this project's own trees point at each other in tests +// and on one machine. +func ValidRemoteURL(s string) bool { + if s == "" || len(s) > maxRemoteURLLength { + return false + } + if strings.HasPrefix(s, "-") { + return false + } + for _, r := range s { + if r < 0x20 || r == 0x7f { + return false + } + } + if strings.Contains(s, "::") { + return false + } + if strings.HasPrefix(s, "/") { + return true + } + return urlSchemePattern.MatchString(s) +} + // protectedDirs are install-tree directories whose contents the operator may have // changed through the console or by hand. An update never overwrites them. var protectedDirs = []string{ @@ -73,11 +110,15 @@ func Protected(rel string) bool { } // Options describes which installation to act on. Root is the only required field; -// Remote and Branch exist for a tree that tracks something other than its own upstream. +// Remote, RemoteURL and Branch carry an explicit update source (config.yaml update +// section) for a tree that tracks something other than its own upstream - either a remote +// it already has, by name, or an address to fetch directly. When both are set, the +// address wins. type Options struct { - Root string - Remote string - Branch string + Root string + Remote string + RemoteURL string + Branch string // BinaryName overrides the executable swapped after a successful update. Empty // means "cyberstrike-ai" in Root; "none" means leave the binary alone. BinaryName string @@ -120,10 +161,13 @@ type Commit struct { // Snapshot is everything a person needs to decide whether to press the button. type Snapshot struct { - Root string `json:"root"` - Installed bool `json:"installed"` - Branch string `json:"branch"` - Remote string `json:"remote"` + Root string `json:"root"` + Installed bool `json:"installed"` + Branch string `json:"branch"` + Remote string `json:"remote"` + // RemoteURL is set when the update source is an explicit address (config.yaml + // update.remote_url) rather than a remote this tree already has. + RemoteURL string `json:"remoteUrl,omitempty"` Remotes []string Commit string `json:"commit"` Subject string `json:"subject"` @@ -211,11 +255,19 @@ func Status(ctx context.Context, opts Options) (*Snapshot, error) { BlockingChanges: []Change{}, } + if url := strings.TrimSpace(opts.RemoteURL); url != "" { + if !ValidRemoteURL(url) { + snap.CheckError = "配置的更新源地址不合法(只允许 https/http/ssh/git/file:// 或本机绝对路径)" + return snap, nil + } + snap.RemoteURL = url + } + if _, err := gitCmd(ctx, root, "rev-parse", "--is-inside-work-tree"); err != nil { // Not a git tree: the page still has to say something useful, so this is a // state rather than an error. snap.Installed = false - snap.CheckError = "这个目录不是 git 工作树,无法自动更新;请用源码包方式更新" + snap.CheckError = "这个目录不是 git 工作树:可以先配置更新源再接入(配置文件 update 段),或按发布包方式更新" snap.GoToolchain, snap.CanBuild = toolchain(ctx, root) snap.HasBinary = fileExists(filepath.Join(root, opts.binaryName())) return snap, nil @@ -295,6 +347,33 @@ func pickRemote(remotes []string, branch, optsRemote string) string { return "" } +// fetchTarget is what git is pointed at: the configured address, or the chosen remote. +func (s *Snapshot) fetchTarget() string { + if s.RemoteURL != "" { + return s.RemoteURL + } + return s.Remote +} + +// fetchNamespace is where the fetched branch is parked under refs/remotes/. +func (s *Snapshot) fetchNamespace() string { + if s.RemoteURL != "" { + return urlRefNamespace + } + return s.Remote +} + +// sourceRef is the remote-tracking ref a status or an update compares against. +func (s *Snapshot) sourceRef() string { return s.fetchNamespace() + "/" + s.Branch } + +// sourceLabel is the human-facing name of where an update would come from. +func (s *Snapshot) sourceLabel() string { + if s.RemoteURL != "" { + return s.RemoteURL + } + return s.Remote +} + // localChanges lists modified and deleted tracked files, and splits them by who owns // the path. func localChanges(ctx context.Context, root string) (all, blocking []Change) { @@ -344,8 +423,8 @@ func Check(ctx context.Context, opts Options) (*Snapshot, error) { return snap, nil } root := snap.Root - if snap.Remote == "" { - snap.CheckError = "该安装没有配置任何 git remote,无法检查更新" + if snap.Remote == "" && snap.RemoteURL == "" { + snap.CheckError = "该安装没有配置任何 git remote,也没有配置更新源地址,无法检查更新" return snap, nil } if snap.Branch == "" || snap.Branch == "HEAD" || !ValidName(snap.Branch) { @@ -356,12 +435,12 @@ func Check(ctx context.Context, opts Options) (*Snapshot, error) { // history. A detached fetch of one branch means a remote with many refs cannot // turn the update check into a full clone. if _, err := gitCmd(ctx, root, "fetch", "--quiet", "--no-tags", "--prune", "--recurse-submodules=no", - snap.Remote, "+refs/heads/"+snap.Branch+":refs/remotes/"+snap.Remote+"/"+snap.Branch); err != nil { + snap.fetchTarget(), "+refs/heads/"+snap.Branch+":refs/remotes/"+snap.fetchNamespace()+"/"+snap.Branch); err != nil { snap.CheckError = err.Error() return snap, nil } - ref := snap.Remote + "/" + snap.Branch + ref := snap.sourceRef() if v, err := gitCmd(ctx, root, "rev-parse", "--short", ref); err == nil { snap.RemoteCommit = v } else {