Skip to content

Commit

Permalink
move kbfs path parsing to Go and use service decoration (keybase#19148)
Browse files Browse the repository at this point in the history
* wip

* rewrite regexp

* add deeplink and platform paths

* use service decoration for kbfs paths

* story

* just parse when decorating

* review feedback from strib

* gofmt
  • Loading branch information
songgao authored Aug 31, 2019
1 parent 6b7323e commit 45e0883
Show file tree
Hide file tree
Showing 16 changed files with 482 additions and 831 deletions.
192 changes: 192 additions & 0 deletions go/chat/utils/kbfs_path_utils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package utils

import (
"context"
"net/url"
"regexp"
"strconv"
"strings"

"github.com/keybase/client/go/kbun"
"github.com/keybase/client/go/libkb"
"github.com/keybase/client/go/protocol/chat1"
"github.com/keybase/client/go/protocol/keybase1"
)

const localUsernameRE = "(?:[a-zA-A0-0_]+-?)+"

var kbfsPathOuterRegExp = func() *regexp.Regexp {
const slashDivided = `(?:(?:/keybase|/Volumes/Keybase\\ \(` + kbun.UsernameRE + `\)|/Volumes/Keybase)((?:\\ |\S)*))`
const slashDividedQuoted = `"(?:(?:/keybase|/Volumes/Keybase \(` + localUsernameRE + `\)|/Volumes/Keybase)(.*))"`
const windows = `(?:(?:K:|k:)(\\\S*))` // don't support escape on windows
// TODO if in the future we want to support custom mount points we can
// probably tap into Env() to get it.
const windowsQuoted = `"(?:(?:K:|k:)(\\.*))"`
const deeplink = `(?:(?:keybase:/)((?:\S)*))`
return regexp.MustCompile(`(?:[^\w"]|^)(` + slashDivided + "|" + slashDividedQuoted + "|" + windows + "|" + windowsQuoted + "|" + deeplink + `)`)
}()

var kbfsPathInnerRegExp = func() *regexp.Regexp {
const socialAssertion = `[-_a-zA-Z0-9.]+@[a-zA-Z.]+`
const user = `(?:(?:` + kbun.UsernameRE + `)|(?:` + socialAssertion + `))`
const usernames = user + `(?:,` + user + `)*`
const teamName = kbun.UsernameRE + `(?:\.` + kbun.UsernameRE + `)*`
const tlfType = "/(?:private|public|team)$"
// TODO support name suffix e.g. conflict
const tlf = "/(?:(?:private|public)/" + usernames + "(?:#" + usernames + ")?|team/" + teamName + `)(?:/|$)`
const specialFiles = "/(?:.kbfs_.+)"
return regexp.MustCompile(`^(?:(?:` + tlf + `)|(?:` + tlfType + `)|(?:` + specialFiles + `))`)
}()

type outerMatch struct {
matchStartIndex int
wholeMatch string
afterKeybase string
}

func (m *outerMatch) isKBFSPath() bool {
return m.matchStartIndex >= 0 && (len(m.afterKeybase) == 0 || kbfsPathInnerRegExp.MatchString(m.afterKeybase))
}

func (m *outerMatch) standardPath() string {
return "/keybase" + m.afterKeybase
}

func (m *outerMatch) deeplinkPath() string {
if len(m.afterKeybase) == 0 {
return ""
}
var segments []string
for _, segment := range strings.Split(m.afterKeybase, "/") {
segments = append(segments, url.PathEscape(segment))
}
return "keybase:/" + strings.Join(segments, "/")
}

func (m *outerMatch) afterMountPath(backslash bool) string {
afterMount := m.afterKeybase
if len(afterMount) == 0 {
afterMount = "/"
}
if backslash {
return strings.ReplaceAll(afterMount, "/", `\`)
}
return afterMount
}

func matchKBFSPathOuter(body string) (outerMatches []outerMatch) {
res := kbfsPathOuterRegExp.FindAllStringSubmatchIndex(body, -1)
for _, indices := range res {
// 2:3 match
// 4:5 slash-divided inside /keybase
// 6:7 quoted slash-divided inside /keybase
// 8:9 windows inside /keybase
// 10:11 quoted windows inside /keybase
// 12:13 deeplink after "keybase:/"
if len(indices) != 14 {
panic("bad regexp: len(indices): " + strconv.Itoa(len(indices)))
}
switch {
case indices[4] > 0:
outerMatches = append(outerMatches, outerMatch{
matchStartIndex: indices[2],
wholeMatch: body[indices[2]:indices[3]],
afterKeybase: strings.TrimRight(
strings.ReplaceAll(
strings.ReplaceAll(
body[indices[4]:indices[5]],
`\\`,
`\`,
),
`\ `,
` `,
),
"/",
),
})
case indices[6] > 0:
outerMatches = append(outerMatches, outerMatch{
matchStartIndex: indices[2],
wholeMatch: body[indices[2]:indices[3]],
afterKeybase: strings.TrimRight(
body[indices[6]:indices[7]],
"/",
),
})
case indices[8] > 0:
outerMatches = append(outerMatches, outerMatch{
matchStartIndex: indices[2],
wholeMatch: body[indices[2]:indices[3]],
afterKeybase: strings.TrimRight(
strings.ReplaceAll(
body[indices[8]:indices[9]],
`\`,
`/`,
),
"/",
),
})
case indices[10] > 0:
outerMatches = append(outerMatches, outerMatch{
matchStartIndex: indices[2],
wholeMatch: body[indices[2]:indices[3]],
afterKeybase: strings.TrimRight(
strings.ReplaceAll(
body[indices[10]:indices[11]],
`\`,
`/`,
),
"/",
),
})
case indices[12] > 0:
unescaped, err := url.PathUnescape(body[indices[12]:indices[13]])
if err != nil {
continue
}
outerMatches = append(outerMatches, outerMatch{
matchStartIndex: indices[2],
wholeMatch: body[indices[2]:indices[3]],
afterKeybase: strings.TrimRight(
unescaped,
"/",
),
})
}
}
return outerMatches
}

func ParseKBFSPaths(ctx context.Context, body string) (paths []chat1.KBFSPath) {
outerMatches := matchKBFSPathOuter(body)
for _, match := range outerMatches {
if match.isKBFSPath() {
var platformAfterMountPath string
if libkb.RuntimeGroup() == keybase1.RuntimeGroup_WINDOWSLIKE {
platformAfterMountPath = match.afterMountPath(true)
} else {
platformAfterMountPath = match.afterMountPath(false)
}
paths = append(paths,
chat1.KBFSPath{
StartIndex: match.matchStartIndex,
RawPath: match.wholeMatch,
StandardPath: match.standardPath(),
DeeplinkPath: match.deeplinkPath(),
PlatformAfterMountPath: platformAfterMountPath,
})
}
}
return paths
}

func DecorateWithKBFSPath(
ctx context.Context, body string, paths []chat1.KBFSPath) (
res string) {
var offset, added int
for _, path := range paths {
body, added = DecorateBody(ctx, body, path.StartIndex+offset, len(path.RawPath), chat1.NewUITextDecorationWithKbfspath(path))
offset += added
}
return body
}
101 changes: 101 additions & 0 deletions go/chat/utils/kbfs_path_utils_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package utils

import (
"testing"

"github.com/keybase/client/go/libkb"
"github.com/keybase/client/go/protocol/chat1"
"github.com/keybase/client/go/protocol/keybase1"
"github.com/stretchr/testify/require"
"golang.org/x/net/context"
)

func strPointer(str string) *string { return &str }
func makeKBFSPathForTest(rawPath string, standardPath *string) chat1.KBFSPath {
if standardPath == nil {
return chat1.KBFSPath{RawPath: rawPath, StandardPath: rawPath}
}
return chat1.KBFSPath{RawPath: rawPath, StandardPath: *standardPath}
}

var kbfsPathTests = map[string]chat1.KBFSPath{
"/keybase ha": makeKBFSPathForTest("/keybase", nil),
"/keybase/哟": {},
"before/keybase": {},
"之前/keybase": makeKBFSPathForTest("/keybase", nil),
"/keybase/public": makeKBFSPathForTest("/keybase/public", nil),
"/keybase/team": makeKBFSPathForTest("/keybase/team", nil),
"/keybase/private/": makeKBFSPathForTest("/keybase/private/", strPointer("/keybase/private")),
"/keybase/team/keybase": makeKBFSPathForTest("/keybase/team/keybase", nil),
"/keybase/team/keybase/blahblah": makeKBFSPathForTest("/keybase/team/keybase/blahblah", nil),
`/keybase/team/keybase/blah\ blah\ blah`: makeKBFSPathForTest(`/keybase/team/keybase/blah\ blah\ blah`, strPointer("/keybase/team/keybase/blah blah blah")),
`/keybase/team/keybase/blah\\blah\\blah`: makeKBFSPathForTest(`/keybase/team/keybase/blah\\blah\\blah`, strPointer(`/keybase/team/keybase/blah\blah\blah`)),
"/keybase/team/keybase/blahblah/": makeKBFSPathForTest("/keybase/team/keybase/blahblah/", strPointer("/keybase/team/keybase/blahblah")),
"/keybase/private/songgao/🍻": makeKBFSPathForTest("/keybase/private/songgao/🍻", nil),
"/keybase/private/songgao/🍻/🍹.png/": makeKBFSPathForTest("/keybase/private/songgao/🍻/🍹.png/", strPointer("/keybase/private/songgao/🍻/🍹.png")),
"/keybase/private/songgao/囧/yo": makeKBFSPathForTest("/keybase/private/songgao/囧/yo", nil),
"/keybase/team/keybase,blah": {},
"/keybase/team/keybase.blah": makeKBFSPathForTest("/keybase/team/keybase.blah", nil),
"/keybaseprivate": {},
"/keybaseprivate/team": {},
"/keybase/teamaa/keybase": {},
"/keybase/.kbfs_status": makeKBFSPathForTest("/keybase/.kbfs_status", nil),
"/foo": {},
"/keybase.": {},

"/keybase/private/songgao,strib#jzila/file": makeKBFSPathForTest("/keybase/private/songgao,strib#jzila/file", nil),
"/keybase/private/song-gao,strib#jzila/file": {},
"/keybase/private/songgao,strib#jzila,jakob223/file": makeKBFSPathForTest("/keybase/private/songgao,strib#jzila,jakob223/file", nil),
"/keybase/private/__songgao__@twitter,strib@github,jzila@reddit,jakob.weisbl.at@dns/file": makeKBFSPathForTest("/keybase/private/__songgao__@twitter,strib@github,jzila@reddit,jakob.weisbl.at@dns/file", nil),

"keybase://team/keybase/blahblah": makeKBFSPathForTest("keybase://team/keybase/blahblah", strPointer("/keybase/team/keybase/blahblah")),
"keybase://private/foo/blahblah": makeKBFSPathForTest("keybase://private/foo/blahblah", strPointer("/keybase/private/foo/blahblah")),
"keybase://public/foo/blahblah": makeKBFSPathForTest("keybase://public/foo/blahblah", strPointer("/keybase/public/foo/blahblah")),
"keybase://public/foo/blah%20blah": makeKBFSPathForTest("keybase://public/foo/blah%20blah", strPointer("/keybase/public/foo/blah blah")),
"keybase://chat/blah": {},

"/Volumes/Keybase/team/keybase/blahblah": makeKBFSPathForTest("/Volumes/Keybase/team/keybase/blahblah", strPointer("/keybase/team/keybase/blahblah")),
"/Volumes/Keybase/private/foo/blahblah": makeKBFSPathForTest("/Volumes/Keybase/private/foo/blahblah", strPointer("/keybase/private/foo/blahblah")),
"/Volumes/Keybase/public/foo/blahblah": makeKBFSPathForTest("/Volumes/Keybase/public/foo/blahblah", strPointer("/keybase/public/foo/blahblah")),
`/Volumes/Keybase\ (meatball)/team/keybase/blahblah`: makeKBFSPathForTest(`/Volumes/Keybase\ (meatball)/team/keybase/blahblah`, strPointer("/keybase/team/keybase/blahblah")),
`/Volumes/Keybase\ (meatball)/private/foo/blahblah`: makeKBFSPathForTest(`/Volumes/Keybase\ (meatball)/private/foo/blahblah`, strPointer("/keybase/private/foo/blahblah")),
`/Volumes/Keybase\ (meatball)/public/foo/blahblah`: makeKBFSPathForTest(`/Volumes/Keybase\ (meatball)/public/foo/blahblah`, strPointer("/keybase/public/foo/blahblah")),
`"/Volumes/Keybase (meatball)/public/foo/blahblah"`: makeKBFSPathForTest(`"/Volumes/Keybase (meatball)/public/foo/blahblah"`, strPointer("/keybase/public/foo/blahblah")),

`K:\team\keybase\blahblah`: makeKBFSPathForTest(`K:\team\keybase\blahblah`, strPointer("/keybase/team/keybase/blahblah")),
`K:\private\foo\blahblah`: makeKBFSPathForTest(`K:\private\foo\blahblah`, strPointer("/keybase/private/foo/blahblah")),
`k:\public\foo\blahblah`: makeKBFSPathForTest(`k:\public\foo\blahblah`, strPointer("/keybase/public/foo/blahblah")),
`K:\public\foo\blahblah lalala`: makeKBFSPathForTest(`K:\public\foo\blahblah`, strPointer("/keybase/public/foo/blahblah")),
`"K:\public\foo\blahblah lalala"`: makeKBFSPathForTest(`"K:\public\foo\blahblah lalala"`, strPointer("/keybase/public/foo/blahblah lalala")),
}

func TestParseKBFSPathMatches(t *testing.T) {
for input, expected := range kbfsPathTests {
paths := ParseKBFSPaths(context.Background(), input)
if len(expected.RawPath) > 0 {
require.Len(t, paths, 1, "error matching: %s", input)
require.Equal(t, expected.RawPath, paths[0].RawPath, "wrong RawPath %q", input)
require.Equal(t, expected.StandardPath, paths[0].StandardPath, "wrong RebasePath %q", input)
} else {
require.Len(t, paths, 0, "unexpected match: %s", input)
}
}
}

func TestParseKBFSPathDetailed(t *testing.T) {
for _, input := range []string{
`this is a kbfs path /keybase/team/keybase/blah\ blah\ blah`,
`this is a kbfs path "K:\team\keybase\blah blah blah"`,
} {
paths := ParseKBFSPaths(context.Background(), input)
require.Len(t, paths, 1, "input: %s", input)
require.Equal(t, 20, paths[0].StartIndex, "input: %s", input)
require.Equal(t, "/keybase/team/keybase/blah blah blah", paths[0].StandardPath, "input: %s", input)
require.Equal(t, "keybase://team/keybase/blah%20blah%20blah", paths[0].DeeplinkPath, "input: %s", input)
if libkb.RuntimeGroup() == keybase1.RuntimeGroup_WINDOWSLIKE {
require.Equal(t, `\team\keybase\blah blah blah`, paths[0].PlatformAfterMountPath, "input: %s", input)
} else {
require.Equal(t, "/team/keybase/blah blah blah", paths[0].PlatformAfterMountPath, "input: %s", input)
}
}
}
4 changes: 4 additions & 0 deletions go/chat/utils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -1501,6 +1501,10 @@ func PresentDecoratedTextBody(ctx context.Context, g *globals.Context, msg chat1
body = EscapeForDecorate(ctx, body)
body = EscapeShrugs(ctx, body)

// This needs to happen before (deep) links.
kbfsPaths := ParseKBFSPaths(ctx, body)
body = DecorateWithKBFSPath(ctx, body, kbfsPaths)

// Links
body = DecorateWithLinks(ctx, body)
// Payment decorations
Expand Down
7 changes: 5 additions & 2 deletions go/kbun/username.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import (
"strings"
)

// Underscores allowed, just not first or doubled.
var usernameRE = regexp.MustCompile(`^([a-zA-Z0-9]+_?)+$`)
// UsernameRE is the regex for matching usernames. Underscores are allowed,
// just not first or doubled.
const UsernameRE = `(?:[a-zA-Z0-9]+_?)+`

var usernameRE = regexp.MustCompile("^" + UsernameRE + "$")

// CheckUsername returns true if the given string can be a Keybase
// username.
Expand Down
Loading

0 comments on commit 45e0883

Please sign in to comment.