package main import ( "fmt" "os" "strings" "text/template" "time" "golang.org/x/text/cases" "golang.org/x/text/language" xlgo "github.com/EthanCodeCraft/xlgo-core" ) func createProject(name string) error { // P1 #21:校验项目名,拒绝路径穿越与非法 Go 包名。 if err := validateProjectName(name); err != nil { return err } // #nosec G703 -- name was validated by validateProjectName: no path separators, no "..", no leading dot, Go identifier only. if _, err := os.Stat(name); !os.IsNotExist(err) { return fmt.Errorf("目录 %s 已存在", name) } // 解析 --template 与 --module 参数(默认 template=api) tmplName := "api" module := name args := os.Args[3:] for i := 0; i < len(args); i++ { switch args[i] { case "--template", "-t": if i+1 >= len(args) { return fmt.Errorf("%s 缺少参数值", args[i]) } tmplName = args[i+1] i++ case "--module", "-m": if i+1 >= len(args) { return fmt.Errorf("%s 缺少参数值", args[i]) } module = args[i+1] i++ default: // P1 #21:未知参数显式报错,不再静默忽略。 return fmt.Errorf("未知参数: %s", args[i]) } } // P1 #21:校验 module 路径,避免模板元字符 {{ }} 经 Sprintf 进 go.mod 后触发 Parse 报错。 if err := validateModulePath(module); err != nil { return err } // 校验模板名 switch tmplName { case "minimal", "api", "fullstack": // ok default: return fmt.Errorf("未知模板: %s(可选: minimal / api / fullstack)", tmplName) } // minimal 模板目录结构最小化;api/fullstack 含完整分层目录 var dirs []string dirs = append(dirs, name, name+"/public", name+"/logs") if tmplName != "minimal" { dirs = append(dirs, name+"/config", name+"/handler", name+"/model", name+"/repository", name+"/service", name+"/middleware", ) } for _, dir := range dirs { // #nosec G301,G703 -- dir is built from validated project name plus fixed scaffold subdirectories; 0755 is intentional for generated project dirs. if err := os.MkdirAll(dir, 0755); err != nil { // #nosec G703 -- name was validated by validateProjectName and is the scaffold root being rolled back. _ = os.RemoveAll(name) // P1 #21:清理半成品 return fmt.Errorf("创建目录失败: %w", err) } } caser := cases.Title(language.English) data := TemplateData{ Package: caser.String(name), Name: caser.String(name), NameLower: strings.ToLower(name), Module: module, Year: time.Now().Year(), } // 按模板选择 main.go 与 config.yaml var mainTmpl, configTmpl string switch tmplName { case "minimal": mainTmpl, configTmpl = templates.MainMinimal, templates.ConfigMinimal case "fullstack": mainTmpl, configTmpl = templates.MainFull, templates.ConfigFull default: // api mainTmpl, configTmpl = templates.Main, templates.Config } // 创建文件 files := map[string]string{ name + "/main.go": mainTmpl, name + "/config.yaml": configTmpl, name + "/go.mod": fmt.Sprintf(templates.GoMod, module, xlgo.Version), name + "/Makefile": templates.Makefile, name + "/.gitignore": templates.Gitignore, } // api/fullstack 模板带示例 handler if tmplName != "minimal" { files[name+"/handler/home.go"] = templates.Handler } for path, content := range files { if err := renderTemplateFile(path, content, data); err != nil { // #nosec G703 -- name was validated by validateProjectName and is the scaffold root being rolled back. _ = os.RemoveAll(name) // P1 #21:部分失败回滚,避免留下半成品项目 return err } } fmt.Printf("✓ 项目 %s 创建成功(模板: %s)\n", name, tmplName) fmt.Println("\n下一步:") fmt.Printf(" cd %s\n", name) fmt.Println(" go mod tidy") fmt.Println(" go run main.go") return nil } // renderTemplateFile 解析并渲染单个模板文件,每次调用显式关闭句柄 // (P1 #21:不在循环内 defer 累积句柄,且 Close 错误纳入返回)。 func renderTemplateFile(path, content string, data TemplateData) (retErr error) { tmpl, err := template.New(path).Parse(content) if err != nil { return fmt.Errorf("解析模板 %s 失败: %w", path, err) } // #nosec G304 -- path is from createProject's files map built from validated project name plus fixed filenames. file, err := os.Create(path) if err != nil { return fmt.Errorf("创建文件 %s 失败: %w", path, err) } defer func() { if cerr := file.Close(); cerr != nil && retErr == nil { retErr = fmt.Errorf("关闭文件 %s 失败: %w", path, cerr) } }() if err := tmpl.Execute(file, data); err != nil { return fmt.Errorf("写入文件 %s 失败: %w", path, err) } return nil } // validateProjectName 校验项目名(P1 #21):非空、无路径分隔符/.. /前导点, // 且可派生为合法 Go 包名(须以字母开头,仅含字母/数字/下划线)。 func validateProjectName(name string) error { if name == "" { return fmt.Errorf("项目名不能为空") } if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") { return fmt.Errorf("项目名不能包含路径分隔符或 ..(防止在预期目录外创建文件): %q", name) } if strings.HasPrefix(name, ".") { return fmt.Errorf("项目名不能以 . 开头: %q", name) } if !isValidGoIdentifier(name) { return fmt.Errorf("项目名 %q 无法生成合法 Go 包名:须以字母开头,仅含字母、数字、下划线", name) } return nil } // isValidGoIdentifier 判断 s 是否为脚手架接受的 ASCII 标识符:字母开头,后续允许字母、数字、下划线。 func isValidGoIdentifier(s string) bool { if s == "" { return false } for i, r := range s { isLetter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') isNum := r >= '0' && r <= '9' if i == 0 { if !isLetter { return false } } else if !isLetter && !isNum && r != '_' { return false } } return true } // validateModulePath 校验 --module(P1 #21):非空、不含模板元字符与空白。 func validateModulePath(module string) error { if module == "" { return fmt.Errorf("模块路径不能为空") } if strings.Contains(module, "{{") || strings.Contains(module, "}}") { return fmt.Errorf("模块路径不能包含模板元字符 {{ }}: %q", module) } if strings.ContainsAny(module, " \t\r\n") { return fmt.Errorf("模块路径不能包含空白字符: %q", module) } return nil } func makeFile(fileType, name string) error { if err := validateMakeName(name); err != nil { return err } name = strings.ToLower(name) nameTitle := makeNameTitle(name) switch fileType { case "handler": return createHandler(name, nameTitle) case "repository": return createRepository(name, nameTitle) case "model": return createModel(name, nameTitle) case "service": return createService(name, nameTitle) default: return fmt.Errorf("未知类型: %s(可用类型: handler, repository, model, service)", fileType) } } // validateMakeName 校验 xlgo make 的资源名,避免路径穿越或生成不可编译的 Go 代码。 func validateMakeName(name string) error { if name == "" { return fmt.Errorf("名称不能为空") } if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") { return fmt.Errorf("名称不能包含路径分隔符或 ..: %q", name) } if strings.HasPrefix(name, ".") { return fmt.Errorf("名称不能以 . 开头: %q", name) } if !isValidGoIdentifier(name) { return fmt.Errorf("名称 %q 必须是合法标识符:须以字母开头,仅含字母、数字、下划线", name) } return nil } // makeNameTitle 把 snake_case 名称转为导出的 CamelCase 类型名。 func makeNameTitle(name string) string { caser := cases.Title(language.English) nameTitle := caser.String(strings.ReplaceAll(name, "_", " ")) return strings.ReplaceAll(nameTitle, " ", "") } func createHandler(name, nameTitle string) error { path := fmt.Sprintf("handler/%s.go", name) if fileExists(path) { return fmt.Errorf("文件 %s 已存在", path) } content := fmt.Sprintf(templates.HandlerMake, nameTitle, name, nameTitle, nameTitle, name, nameTitle, nameTitle, nameTitle, name, nameTitle, name, nameTitle, nameTitle, name, name, nameTitle, nameTitle, name, name, nameTitle, nameTitle, name, name, nameTitle, nameTitle, name, name, nameTitle, ) content = replaceModuleImports(content) if err := writeFile(path, content); err != nil { return err } fmt.Printf("✓ 创建处理器: %s\n", path) return nil } func createRepository(name, nameTitle string) error { path := fmt.Sprintf("repository/%s_repository.go", name) if fileExists(path) { return fmt.Errorf("文件 %s 已存在", path) } content := fmt.Sprintf(templates.RepositoryMake, nameTitle, name, nameTitle, nameTitle, nameTitle, name, nameTitle, nameTitle, nameTitle, nameTitle, nameTitle, nameTitle, ) content = replaceModuleImports(content) if err := writeFile(path, content); err != nil { return err } fmt.Printf("✓ 创建仓库: %s\n", path) return nil } func createModel(name, nameTitle string) error { path := fmt.Sprintf("model/%s.go", name) if fileExists(path) { return fmt.Errorf("文件 %s 已存在", path) } content := fmt.Sprintf(templates.ModelMake, nameTitle, name, nameTitle, nameTitle, name, ) if err := writeFile(path, content); err != nil { return err } fmt.Printf("✓ 创建模型: %s\n", path) return nil } func createService(name, nameTitle string) error { path := fmt.Sprintf("service/%s_service.go", name) if fileExists(path) { return fmt.Errorf("文件 %s 已存在", path) } content := fmt.Sprintf(templates.ServiceMake, nameTitle, name, nameTitle, nameTitle, nameTitle, name, nameTitle, nameTitle, nameTitle, nameTitle, nameTitle, nameTitle, nameTitle, nameTitle, nameTitle, nameTitle, nameTitle, nameTitle, nameTitle, ) content = replaceModuleImports(content) if err := writeFile(path, content); err != nil { return err } fmt.Printf("✓ 创建服务: %s\n", path) return nil }