diff --git a/handlers/sshd_config/analyzer/analyzer.go b/handlers/sshd_config/analyzer/analyzer.go index 3bee59b..0b661c6 100644 --- a/handlers/sshd_config/analyzer/analyzer.go +++ b/handlers/sshd_config/analyzer/analyzer.go @@ -13,19 +13,54 @@ func Analyze( d *sshdconfig.SSHDocument, ) []protocol.Diagnostic { errors := analyzeOptionsAreValid(d) + + if len(errors) > 0 { + return errsToDiagnostics(errors) + } + indexes, indexErrors := indexes.CreateIndexes(*d.Config) - _ = indexes + + d.Indexes = indexes errors = append(errors, indexErrors...) if len(errors) > 0 { - return utils.Map( - errors, - func(err common.LSPError) protocol.Diagnostic { - return err.ToDiagnostic() - }, - ) + return errsToDiagnostics(errors) + } + + includeErrors := analyzeIncludeValues(d) + + if len(includeErrors) > 0 { + errors = append(errors, includeErrors...) + } else { + for _, include := range d.Indexes.Includes { + for _, value := range include.Values { + for _, path := range value.Paths { + _, err := parseFile(string(path)) + + if err != nil { + errors = append(errors, common.LSPError{ + Range: value.LocationRange, + Err: err, + }) + } + } + } + } + } + + if len(errors) > 0 { + return errsToDiagnostics(errors) } return nil } + +func errsToDiagnostics(errs []common.LSPError) []protocol.Diagnostic { + return utils.Map( + errs, + func(err common.LSPError) protocol.Diagnostic { + return err.ToDiagnostic() + }, + ) +} diff --git a/handlers/sshd_config/analyzer/include.go b/handlers/sshd_config/analyzer/include.go new file mode 100644 index 0000000..f785df3 --- /dev/null +++ b/handlers/sshd_config/analyzer/include.go @@ -0,0 +1,95 @@ +package analyzer + +import ( + "config-lsp/common" + sshdconfig "config-lsp/handlers/sshd_config" + "config-lsp/handlers/sshd_config/ast" + "config-lsp/handlers/sshd_config/indexes" + "config-lsp/utils" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "regexp" +) + +var whitespacePattern = regexp.MustCompile(`\S+`) + +func analyzeIncludeValues( + d *sshdconfig.SSHDocument, +) []common.LSPError { + errs := make([]common.LSPError, 0) + + for _, include := range d.Indexes.Includes { + for _, value := range include.Values { + validPaths, err := createIncludePaths(value.Value) + + if err != nil { + errs = append(errs, common.LSPError{ + Range: value.LocationRange, + Err: err, + }) + } else { + value.Paths = validPaths + } + } + } + + return errs +} + +func createIncludePaths( + suggestedPath string, +) ([]indexes.ValidPath, error) { + var absolutePath string + + if path.IsAbs(suggestedPath) { + absolutePath = suggestedPath + } else { + absolutePath = path.Join("/etc", "ssh", suggestedPath) + } + + files, err := filepath.Glob(absolutePath) + + if err != nil { + return nil, errors.New(fmt.Sprintf("Could not find file %s (error: %s)", absolutePath, err)) + } + + if len(files) == 0 { + return nil, errors.New(fmt.Sprintf("Could not find file %s", absolutePath)) + } + + return utils.Map( + files, + func(file string) indexes.ValidPath { + return indexes.ValidPath(file) + }, + ), nil +} + +func parseFile( + filePath string, +) (*sshdconfig.SSHDocument, error) { + c := ast.NewSSHConfig() + + content, err := os.ReadFile(filePath) + + if err != nil { + return nil, err + } + + c.Parse(string(content)) + + d := &sshdconfig.SSHDocument{ + Config: c, + } + + errs := Analyze(d) + + if len(errs) > 0 { + return nil, errors.New(fmt.Sprintf("Errors in %s", filePath)) + } + + return d, nil +} diff --git a/handlers/sshd_config/analyzer/options.go b/handlers/sshd_config/analyzer/options.go index 13140f0..67aaf87 100644 --- a/handlers/sshd_config/analyzer/options.go +++ b/handlers/sshd_config/analyzer/options.go @@ -60,24 +60,27 @@ func checkOption( return errs } - if option.OptionValue == nil { - return errs + if option.OptionValue == nil || option.OptionValue.Value == "" { + errs = append(errs, common.LSPError{ + Range: option.Key.LocationRange, + Err: errors.New(fmt.Sprintf("Option '%s' requires a value", option.Key.Value)), + }) + } else { + invalidValues := docOption.CheckIsValid(option.OptionValue.Value) + + errs = append( + errs, + utils.Map( + invalidValues, + func(invalidValue *docvalues.InvalidValue) common.LSPError { + err := docvalues.LSPErrorFromInvalidValue(option.Start.Line, *invalidValue) + err.ShiftCharacter(option.OptionValue.Start.Character) + + return err + }, + )..., + ) } - - invalidValues := docOption.CheckIsValid(option.OptionValue.Value) - - errs = append( - errs, - utils.Map( - invalidValues, - func(invalidValue *docvalues.InvalidValue) common.LSPError { - err := docvalues.LSPErrorFromInvalidValue(option.Start.Line, *invalidValue) - err.ShiftCharacter(option.OptionValue.Start.Character) - - return err - }, - )..., - ) } return errs diff --git a/handlers/sshd_config/fields/fields.go b/handlers/sshd_config/fields/fields.go index 586da43..d7abb52 100644 --- a/handlers/sshd_config/fields/fields.go +++ b/handlers/sshd_config/fields/fields.go @@ -391,9 +391,7 @@ See PATTERNS in ssh_config(5) for more information on patterns. This keyword may Value: docvalues.ArrayValue{ Separator: " ", DuplicatesExtractor: &docvalues.SimpleDuplicatesExtractor, - SubValue: docvalues.PathValue{ - RequiredType: docvalues.PathTypeFile, - }, + SubValue: docvalues.StringValue{}, }, // TODO: Add extra check }, diff --git a/handlers/sshd_config/handlers/definition.go b/handlers/sshd_config/handlers/definition.go new file mode 100644 index 0000000..19449c3 --- /dev/null +++ b/handlers/sshd_config/handlers/definition.go @@ -0,0 +1,47 @@ +package handlers + +import ( + "config-lsp/handlers/sshd_config/indexes" + "config-lsp/utils" + "fmt" + "slices" + + protocol "github.com/tliron/glsp/protocol_3_16" +) + +func GetIncludeOptionLocation( + include *indexes.SSHIndexIncludeLine, + cursor uint32, +) []protocol.Location { + index, found := slices.BinarySearchFunc( + include.Values, + cursor, + func(i *indexes.SSHIndexIncludeValue, cursor uint32) int { + if cursor < i.Start.Character { + return -1 + } + + if cursor > i.End.Character { + return 1 + } + + return 0 + }, + ) + + if !found { + return nil + } + + path := include.Values[index] + println("paths", fmt.Sprintf("%v", path.Paths)) + + return utils.Map( + path.Paths, + func(path indexes.ValidPath) protocol.Location { + return protocol.Location{ + URI: path.AsURI(), + } + }, + ) +} diff --git a/handlers/sshd_config/indexes/indexes.go b/handlers/sshd_config/indexes/indexes.go index 9cab396..ed56afd 100644 --- a/handlers/sshd_config/indexes/indexes.go +++ b/handlers/sshd_config/indexes/indexes.go @@ -5,6 +5,7 @@ import ( "config-lsp/handlers/sshd_config/ast" "errors" "fmt" + "regexp" ) var allowedDoubleOptions = map[string]struct{}{ @@ -24,7 +25,26 @@ type SSHIndexKey struct { type SSHIndexAllOption struct { MatchBlock *ast.SSHMatchBlock - Option *ast.SSHOption + Option *ast.SSHOption +} + +type ValidPath string + +func (v ValidPath) AsURI() string { + return "file://" + string(v) +} + +type SSHIndexIncludeValue struct { + common.LocationRange + Value string + + // Actual valid paths, these will be set by the analyzer + Paths []ValidPath +} + +type SSHIndexIncludeLine struct { + Values []*SSHIndexIncludeValue + Option *SSHIndexAllOption } type SSHIndexes struct { @@ -35,18 +55,22 @@ type SSHIndexes struct { OptionsPerRelativeKey map[SSHIndexKey][]*ast.SSHOption // This is a map of `Option name` to a list of options with that name - AllOptionsPerName map[string][]*SSHIndexAllOption + AllOptionsPerName map[string]map[uint32]*SSHIndexAllOption + + Includes map[uint32]*SSHIndexIncludeLine } +var whitespacePattern = regexp.MustCompile(`\S+`) + func CreateIndexes(config ast.SSHConfig) (*SSHIndexes, []common.LSPError) { errs := make([]common.LSPError, 0) indexes := &SSHIndexes{ OptionsPerRelativeKey: make(map[SSHIndexKey][]*ast.SSHOption), - AllOptionsPerName: make(map[string][]*SSHIndexAllOption), + AllOptionsPerName: make(map[string]map[uint32]*SSHIndexAllOption), + Includes: make(map[uint32]*SSHIndexIncludeLine), } it := config.Options.Iterator() - for it.Next() { entry := it.Value().(ast.SSHEntry) @@ -59,7 +83,6 @@ func CreateIndexes(config ast.SSHConfig) (*SSHIndexes, []common.LSPError) { matchBlock := entry.(*ast.SSHMatchBlock) it := matchBlock.Options.Iterator() - for it.Next() { option := it.Value().(*ast.SSHOption) @@ -68,6 +91,43 @@ func CreateIndexes(config ast.SSHConfig) (*SSHIndexes, []common.LSPError) { } } + // Add Includes + for _, includeOption := range indexes.AllOptionsPerName["Include"] { + rawValue := includeOption.Option.OptionValue.Value + pathIndexes := whitespacePattern.FindAllStringIndex(rawValue, -1) + paths := make([]*SSHIndexIncludeValue, 0) + + for _, pathIndex := range pathIndexes { + startIndex := pathIndex[0] + endIndex := pathIndex[1] + + rawPath := rawValue[startIndex:endIndex] + + offset := includeOption.Option.OptionValue.Start.Character + path := SSHIndexIncludeValue{ + LocationRange: common.LocationRange{ + Start: common.Location{ + Line: includeOption.Option.Start.Line, + Character: uint32(startIndex) + offset, + }, + End: common.Location{ + Line: includeOption.Option.Start.Line, + Character: uint32(endIndex) + offset - 1, + }, + }, + Value: rawPath, + Paths: make([]ValidPath, 0), + } + + paths = append(paths, &path) + } + + indexes.Includes[includeOption.Option.Start.Line] = &SSHIndexIncludeLine{ + Values: paths, + Option: includeOption, + } + } + return indexes, errs } @@ -97,17 +157,16 @@ func addOption( i.OptionsPerRelativeKey[indexEntry] = []*ast.SSHOption{option} } - - if existingEntry, found := i.AllOptionsPerName[option.Key.Value]; found { - i.AllOptionsPerName[option.Key.Value] = append(existingEntry, &SSHIndexAllOption{ + if _, found := i.AllOptionsPerName[option.Key.Value]; found { + i.AllOptionsPerName[option.Key.Value][option.Start.Line] = &SSHIndexAllOption{ MatchBlock: matchBlock, - Option: option, - }) + Option: option, + } } else { - i.AllOptionsPerName[option.Key.Value] = []*SSHIndexAllOption{ - { + i.AllOptionsPerName[option.Key.Value] = map[uint32]*SSHIndexAllOption{ + option.Start.Line: { MatchBlock: matchBlock, - Option: option, + Option: option, }, } } diff --git a/handlers/sshd_config/indexes/indexes_test.go b/handlers/sshd_config/indexes/indexes_test.go index c6901d3..7271dc6 100644 --- a/handlers/sshd_config/indexes/indexes_test.go +++ b/handlers/sshd_config/indexes/indexes_test.go @@ -116,3 +116,48 @@ Match Address 192.168.0.1/24 t.Errorf("Expected 3 PermitRootLogin options, but got %v", indexes.AllOptionsPerName["PermitRootLogin"]) } } + +func TestIncludeExample( + t *testing.T, +) { + input := utils.Dedent(` +PermitRootLogin yes +Include /etc/ssh/sshd_config.d/*.conf hello_world +`) + config := ast.NewSSHConfig() + errors := config.Parse(input) + + if len(errors) > 0 { + t.Fatalf("Expected no errors, but got %v", len(errors)) + } + + indexes, errors := CreateIndexes(*config) + + if len(errors) > 0 { + t.Fatalf("Expected no errors, but got %v", len(errors)) + } + + if !(len(indexes.Includes) == 1) { + t.Fatalf("Expected 1 include, but got %v", len(indexes.Includes)) + } + + if !(len(indexes.Includes[1].Values) == 2) { + t.Fatalf("Expected 2 include path, but got %v", len(indexes.Includes[1].Values)) + } + + if !(indexes.Includes[1].Values[0].Value == "/etc/ssh/sshd_config.d/*.conf" && + indexes.Includes[1].Values[0].Start.Line == 1 && + indexes.Includes[1].Values[0].End.Line == 1 && + indexes.Includes[1].Values[0].Start.Character == 8 && + indexes.Includes[1].Values[0].End.Character == 36) { + t.Errorf("Expected '/etc/ssh/sshd_config.d/*.conf' on line 1, but got %v on line %v", indexes.Includes[1].Values[0].Value, indexes.Includes[1].Values[0].Start.Line) + } + + if !(indexes.Includes[1].Values[1].Value == "hello_world" && + indexes.Includes[1].Values[1].Start.Line == 1 && + indexes.Includes[1].Values[1].End.Line == 1 && + indexes.Includes[1].Values[1].Start.Character == 38 && + indexes.Includes[1].Values[1].End.Character == 48) { + t.Errorf("Expected 'hello_world' on line 1, but got %v on line %v", indexes.Includes[1].Values[1].Value, indexes.Includes[1].Values[1].Start.Line) + } +} diff --git a/handlers/sshd_config/lsp/text-document-definition.go b/handlers/sshd_config/lsp/text-document-definition.go new file mode 100644 index 0000000..7ccdb66 --- /dev/null +++ b/handlers/sshd_config/lsp/text-document-definition.go @@ -0,0 +1,23 @@ +package lsp + +import ( + sshdconfig "config-lsp/handlers/sshd_config" + "config-lsp/handlers/sshd_config/handlers" + + "github.com/tliron/glsp" + protocol "github.com/tliron/glsp/protocol_3_16" +) + +func TextDocumentDefinition(context *glsp.Context, params *protocol.DefinitionParams) ([]protocol.Location, error) { + d := sshdconfig.DocumentParserMap[params.TextDocument.URI] + cursor := params.Position.Character + line := params.Position.Line + + if include, found := d.Indexes.Includes[line]; found { + relativeCursor := cursor - include.Option.Option.LocationRange.Start.Character + + return handlers.GetIncludeOptionLocation(include, relativeCursor), nil + } + + return nil, nil +} diff --git a/handlers/sshd_config/shared.go b/handlers/sshd_config/shared.go index 318f6ba..b44850f 100644 --- a/handlers/sshd_config/shared.go +++ b/handlers/sshd_config/shared.go @@ -2,12 +2,14 @@ package sshdconfig import ( "config-lsp/handlers/sshd_config/ast" + "config-lsp/handlers/sshd_config/indexes" protocol "github.com/tliron/glsp/protocol_3_16" ) type SSHDocument struct { - Config *ast.SSHConfig + Config *ast.SSHConfig + Indexes *indexes.SSHIndexes } var DocumentParserMap = map[protocol.DocumentUri]*SSHDocument{} diff --git a/root-handler/text-document-definition.go b/root-handler/text-document-definition.go index 194bcb8..70e7b6a 100644 --- a/root-handler/text-document-definition.go +++ b/root-handler/text-document-definition.go @@ -2,6 +2,7 @@ package roothandler import ( aliases "config-lsp/handlers/aliases/lsp" + sshdconfig "config-lsp/handlers/sshd_config/lsp" "github.com/tliron/glsp" protocol "github.com/tliron/glsp/protocol_3_16" @@ -24,7 +25,7 @@ func TextDocumentDefinition(context *glsp.Context, params *protocol.DefinitionPa case LanguageHosts: return nil, nil case LanguageSSHDConfig: - return nil, nil + return sshdconfig.TextDocumentDefinition(context, params) case LanguageFstab: return nil, nil case LanguageWireguard: