diff --git a/pkg/cmdrunner/cmdrunner.go b/pkg/cmdrunner/cmdrunner.go index bccb274b..f788e918 100644 --- a/pkg/cmdrunner/cmdrunner.go +++ b/pkg/cmdrunner/cmdrunner.go @@ -20,10 +20,12 @@ import ( "github.com/scripthaus-dev/mshell/pkg/base" "github.com/scripthaus-dev/mshell/pkg/packet" "github.com/scripthaus-dev/mshell/pkg/shexec" + "github.com/scripthaus-dev/sh2-server/pkg/comp" "github.com/scripthaus-dev/sh2-server/pkg/remote" "github.com/scripthaus-dev/sh2-server/pkg/scbase" "github.com/scripthaus-dev/sh2-server/pkg/scpacket" "github.com/scripthaus-dev/sh2-server/pkg/sstore" + "github.com/scripthaus-dev/sh2-server/pkg/utilfn" ) const ( @@ -32,6 +34,11 @@ const ( HistoryTypeGlobal = "global" ) +func init() { + comp.RegisterSimpleCompFn("meta", simpleCompMeta) + comp.RegisterSimpleCompFn("command+meta", simpleCompCommandMeta) +} + const DefaultUserId = "sawka" const MaxNameLen = 50 const MaxRemoteAliasLen = 50 @@ -1043,50 +1050,6 @@ func updateHistoryContext(ctx context.Context, line *sstore.LineType, cmd *sstor } } -func getStrArr(v interface{}, field string) []string { - if v == nil { - return nil - } - m, ok := v.(map[string]interface{}) - if !ok { - return nil - } - fieldVal := m[field] - if fieldVal == nil { - return nil - } - iarr, ok := fieldVal.([]interface{}) - if !ok { - return nil - } - var sarr []string - for _, iv := range iarr { - if sv, ok := iv.(string); ok { - sarr = append(sarr, sv) - } - } - return sarr -} - -func getBool(v interface{}, field string) bool { - if v == nil { - return false - } - m, ok := v.(map[string]interface{}) - if !ok { - return false - } - fieldVal := m[field] - if fieldVal == nil { - return false - } - bval, ok := fieldVal.(bool) - if !ok { - return false - } - return bval -} - func makeInfoFromComps(compType string, comps []string, hasMore bool) sstore.UpdatePacket { sort.Slice(comps, func(i int, j int) bool { c1 := comps[i] @@ -1156,6 +1119,23 @@ func longestPrefix(root string, comps []string) string { return lcp } +func simpleCompMeta(ctx context.Context, prefix string, compCtx comp.CompContext, args []interface{}) (*comp.CompReturn, error) { + compsCmd, _ := comp.DoSimpleComp(ctx, "command", prefix, compCtx, nil) + compsMeta, _ := simpleCompCommandMeta(ctx, prefix, compCtx, nil) + return comp.CombineCompReturn(compsCmd, compsMeta), nil +} + +func simpleCompCommandMeta(ctx context.Context, prefix string, compCtx comp.CompContext, args []interface{}) (*comp.CompReturn, error) { + rtn := comp.CompReturn{} + validCommands := getValidCommands() + for _, cmd := range validCommands { + if strings.HasPrefix(cmd, prefix) { + rtn.Entries = append(rtn.Entries, comp.CompEntry{Word: cmd, IsMetaCmd: true}) + } + } + return &rtn, nil +} + func doMetaCompGen(ctx context.Context, pk *scpacket.FeCommandPacketType, prefix string, forDisplay bool) ([]string, bool, error) { ids, err := resolveUiIds(ctx, pk, 0) // best effort var comps []string @@ -1202,8 +1182,8 @@ func doCompGen(ctx context.Context, pk *scpacket.FeCommandPacketType, prefix str if err = resp.Err(); err != nil { return nil, false, err } - comps := getStrArr(resp.Data, "comps") - hasMore := getBool(resp.Data, "hasmore") + comps := utilfn.GetStrArr(resp.Data, "comps") + hasMore := utilfn.GetBool(resp.Data, "hasmore") return comps, hasMore, nil } @@ -1743,7 +1723,7 @@ func displayStateUpdateDiff(buf *bytes.Buffer, oldState packet.ShellState, newSt if newVal.IsExport() { exportStr = "export " } - buf.WriteString(fmt.Sprintf("%s%s=%s\n", exportStr, key, ShellQuote(newVal.Value, false, 50))) + buf.WriteString(fmt.Sprintf("%s%s=%s\n", exportStr, key, utilfn.ShellQuote(newVal.Value, false, 50))) } } for key, _ := range oldEnvMap { diff --git a/pkg/cmdrunner/shparse.go b/pkg/cmdrunner/shparse.go index ccdb7f40..941c9e9d 100644 --- a/pkg/cmdrunner/shparse.go +++ b/pkg/cmdrunner/shparse.go @@ -6,9 +6,9 @@ import ( "regexp" "strings" - "github.com/alessio/shellescape" "github.com/scripthaus-dev/mshell/pkg/shexec" "github.com/scripthaus-dev/sh2-server/pkg/scpacket" + "github.com/scripthaus-dev/sh2-server/pkg/utilfn" "mvdan.cc/sh/v3/expand" "mvdan.cc/sh/v3/syntax" ) @@ -116,31 +116,6 @@ func onlyRawArgs(metaCmd string, metaSubCmd string) bool { return metaCmd == "run" || metaCmd == "comment" } -// minimum maxlen=6 -func ShellQuote(val string, forceQuote bool, maxLen int) string { - if maxLen < 6 { - maxLen = 6 - } - rtn := shellescape.Quote(val) - if strings.HasPrefix(rtn, "\"") || strings.HasPrefix(rtn, "'") { - if len(rtn) > maxLen { - return rtn[0:maxLen-4] + "..." + rtn[0:1] - } - return rtn - } - if forceQuote { - if len(rtn) > maxLen-2 { - return "\"" + rtn[0:maxLen-5] + "...\"" - } - return "\"" + rtn + "\"" - } else { - if len(rtn) > maxLen { - return rtn[0:maxLen-3] + "..." - } - return rtn - } -} - func setBracketArgs(argMap map[string]string, bracketStr string) error { bracketStr = strings.TrimSpace(bracketStr) if bracketStr == "" { @@ -161,7 +136,7 @@ func setBracketArgs(argMap map[string]string, bracketStr string) error { varVal = litStr[eqIdx+1:] } if !shexec.IsValidBashIdentifier(varName) { - wordErr = fmt.Errorf("invalid identifier %s in bracket args", ShellQuote(varName, true, 20)) + wordErr = fmt.Errorf("invalid identifier %s in bracket args", utilfn.ShellQuote(varName, true, 20)) return false } if varVal == "" { diff --git a/pkg/comp/comp.go b/pkg/comp/comp.go index 5d555f64..cc6fffea 100644 --- a/pkg/comp/comp.go +++ b/pkg/comp/comp.go @@ -2,17 +2,23 @@ package comp import ( + "bytes" "context" "fmt" + "sort" + "strconv" "strings" "unicode" "github.com/scripthaus-dev/mshell/pkg/packet" "github.com/scripthaus-dev/mshell/pkg/shexec" "github.com/scripthaus-dev/sh2-server/pkg/sstore" + "github.com/scripthaus-dev/sh2-server/pkg/utilfn" "mvdan.cc/sh/v3/syntax" ) +const MaxCompQuoteLen = 5000 + const ( SimpleCompGenTypeFile = "file" SimpleCompGenTypeDir = "dir" @@ -24,19 +30,31 @@ const ( SimpleCompGenTypeVariable = "variable" ) +const ( + QuoteTypeLiteral = "" + QuoteTypeDQ = "\"" + QuoteTypeANSI = "$'" + QuoteTypeSQ = "'" +) + type CompContext struct { RemotePtr sstore.RemotePtrType State *packet.ShellState ForDisplay bool } -type SimpleCompGenFnType = func(ctx context.Context, point SimpleCompPoint, compCtx CompContext, args []interface{}) (*CompReturn, error) - type SimpleCompPoint struct { Word string Pos int } +type fullCompPrefix struct { + RawStr string + RawPos int + CompPrefix string + QuoteTypePref string +} + type ParsedWord struct { Offset int Word *syntax.Word @@ -53,6 +71,75 @@ type CompPoint struct { Suffix string } +// directories will have a trailing "/" +type CompEntry struct { + Word string + IsMetaCmd bool +} + +type CompReturn struct { + Entries []CompEntry + HasMore bool +} + +func compQuoteDQString(s string, close bool) string { + var buf bytes.Buffer + buf.WriteByte('"') + for _, ch := range s { + if ch == '"' || ch == '\\' || ch == '$' || ch == '`' { + buf.WriteByte('\\') + buf.WriteRune(ch) + continue + } + buf.WriteRune(ch) + } + if close { + buf.WriteByte('"') + } + return buf.String() +} + +func compQuoteString(s string, quoteType string, close bool) string { + if quoteType != QuoteTypeANSI { + for _, ch := range s { + if ch > unicode.MaxASCII || !unicode.IsPrint(ch) || ch == '!' { + quoteType = QuoteTypeANSI + break + } + if ch == '\'' { + if quoteType == QuoteTypeSQ || quoteType == QuoteTypeLiteral { + quoteType = QuoteTypeANSI + break + } + } + } + } + if quoteType == QuoteTypeANSI { + rtn := strconv.QuoteToASCII(s) + rtn = "$'" + strings.ReplaceAll(rtn[1:len(rtn)-1], "'", "\\'") + if close { + rtn = rtn + "'" + } + return rtn + } + if quoteType == QuoteTypeLiteral { + rtn := utilfn.ShellQuote(s, false, MaxCompQuoteLen) + if len(rtn) > 0 && rtn[0] == '\'' && !close { + rtn = rtn[0 : len(rtn)-1] + } + return rtn + } + if quoteType == QuoteTypeSQ { + rtn := utilfn.ShellQuote(s, true, MaxCompQuoteLen) + if !close { + rtn = rtn[0 : len(rtn)-1] + } + return rtn + } + // QuoteTypeDQ + return compQuoteDQString(s, close) +} + func (p *CompPoint) wordAsStr(w ParsedWord) string { if w.Word != nil { return p.StmtStr[w.Word.Pos().Offset():w.Word.End().Offset()] @@ -63,16 +150,29 @@ func (p *CompPoint) wordAsStr(w ParsedWord) string { func (p *CompPoint) simpleExpandWord(w ParsedWord) string { ectx := shexec.SimpleExpandContext{} if w.Word != nil { - return SimpleExpandWord(ectx, w.Word, p.StmtStr) + return shexec.SimpleExpandWord(ectx, w.Word, p.StmtStr) } - return SimpleExpandPartialWord(ectx, p.PartialWord, false) + return shexec.SimpleExpandPartialWord(ectx, w.PartialWord, false) } -func (p *CompPoint) compPrefix() string { - pword := p.Words[p.CompWord] +func getQuoteTypePref(str string) string { + if strings.HasPrefix(str, QuoteTypeANSI) { + return QuoteTypeANSI + } + if strings.HasPrefix(str, QuoteTypeDQ) { + return QuoteTypeDQ + } + if strings.HasPrefix(str, QuoteTypeSQ) { + return QuoteTypeSQ + } + return QuoteTypeLiteral +} + +func (p *CompPoint) getCompPrefix() string { if p.CompWordPos == 0 { return "" } + pword := p.Words[p.CompWord] wordStr := p.wordAsStr(pword) if p.CompWordPos == len(wordStr) { return p.simpleExpandWord(pword) @@ -82,7 +182,21 @@ func (p *CompPoint) compPrefix() string { // and a partial on just the current part. this is an uncommon case though // and has very little upside (even bash does not expand multipart words correctly) partialWordStr := wordStr[:p.CompWordPos] - return SimpleExpandPartialWord(shexec.SimpleExpandContext{}, partialWordStr, false) + return shexec.SimpleExpandPartialWord(shexec.SimpleExpandContext{}, partialWordStr, false) +} + +func (p *CompPoint) extendWord(newWord string, newWordComplete bool) (string, int) { + pword := p.Words[p.CompWord] + wordStr := p.wordAsStr(pword) + quotePref := getQuoteTypePref(wordStr) + needsClose := newWordComplete && (len(wordStr) == p.CompWordPos) + wordSuffix := wordStr[p.CompWordPos:] + newQuotedStr := compQuoteString(newWord, quotePref, needsClose) + if needsClose && wordSuffix == "" { + newQuotedStr = newQuotedStr + " " + } + newPos := len(newQuotedStr) + return newQuotedStr + wordSuffix, newPos } func (p *CompPoint) dump() { @@ -107,17 +221,6 @@ func (p *CompPoint) dump() { fmt.Printf("\n") } -// directories will have a trailing "/" -type CompEntry struct { - Word string - IsMetaCmd bool -} - -type CompReturn struct { - Entries []CompEntry - HasMore bool -} - var SimpleCompGenFns map[string]SimpleCompGenFnType func strWithCursor(str string, pos int) string { @@ -229,7 +332,6 @@ func ParseCompPoint(fullCmdStr string, pos int) (*CompPoint, error) { } } } - rtnPoint.dump() return &rtnPoint, nil } @@ -253,3 +355,32 @@ func splitCompWord(p *CompPoint) { func DoCompGen(ctx context.Context, point CompPoint, rptr sstore.RemotePtrType, state *packet.ShellState) (*CompReturn, error) { return nil, nil } + +func SortCompReturnEntries(c *CompReturn) { + sort.Slice(c.Entries, func(i int, j int) bool { + e1 := c.Entries[i] + e2 := c.Entries[j] + if e1.Word < e2.Word { + return true + } + if e1.Word == e2.Word && e1.IsMetaCmd && !e2.IsMetaCmd { + return true + } + return false + }) +} + +func CombineCompReturn(c1 *CompReturn, c2 *CompReturn) *CompReturn { + if c1 == nil { + return c2 + } + if c2 == nil { + return c1 + } + var rtn CompReturn + rtn.HasMore = c1.HasMore || c2.HasMore + rtn.Entries = append([]CompEntry{}, c1.Entries...) + rtn.Entries = append(rtn.Entries, c2.Entries...) + SortCompReturnEntries(&rtn) + return &rtn +} diff --git a/pkg/comp/simplecomp.go b/pkg/comp/simplecomp.go index b34a2e7b..ddae3f46 100644 --- a/pkg/comp/simplecomp.go +++ b/pkg/comp/simplecomp.go @@ -3,12 +3,47 @@ package comp import ( "context" "fmt" + "sync" "github.com/google/uuid" "github.com/scripthaus-dev/mshell/pkg/packet" "github.com/scripthaus-dev/sh2-server/pkg/remote" + "github.com/scripthaus-dev/sh2-server/pkg/utilfn" ) +var globalLock = &sync.Mutex{} +var simpleCompMap = map[string]SimpleCompGenFnType{ + "file": simpleCompFile, + "directory": simpleCompDir, + "variable": simpleCompVar, + "command": simpleCompCommand, +} + +type SimpleCompGenFnType = func(ctx context.Context, prefix string, compCtx CompContext, args []interface{}) (*CompReturn, error) + +func RegisterSimpleCompFn(compType string, fn SimpleCompGenFnType) { + globalLock.Lock() + defer globalLock.Unlock() + if _, ok := simpleCompMap[compType]; ok { + panic(fmt.Sprintf("simpleCompFn %q already registered", compType)) + } + simpleCompMap[compType] = fn +} + +func getSimpleCompFn(compType string) SimpleCompGenFnType { + globalLock.Lock() + defer globalLock.Unlock() + return simpleCompMap[compType] +} + +func DoSimpleComp(ctx context.Context, compType string, prefix string, compCtx CompContext, args []interface{}) (*CompReturn, error) { + compFn := getSimpleCompFn(compType) + if compFn == nil { + return nil, fmt.Errorf("no simple comp fn for %q", compType) + } + return compFn(ctx, prefix, compCtx, args) +} + func compsToCompReturn(comps []string, hasMore bool) *CompReturn { var rtn CompReturn rtn.HasMore = hasMore @@ -18,13 +53,13 @@ func compsToCompReturn(comps []string, hasMore bool) *CompReturn { return &rtn } -func doCompGen(ctx context.Context, compCtx CompContext, prefix string, compType string, forDisplay bool) (*CompReturn, error) { +func doCompGen(ctx context.Context, prefix string, compType string, compCtx CompContext) (*CompReturn, error) { if !packet.IsValidCompGenType(compType) { - return nil, false, fmt.Errorf("/compgen invalid type '%s'", compType) + return nil, fmt.Errorf("/compgen invalid type '%s'", compType) } msh := remote.GetRemoteById(compCtx.RemotePtr.RemoteId) if msh == nil { - return nil, false, fmt.Errorf("invalid remote '%s', not found", compCtx.RemotePtr) + return nil, fmt.Errorf("invalid remote '%s', not found", compCtx.RemotePtr) } cgPacket := packet.MakeCompGenPacket() cgPacket.ReqId = uuid.New().String() @@ -32,23 +67,29 @@ func doCompGen(ctx context.Context, compCtx CompContext, prefix string, compType cgPacket.Prefix = prefix cgPacket.Cwd = compCtx.State.Cwd resp, err := msh.PacketRpc(ctx, cgPacket) - if err != nil { - return nil, false, err - } - if err = resp.Err(); err != nil { - return nil, false, err - } - comps := getStrArr(resp.Data, "comps") - hasMore := getBool(resp.Data, "hasmore") - return compsToCompReturn(conmps, hasMore), nil -} - -func SimpleCompFile(ctx context.Context, point SimpleCompPoint, compCtx CompContext, args []interface{}) (*CompReturn, error) { - pword := point.Words[p.CompWord] - prefix := "" - crtn, err := doCompGen(ctx, prefix, "file") if err != nil { return nil, err } - return crtn, nil + if err = resp.Err(); err != nil { + return nil, err + } + comps := utilfn.GetStrArr(resp.Data, "comps") + hasMore := utilfn.GetBool(resp.Data, "hasmore") + return compsToCompReturn(comps, hasMore), nil +} + +func simpleCompFile(ctx context.Context, prefix string, compCtx CompContext, args []interface{}) (*CompReturn, error) { + return doCompGen(ctx, prefix, "file", compCtx) +} + +func simpleCompDir(ctx context.Context, prefix string, compCtx CompContext, args []interface{}) (*CompReturn, error) { + return doCompGen(ctx, prefix, "directory", compCtx) +} + +func simpleCompVar(ctx context.Context, prefix string, compCtx CompContext, args []interface{}) (*CompReturn, error) { + return doCompGen(ctx, prefix, "variable", compCtx) +} + +func simpleCompCommand(ctx context.Context, prefix string, compCtx CompContext, args []interface{}) (*CompReturn, error) { + return doCompGen(ctx, prefix, "command", compCtx) } diff --git a/pkg/utilfn/utilfn.go b/pkg/utilfn/utilfn.go new file mode 100644 index 00000000..a7fc04de --- /dev/null +++ b/pkg/utilfn/utilfn.go @@ -0,0 +1,76 @@ +package utilfn + +import ( + "strings" + + "github.com/alessio/shellescape" +) + +func GetStrArr(v interface{}, field string) []string { + if v == nil { + return nil + } + m, ok := v.(map[string]interface{}) + if !ok { + return nil + } + fieldVal := m[field] + if fieldVal == nil { + return nil + } + iarr, ok := fieldVal.([]interface{}) + if !ok { + return nil + } + var sarr []string + for _, iv := range iarr { + if sv, ok := iv.(string); ok { + sarr = append(sarr, sv) + } + } + return sarr +} + +func GetBool(v interface{}, field string) bool { + if v == nil { + return false + } + m, ok := v.(map[string]interface{}) + if !ok { + return false + } + fieldVal := m[field] + if fieldVal == nil { + return false + } + bval, ok := fieldVal.(bool) + if !ok { + return false + } + return bval +} + +// minimum maxlen=6 +func ShellQuote(val string, forceQuote bool, maxLen int) string { + if maxLen < 6 { + maxLen = 6 + } + rtn := shellescape.Quote(val) + if strings.HasPrefix(rtn, "\"") || strings.HasPrefix(rtn, "'") { + if len(rtn) > maxLen { + return rtn[0:maxLen-4] + "..." + rtn[0:1] + } + return rtn + } + if forceQuote { + if len(rtn) > maxLen-2 { + return "\"" + rtn[0:maxLen-5] + "...\"" + } + return "\"" + rtn + "\"" + } else { + if len(rtn) > maxLen { + return rtn[0:maxLen-3] + "..." + } + return rtn + } +}