From a85b004947a3af41dac0c271f9cb11f87a92295f Mon Sep 17 00:00:00 2001 From: Carlos Granados Date: Thu, 23 Jul 2026 18:53:06 +0200 Subject: [PATCH] Ini package --- internal/ini/ini.go | 183 +++++++++++++++++++++++++++++++ internal/ini/ini_test.go | 230 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 413 insertions(+) create mode 100644 internal/ini/ini.go create mode 100644 internal/ini/ini_test.go diff --git a/internal/ini/ini.go b/internal/ini/ini.go new file mode 100644 index 0000000..1ae269c --- /dev/null +++ b/internal/ini/ini.go @@ -0,0 +1,183 @@ +// Package ini provides line-based editing of PHP ini files. It is deliberately +// not a general ini parser: it preserves the original file's formatting, +// comments and line endings, and only rewrites the specific lines the installer +// cares about — xdebug loader directives and the xdebug.mode setting. +// +// All functions are pure (string in, string out) so they can be unit tested +// without touching the filesystem. +package ini + +import "strings" + +// AllowedModes are the only xdebug.mode tokens the installer permits. +var AllowedModes = []string{"off", "debug"} + +// StripXdebugLoaders removes every line that loads the xdebug extension — i.e. a +// `zend_extension=` directive whose value references "xdebug" — whether the line +// is active or commented out. (Xdebug is a Zend extension and only loads via +// zend_extension, so plain `extension=` lines are left alone.) It returns the +// rewritten content and the list of removed lines (trimmed of any trailing CR) +// for reporting. +func StripXdebugLoaders(content string) (string, []string) { + lines := strings.Split(content, "\n") + kept := make([]string, 0, len(lines)) + var removed []string + for _, ln := range lines { + _, key, value, ok := parseDirective(ln) + if ok && key == "zend_extension" && referencesXdebug(value) { + removed = append(removed, strings.TrimRight(ln, "\r")) + continue + } + kept = append(kept, ln) + } + return strings.Join(kept, "\n"), removed +} + +// DisallowedModes returns the de-duplicated set of xdebug.mode tokens present in +// xdebug.mode directives (active or commented out) that are not in AllowedModes, +// preserving first-seen order. It is empty if xdebug.mode is absent or already +// valid, letting the caller decide whether to prompt the user. +func DisallowedModes(content string) []string { + var out []string + seen := map[string]bool{} + for _, ln := range strings.Split(content, "\n") { + _, key, value, ok := parseDirective(ln) + if !ok || key != "xdebug.mode" { + continue + } + for _, m := range parseModeList(value) { + if !isAllowedMode(m) && !seen[m] { + seen[m] = true + out = append(out, m) + } + } + } + return out +} + +// SanitizeXdebugMode rewrites every xdebug.mode directive that contains a +// disallowed token so that only allowed tokens remain, preserving their order +// and de-duplicating. If no allowed token survives, the value becomes "off". +// Commented-out xdebug.mode lines are sanitized too, keeping their leading `;`. +// Lines that already contain only allowed tokens are left byte-for-byte +// untouched. +// +// It returns the new content, the disallowed tokens that were removed +// (de-duplicated, first-seen order) and whether anything changed. +func SanitizeXdebugMode(content string) (string, []string, bool) { + lines := strings.Split(content, "\n") + var removed []string + seenRemoved := map[string]bool{} + changed := false + + for i, ln := range lines { + _, key, value, ok := parseDirective(ln) + if !ok || key != "xdebug.mode" { + continue + } + + var allowed []string + seenAllowed := map[string]bool{} + hasDisallowed := false + for _, m := range parseModeList(value) { + if isAllowedMode(m) { + if !seenAllowed[m] { + seenAllowed[m] = true + allowed = append(allowed, m) + } + continue + } + hasDisallowed = true + if !seenRemoved[m] { + seenRemoved[m] = true + removed = append(removed, m) + } + } + if !hasDisallowed { + continue + } + if len(allowed) == 0 { + allowed = []string{"off"} + } + lines[i] = rewriteValue(ln, strings.Join(allowed, ",")) + changed = true + } + + return strings.Join(lines, "\n"), removed, changed +} + +// parseDirective inspects a single line. It reports whether the line is a +// directive (has a `key=value` shape), the lowercased key, the trimmed value, +// and whether the directive is commented out (leading `;`). Non-directive lines +// (blank, pure comments, section headers) return ok=false. +func parseDirective(line string) (commented bool, key, value string, ok bool) { + s := strings.TrimRight(line, "\r") + trimmed := strings.TrimLeft(s, " \t") + if strings.HasPrefix(trimmed, ";") { + commented = true + trimmed = strings.TrimLeft(trimmed[1:], " \t") + } + eq := strings.IndexByte(trimmed, '=') + if eq < 0 { + return commented, "", "", false + } + key = strings.ToLower(strings.TrimSpace(trimmed[:eq])) + if key == "" { + return commented, "", "", false + } + value = strings.TrimSpace(trimmed[eq+1:]) + return commented, key, value, true +} + +// rewriteValue replaces the value portion of a directive line with newValue, +// preserving the key, the exact spacing up to and including `=`, the leading +// whitespace of the original value, and any trailing CR. +func rewriteValue(line, newValue string) string { + cr := "" + body := line + if strings.HasSuffix(body, "\r") { + cr = "\r" + body = body[:len(body)-1] + } + eq := strings.IndexByte(body, '=') + if eq < 0 { + return line + } + head := body[:eq+1] + rest := body[eq+1:] + ws := rest[:len(rest)-len(strings.TrimLeft(rest, " \t"))] + return head + ws + newValue + cr +} + +func referencesXdebug(value string) bool { + return strings.Contains(strings.ToLower(unquote(value)), "xdebug") +} + +func parseModeList(value string) []string { + var out []string + for _, p := range strings.Split(unquote(value), ",") { + if p = strings.ToLower(strings.TrimSpace(p)); p != "" { + out = append(out, p) + } + } + return out +} + +func isAllowedMode(m string) bool { + for _, a := range AllowedModes { + if m == a { + return true + } + } + return false +} + +// unquote strips a single pair of matching surrounding quotes, if present. +func unquote(v string) string { + if len(v) >= 2 { + if (v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'') { + return v[1 : len(v)-1] + } + } + return v +} diff --git a/internal/ini/ini_test.go b/internal/ini/ini_test.go new file mode 100644 index 0000000..63a0dcd --- /dev/null +++ b/internal/ini/ini_test.go @@ -0,0 +1,230 @@ +package ini + +import ( + "reflect" + "testing" +) + +func TestStripXdebugLoaders(t *testing.T) { + tests := []struct { + name string + in string + want string + wantRemoved []string + }{ + { + name: "simple zend_extension", + in: "zend_extension=xdebug.so\n", + want: "", + wantRemoved: []string{"zend_extension=xdebug.so"}, + }, + { + name: "plain extension= is left alone (xdebug loads via zend_extension only)", + in: "extension=xdebug\n", + want: "extension=xdebug\n", + wantRemoved: nil, + }, + { + name: "spaces and quotes and full path", + in: "zend_extension = \"/usr/lib/php/20210902/xdebug.so\"\n", + want: "", + wantRemoved: []string{"zend_extension = \"/usr/lib/php/20210902/xdebug.so\""}, + }, + { + name: "commented loader is also removed", + in: ";zend_extension=xdebug.so\n", + want: "", + wantRemoved: []string{";zend_extension=xdebug.so"}, + }, + { + name: "keeps other extensions and comments", + in: "extension=mysqli\n; xdebug is nice\nzend_extension=opcache.so\n", + want: "extension=mysqli\n; xdebug is nice\nzend_extension=opcache.so\n", + wantRemoved: nil, + }, + { + name: "removes only the loader among other lines", + in: "memory_limit=256M\nzend_extension=xdebug.so\ndisplay_errors=On\n", + want: "memory_limit=256M\ndisplay_errors=On\n", + wantRemoved: []string{"zend_extension=xdebug.so"}, + }, + { + name: "preserves CRLF endings", + in: "memory_limit=256M\r\nzend_extension=xdebug.so\r\ndisplay_errors=On\r\n", + want: "memory_limit=256M\r\ndisplay_errors=On\r\n", + wantRemoved: []string{"zend_extension=xdebug.so"}, + }, + { + name: "no trailing newline", + in: "a=1\nzend_extension=xdebug.so", + want: "a=1", + wantRemoved: []string{"zend_extension=xdebug.so"}, + }, + { + name: "empty content", + in: "", + want: "", + wantRemoved: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, removed := StripXdebugLoaders(tt.in) + if got != tt.want { + t.Errorf("content = %q, want %q", got, tt.want) + } + if !reflect.DeepEqual(removed, tt.wantRemoved) { + t.Errorf("removed = %#v, want %#v", removed, tt.wantRemoved) + } + }) + } +} + +func TestDisallowedModes(t *testing.T) { + tests := []struct { + name string + in string + want []string + }{ + {"none present", "memory_limit=256M\n", nil}, + {"only allowed", "xdebug.mode=debug\n", nil}, + {"off and debug", "xdebug.mode = off,debug\n", nil}, + {"single disallowed", "xdebug.mode=develop\n", []string{"develop"}}, + {"mixed", "xdebug.mode=debug,develop,coverage\n", []string{"develop", "coverage"}}, + {"quoted and spaced", "xdebug.mode = \"debug, profile \"\n", []string{"profile"}}, + {"commented line included", ";xdebug.mode=develop\n", []string{"develop"}}, + {"dedup across lines", "xdebug.mode=develop\nxdebug.mode=develop,trace\n", []string{"develop", "trace"}}, + {"case-insensitive tokens", "xdebug.mode=DEBUG,Develop\n", []string{"develop"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := DisallowedModes(tt.in); !reflect.DeepEqual(got, tt.want) { + t.Errorf("DisallowedModes = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestSanitizeXdebugMode(t *testing.T) { + tests := []struct { + name string + in string + want string + wantRemoved []string + wantChanged bool + }{ + { + name: "drops disallowed keeps allowed", + in: "xdebug.mode=debug,develop,coverage\n", + want: "xdebug.mode=debug\n", + wantRemoved: []string{"develop", "coverage"}, + wantChanged: true, + }, + { + name: "no allowed left falls back to off", + in: "xdebug.mode=develop,coverage\n", + want: "xdebug.mode=off\n", + wantRemoved: []string{"develop", "coverage"}, + wantChanged: true, + }, + { + name: "preserves spacing around equals", + in: "xdebug.mode = debug , trace\n", + want: "xdebug.mode = debug\n", + wantRemoved: []string{"trace"}, + wantChanged: true, + }, + { + name: "already valid untouched", + in: "xdebug.mode=off,debug\n", + want: "xdebug.mode=off,debug\n", + wantRemoved: nil, + wantChanged: false, + }, + { + name: "commented line sanitized keeping marker", + in: ";xdebug.mode=develop\n", + want: ";xdebug.mode=off\n", + wantRemoved: []string{"develop"}, + wantChanged: true, + }, + { + name: "commented line preserves marker spacing", + in: "; xdebug.mode = debug, coverage\n", + want: "; xdebug.mode = debug\n", + wantRemoved: []string{"coverage"}, + wantChanged: true, + }, + { + name: "commented line already valid untouched", + in: ";xdebug.mode=off\n", + want: ";xdebug.mode=off\n", + wantRemoved: nil, + wantChanged: false, + }, + { + name: "preserves CRLF", + in: "xdebug.mode=debug,profile\r\n", + want: "xdebug.mode=debug\r\n", + wantRemoved: []string{"profile"}, + wantChanged: true, + }, + { + name: "only touches the mode line", + in: "memory_limit=256M\nxdebug.mode=develop\ndisplay_errors=On\n", + want: "memory_limit=256M\nxdebug.mode=off\ndisplay_errors=On\n", + wantRemoved: []string{"develop"}, + wantChanged: true, + }, + { + name: "multiple mode lines", + in: "xdebug.mode=debug,develop\nxdebug.mode=trace\n", + want: "xdebug.mode=debug\nxdebug.mode=off\n", + wantRemoved: []string{"develop", "trace"}, + wantChanged: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, removed, changed := SanitizeXdebugMode(tt.in) + if got != tt.want { + t.Errorf("content = %q, want %q", got, tt.want) + } + if !reflect.DeepEqual(removed, tt.wantRemoved) { + t.Errorf("removed = %#v, want %#v", removed, tt.wantRemoved) + } + if changed != tt.wantChanged { + t.Errorf("changed = %v, want %v", changed, tt.wantChanged) + } + }) + } +} + +func TestParseDirective(t *testing.T) { + tests := []struct { + name string + line string + wantCommented bool + wantKey string + wantValue string + wantOK bool + }{ + {"simple", "extension=mysqli", false, "extension", "mysqli", true}, + {"spaces", " xdebug.mode = debug ", false, "xdebug.mode", "debug", true}, + {"commented directive", ";extension=xdebug", true, "extension", "xdebug", true}, + {"lowercased key", "Zend_Extension=xdebug.so", false, "zend_extension", "xdebug.so", true}, + {"blank line", "", false, "", "", false}, + {"pure comment", "; just a note", true, "", "", false}, + {"section header", "[xdebug]", false, "", "", false}, + {"trailing cr", "extension=mysqli\r", false, "extension", "mysqli", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + commented, key, value, ok := parseDirective(tt.line) + if commented != tt.wantCommented || key != tt.wantKey || value != tt.wantValue || ok != tt.wantOK { + t.Errorf("parseDirective(%q) = (%v, %q, %q, %v), want (%v, %q, %q, %v)", + tt.line, commented, key, value, ok, tt.wantCommented, tt.wantKey, tt.wantValue, tt.wantOK) + } + }) + } +}