package apigen import ( "bytes" "context" "encoding/json" "errors" "fmt" gotoken "go/token" "io" "os" "os/exec" "path/filepath" "strings" "time" apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "golang.org/x/mod/modfile" "golang.org/x/mod/module" ) // WorkspaceCatalog is an explicitly trusted Go provider: func() errors.Catalog. // Only workspace generate/check execute providers. Parsing an .api never does. type WorkspaceCatalog struct { Dir string `json:"dir"` Package string `json:"package"` Function string `json:"function"` Output string `json:"output"` } type limitedCatalogBuffer struct { bytes.Buffer limit int } func (b *limitedCatalogBuffer) Write(p []byte) (int, error) { if len(p) > b.limit-b.Len() { return 0, errors.New("catalog provider output exceeds limit") } return b.Buffer.Write(p) } func exportWorkspaceCatalog(root string, cfg WorkspaceCatalog) ([]byte, error) { if cfg.Dir == "" || cfg.Output == "" || !gotoken.IsIdentifier(cfg.Function) || !gotoken.IsExported(cfg.Function) || module.CheckImportPath(cfg.Package) != nil { return nil, errors.New("workspace catalog requires dir, an importable package, an exported function and output") } if err := safePath(root, filepath.ToSlash(filepath.Join(cfg.Dir, ".goOmniExportCheck"))); err != nil { return nil, err } if err := safePath(root, cfg.Output); err != nil { return nil, err } if filepath.Ext(cfg.Output) != ".json" { return nil, errors.New("workspace catalog output must be .json") } dir := filepath.Join(root, filepath.FromSlash(cfg.Dir)) if stat, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil || !stat.Mode().IsRegular() { return nil, errors.New("workspace catalog dir must contain go.mod") } temp, err := os.MkdirTemp("", "go-omni-catalog-*") if err != nil { return nil, err } defer os.RemoveAll(temp) // Overlay a virtual Go file under the provider module. This allows internal // packages without creating files or changing go.mod/go.sum in that module. virtual := filepath.Join(dir, ".goOmniCatalogExport", "main.go") if _, err := os.Lstat(filepath.Dir(virtual)); !errors.Is(err, os.ErrNotExist) { return nil, errors.New("reserved catalog export path already exists") } source := fmt.Sprintf("package main\nimport (\"os\"; provider %q; omnierrors %q)\nfunc main(){if err:=omnierrors.ExportCatalog(os.Stdout,provider.%s());err!=nil{os.Exit(1)}}\n", cfg.Package, frameworkModule+"/errors", cfg.Function) backing := filepath.Join(temp, "main.go") if err = os.WriteFile(backing, []byte(source), 0600); err != nil { return nil, err } overlay, _ := json.Marshal(map[string]any{"Replace": map[string]string{virtual: backing}}) overlayPath := filepath.Join(temp, "overlay.json") if err = os.WriteFile(overlayPath, overlay, 0600); err != nil { return nil, err } // Keep all module metadata writes in the temporary directory as well. // -mod=readonly alone must not be relied on to protect every go.sum update. moduleData, err := os.ReadFile(filepath.Join(dir, "go.mod")) if err != nil { return nil, err } mf, err := modfile.Parse("go.mod", moduleData, nil) if err != nil { return nil, errors.New("workspace catalog has invalid go.mod") } replacements := append([]*modfile.Replace(nil), mf.Replace...) for _, r := range replacements { if r.New.Version == "" && !filepath.IsAbs(r.New.Path) { if err = mf.AddReplace(r.Old.Path, r.Old.Version, filepath.Join(dir, r.New.Path), ""); err != nil { return nil, err } } } moduleData, err = mf.Format() if err != nil { return nil, err } modPath := filepath.Join(temp, "go.mod") if err = os.WriteFile(modPath, moduleData, 0600); err != nil { return nil, err } if sums, e := os.ReadFile(filepath.Join(dir, "go.sum")); e == nil { if err = os.WriteFile(filepath.Join(temp, "go.sum"), sums, 0600); err != nil { return nil, err } } else if !errors.Is(e, os.ErrNotExist) { return nil, e } ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() executable := filepath.Join(temp, "catalogExport") // Build first, then execute the binary directly: cancellation kills the // provider itself instead of only terminating a go-run wrapper process. build := exec.CommandContext(ctx, "go", "build", "-mod=readonly", "-modfile", modPath, "-overlay", overlayPath, "-o", executable, virtual) build.Dir = dir build.Env = append(os.Environ(), "GOWORK=off") build.WaitDelay = 2 * time.Second build.Stdout = &limitedCatalogBuffer{limit: 64 << 10} build.Stderr = &limitedCatalogBuffer{limit: 64 << 10} if err = build.Run(); err != nil { return nil, fmt.Errorf("workspace catalog %s.%s: build failed (%v); check module dependencies and func() errors.Catalog signature", cfg.Package, cfg.Function, err) } cmd := exec.CommandContext(ctx, executable) cmd.Dir = dir cmd.Env = append(os.Environ(), "GOWORK=off") cmd.WaitDelay = 2 * time.Second stdout := &limitedCatalogBuffer{limit: 1 << 20} stderr := &limitedCatalogBuffer{limit: 64 << 10} cmd.Stdout = stdout cmd.Stderr = stderr if err = cmd.Run(); err != nil { if ctx.Err() != nil { return nil, fmt.Errorf("workspace catalog %s: %w", cfg.Package, ctx.Err()) } // Provider output can contain arbitrary application data. Do not echo it. return nil, fmt.Errorf("workspace catalog %s.%s: execution failed (%v); provider must return errors.Catalog without writing stdout", cfg.Package, cfg.Function, err) } var catalog apierrors.Catalog if err = apierrors.DecodeCatalogJSON(stdout.Bytes(), &catalog); err != nil { return nil, errors.New("workspace catalog provider did not emit a valid catalog") } var normalized bytes.Buffer if err = apierrors.ExportCatalog(&normalized, catalog); err != nil { return nil, err } return normalized.Bytes(), nil } func prepareWorkspaceCatalogs(root string, configs []WorkspaceCatalog, check bool) (map[string][]byte, error) { if len(configs) > 16 { return nil, errors.New("workspace supports at most 16 catalog providers") } result := map[string][]byte{} for _, cfg := range configs { if err := safePath(root, cfg.Output); err != nil { return nil, err } path := filepath.Join(root, filepath.FromSlash(cfg.Output)) for prior := range result { if strings.EqualFold(prior, path) { return nil, errors.New("workspace catalog outputs overlap") } } data, err := exportWorkspaceCatalog(root, cfg) if err != nil { return nil, err } if check { old, err := os.ReadFile(path) if err != nil || !bytes.Equal(old, data) { return nil, fmt.Errorf("workspace catalog drift: %s; run workspace generate", cfg.Output) } } result[path] = data } return result, nil } func writeWorkspaceCatalog(path string, data []byte) error { mode := os.FileMode(0644) if stat, err := os.Lstat(path); err == nil { if !stat.Mode().IsRegular() { return errors.New("workspace catalog output must be a regular file") } mode = stat.Mode().Perm() } else if !errors.Is(err, os.ErrNotExist) { return err } old, err := os.ReadFile(path) if err == nil && bytes.Equal(old, data) { return nil } if err != nil && !errors.Is(err, os.ErrNotExist) { return err } f, err := os.CreateTemp(filepath.Dir(path), ".omni-catalog-*") if err != nil { return err } defer os.Remove(f.Name()) if err = f.Chmod(mode); err != nil { _ = f.Close() return err } _, writeErr := io.Copy(f, bytes.NewReader(data)) err = errors.Join(writeErr, f.Close()) if err != nil { return err } return os.Rename(f.Name(), path) }