package client import ( "fmt" "io" "os" "path/filepath" "strings" "github.com/m3tam3re/agent-lib/internal/target" ) // SyncOptions parameterize a client sync run. RepoURL (with optional Ref) // is the inline --repo override: it works without any config file and beats // a repo_url discovered in the config chain. type SyncOptions struct { ConfigPath string RepoURL string Ref string } // prepared is everything sync and status share: the pulled repository, the // planned action list, and the local state — before any mutation. type prepared struct { paths StatePaths pull *PullResult manifest *Manifest items []PlannedItem plan []PlanEntry } // prepare pulls, inventories, hashes and plans — the complete read-only // front half of a client run. func prepare(opts SyncOptions) (*prepared, error) { if opts.RepoURL == "" && opts.Ref != "" { return nil, fmt.Errorf("--ref requires --repo (it only overrides the repository to pull)") } cfg, err := resolveRunConfig(opts) if err != nil { return nil, err } paths, err := ResolveStatePaths() if err != nil { return nil, err } if err := os.MkdirAll(paths.Root, 0o755); err != nil { return nil, err } pull, err := Pull(paths.CacheDir, cfg.RepoURL, cfg.Ref, cfg.Token, cfg.TokenUser) if err != nil { return nil, err } inv, err := BuildInventory(pull.Tree, pull.Rev) if err != nil { return nil, err } oc, err := target.NewOpenCode() if err != nil { return nil, err } manifest, err := LoadManifest(paths.Manifest) if err != nil { return nil, err } items := make([]PlannedItem, 0, len(inv.Items)) for _, it := range inv.Deployable() { if _, ok := oc.DeployPath(it.Type, it.Name); !ok { continue } hash, err := HashItem(pull.Tree, it) if err != nil { return nil, fmt.Errorf("hashing %s/%s: %w", it.Type, it.Name, err) } items = append(items, PlannedItem{Type: it.Type, Name: it.Name, Origin: it.Origin, RelPath: it.RelPath, Hash: hash}) } pathFor := func(typ, name string) string { path, _ := oc.DeployPath(typ, name) return path } plan := Plan(items, manifest, diskFacts(oc, manifest, items), pathFor) for _, e := range plan { if !safePath(e.DstPath) { return nil, fmt.Errorf("refusing unsafe deploy path %q", e.DstPath) } } return &prepared{paths: paths, pull: pull, manifest: manifest, items: items, plan: plan}, nil } // resolveRunConfig applies the --repo inline override to the config chain: // with --repo, any discovered config only contributes auth (and ref, unless // --ref is also given); without --repo, a config with a repo_url is required. func resolveRunConfig(opts SyncOptions) (*ClientConfig, error) { if opts.RepoURL == "" { cfg, err := LoadClientConfig(opts.ConfigPath) if err != nil { return nil, err } cfg.FillTokenAuth() return cfg, nil } cfg, err := LoadOptionalConfig(opts.ConfigPath) if err != nil { return nil, err } if cfg == nil { cfg = &ClientConfig{} } cfg.RepoURL = opts.RepoURL if opts.Ref != "" { cfg.Ref = opts.Ref } cfg.FillTokenAuth() return cfg, nil } // Sync pulls the work repository, plans the sync as a pure function over // (inventory, manifest, disk facts) and executes the plan. Validation and // hashing happen before the first mutation; unreachable repositories fail // cleanly with the previous state intact. func Sync(opts SyncOptions, stdout io.Writer) (*SyncReport, error) { p, err := prepare(opts) if err != nil { return nil, err } return ExecutePlan(p.pull.Tree, p.plan, p.manifest, p.paths, p.pull.Rev, stdout) } // safePath rejects destinations that escape the user's home directory. func safePath(dst string) bool { home, err := os.UserHomeDir() if err != nil { return false } if dst == home { return false } rel, err := filepath.Rel(home, dst) if err != nil { return false } return rel != ".." && rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) }