diff --git a/cli/cmd/provision.go b/cli/cmd/provision.go index fae9aa71..c99c561d 100644 --- a/cli/cmd/provision.go +++ b/cli/cmd/provision.go @@ -191,12 +191,26 @@ func bootstrapInventory(invPath string) error { } func bootstrapFromProviderTemplate(invPath string, cfg *config.Config) error { - labName := "GOAD" - if cfg.ResolvedProvider() == "proxmox" { - labName = cfg.ProxmoxLab() - } providerName := cfg.ResolvedProvider() - templatePath := filepath.Join(cfg.ProjectRoot, "ad", labName, "providers", providerName, "inventory") + + // Resolve the lab tree that holds the provider inventory template. For a + // variant environment, read from the variant target tree so the + // bootstrapped inventory (which carries domain_name and the asset layout) + // points at the variant's ad// assets rather than the stock + // ad/GOAD/ tree. Falls back to the stock/proxmox path for non-variants. + var templatePath string + if ec := cfg.ActiveEnvironment(); ec.Variant { + if _, target := cfg.ResolvedVariantPaths(); target != "" { + templatePath = filepath.Join(target, "providers", providerName, "inventory") + } + } + if templatePath == "" { + labName := "GOAD" + if providerName == "proxmox" { + labName = cfg.ProxmoxLab() + } + templatePath = filepath.Join(cfg.ProjectRoot, "ad", labName, "providers", providerName, "inventory") + } data, err := os.ReadFile(templatePath) if err != nil { diff --git a/cli/internal/variant/generator.go b/cli/internal/variant/generator.go index 629ccd5d..1c573a1e 100644 --- a/cli/internal/variant/generator.go +++ b/cli/internal/variant/generator.go @@ -715,11 +715,7 @@ func (g *Generator) applyReplacements(content string) string { } if g.isNameComponent(r.Old) { - pattern := `\b` + regexp.QuoteMeta(r.Old) + `\b` - re, err := regexp.Compile(pattern) - if err == nil { - content = re.ReplaceAllString(content, r.New) - } + content = replaceNameComponent(content, r.Old, r.New) } else { content = strings.ReplaceAll(content, r.Old, r.New) } @@ -727,6 +723,56 @@ func (g *Generator) applyReplacements(content string) string { return content } +// replaceNameComponent replaces every occurrence of a name-component token +// (a firstname/surname fragment) with its replacement, honoring both normal +// word boundaries AND CamelCase boundaries. Go's regexp (RE2) has no +// lookahead, so we match a leading word boundary plus the token, then accept a +// match only when the character immediately after it is: +// +// (a) absent (end of string), +// (b) a non-word character (normal word boundary), or +// (c) an uppercase ASCII letter (CamelCase boundary, e.g. "StarkWallpaper"). +// +// A trailing lowercase letter, digit, or underscore is rejected so that +// "starky"/"starkey" are left intact. +func replaceNameComponent(content, old, replacement string) string { + re, err := regexp.Compile(`\b` + regexp.QuoteMeta(old)) + if err != nil { + return content + } + + var b strings.Builder + last := 0 + for _, loc := range re.FindAllStringIndex(content, -1) { + start, end := loc[0], loc[1] + + accept := true + if end < len(content) { + c := content[end] + isWord := c == '_' || + ('0' <= c && c <= '9') || + ('a' <= c && c <= 'z') || + ('A' <= c && c <= 'Z') + isUpper := 'A' <= c && c <= 'Z' + // Reject only when the following char is a word char that is NOT + // an uppercase letter (i.e. lowercase letter, digit, or underscore). + if isWord && !isUpper { + accept = false + } + } + + b.WriteString(content[last:start]) + if accept { + b.WriteString(replacement) + } else { + b.WriteString(content[start:end]) + } + last = end + } + b.WriteString(content[last:]) + return b.String() +} + // isNameComponent returns true if old is a firstname/surname component needing word-boundary protection. func (g *Generator) isNameComponent(old string) bool { if _, ok := g.mappings.Misc[old]; !ok { @@ -845,14 +891,23 @@ var textFilenames = map[string]bool{ func (g *Generator) transformFile(srcPath, relPath string) (transformed bool) { ext := filepath.Ext(srcPath) base := filepath.Base(srcPath) - targetFile := filepath.Join(g.TargetPath, relPath) + + // Rename the output basename so a file named after an identity + // (e.g. files/srv02/all/arya.txt) is written under the same rewritten + // name that config.json now references (kathleen.txt). Only the final + // path element is transformed; the directory portion of relPath is left + // untouched. Uses the same replacement machinery as file content. + relDir := filepath.Dir(relPath) + newBase := g.applyReplacements(base) + targetFile := filepath.Join(g.TargetPath, relDir, newBase) if err := os.MkdirAll(filepath.Dir(targetFile), 0o755); err != nil { fmt.Printf("Warning: mkdir failed for %s: %v\n", relPath, err) return false } - if textExtensions[ext] || textFilenames[base] { + isInventory := strings.HasPrefix(base, "inventory") + if textExtensions[ext] || textFilenames[base] || isInventory { content, err := os.ReadFile(srcPath) if err != nil { fmt.Printf("Warning: Could not read %s: %v\n", relPath, err) @@ -876,6 +931,10 @@ func (g *Generator) transformFile(srcPath, relPath string) (transformed bool) { } } + if isInventory { + newContent = g.repointDomainName(newContent) + } + if err := os.WriteFile(targetFile, []byte(newContent), 0o644); err != nil { fmt.Printf("Warning: Could not write %s: %v\n", relPath, err) return false @@ -887,6 +946,22 @@ func (g *Generator) transformFile(srcPath, relPath string) (transformed bool) { return false } +// repointDomainName rewrites the Ansible `domain_name` inventory variable to +// the variant target folder basename. Playbooks resolve vuln scripts/files as +// ad/{{ domain_name }}/..., so for a variant this must point at the variant's +// own ad// tree rather than the stock ad/GOAD/ tree. Only the value is +// changed; the key, indentation, and surrounding lines are preserved. +func (g *Generator) repointDomainName(content string) string { + target := filepath.Base(g.TargetPath) + re := regexp.MustCompile(`(?m)^(\s*domain_name\s*=).*$`) + return re.ReplaceAllStringFunc(content, func(line string) string { + if idx := strings.Index(line, "="); idx >= 0 { + return line[:idx+1] + target + } + return line + }) +} + // copyAndTransform copies the source directory, transforming text files. func (g *Generator) copyAndTransform() error { fmt.Println("\n=== Copying and Transforming Files ===") @@ -908,9 +983,12 @@ func (g *Generator) copyAndTransform() error { return nil } - // Skip .git files + // Skip git metadata (the .git directory itself is skipped above), but KEEP + // .gitkeep placeholders: they preserve otherwise-empty directories the lab + // relies on (e.g. files/srv02/wwwroot/upload/), which would silently vanish + // from the variant if dropped. rel, _ := filepath.Rel(g.SourcePath, path) - if strings.Contains(rel, ".git") { + if strings.Contains(rel, ".git") && d.Name() != ".gitkeep" { return nil } diff --git a/cli/internal/variant/replace_name_component_test.go b/cli/internal/variant/replace_name_component_test.go new file mode 100644 index 00000000..b1ca8a20 --- /dev/null +++ b/cli/internal/variant/replace_name_component_test.go @@ -0,0 +1,37 @@ +package variant + +import "testing" + +// TestReplaceNameComponent locks in the CamelCase-aware boundary matching that +// closes the compound-token leak (e.g. the GPO name "StarkWallpaper"). A name +// component is replaced when it is preceded by a word boundary and followed by +// end-of-string, a non-word character, or an UPPERCASE letter (CamelCase); it is +// left intact when followed by a lowercase letter, digit, or underscore. +func TestReplaceNameComponent(t *testing.T) { + cases := []struct { + name string + in string + old string + repl string + want string + }{ + {"camelCase boundary (the StarkWallpaper bug)", `New-GPO -Name "StarkWallpaper"`, "Stark", "Research", `New-GPO -Name "ResearchWallpaper"`}, + {"trailing backslash is a boundary", `north\Stark`, "Stark", "Research", `north\Research`}, + {"trailing dot is a boundary", "Stark.txt", "Stark", "Research", "Research.txt"}, + {"end of string", "Stark", "Stark", "Research", "Research"}, + {"standalone word", "the Stark house", "Stark", "Research", "the Research house"}, + {"multiple occurrences, mixed boundaries", "Stark and StarkWallpaper", "Stark", "Research", "Research and ResearchWallpaper"}, + {"lowercase continuation is NOT replaced", "Starkey", "Stark", "Research", "Starkey"}, + {"digit continuation is NOT replaced", "Stark1", "Stark", "Research", "Stark1"}, + {"underscore continuation is NOT replaced", "Stark_svc", "Stark", "Research", "Stark_svc"}, + {"no left boundary is NOT replaced", "aStark", "Stark", "Research", "aStark"}, + {"no occurrence is unchanged", "nothing here", "Stark", "Research", "nothing here"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := replaceNameComponent(tc.in, tc.old, tc.repl); got != tc.want { + t.Errorf("replaceNameComponent(%q, %q, %q) = %q, want %q", tc.in, tc.old, tc.repl, got, tc.want) + } + }) + } +}