diff --git a/README.md b/README.md index 7a32a6c..0c4a6bd 100644 --- a/README.md +++ b/README.md @@ -339,27 +339,50 @@ notte profiles delete --profile-id # Delete a profile notte profiles sync # Copy your local browser login into a profile ``` -`profiles sync` reads the cookies from a local Firefox, Chrome, Brave, Edge, -Chromium, Arc or Helium profile and uploads them into a Notte profile, so remote -sessions start already logged in. The cookies are read from a copy of the -browser's own files on your machine (and, for the Chromium browsers, decrypted -with the key from your OS keychain); nothing is sent until you confirm, and -`--domain` limits the sync to the sites you name. - -```bash -notte profiles sync # pick a browser profile, create a Notte profile -notte profiles sync --domain github.com --domain mail.google.com # only these sites (and subdomains) -notte profiles sync --profile-id # refresh an existing profile after a re-login -notte profiles sync --browser brave --local-profile Work # choose the source without the prompt -``` - -Then start a session with it: `notte sessions start --profile-id `. Re-run -`sync` whenever the local login changes. Supported on macOS and Linux (Arc and -Helium on macOS only); for the -Chromium browsers the first run asks permission to read the browser key from your -OS keychain (Firefox stores cookies in the clear and needs no such permission). -Scoped syncs into an existing profile may want `--mode append` so other sites' -cookies are kept. +`profiles sync` copies cookies and localStorage from a local Firefox, Chrome, +Brave, Edge, Chromium, Arc or Helium profile into a Notte profile. This includes +logins stored only in localStorage, even when the site has no cookies. Browser +files are read through private temporary copies; encrypted cookies are decrypted +locally. Nothing is uploaded until you confirm (or pass `--yes`). `--domain` +restricts cookies and storage to the named hosts and their subdomains. + +```bash +notte profiles sync # choose a local browser profile +notte profiles sync --domain bsky.app # include storage-only logins +notte profiles sync --browser brave --local-profile Work # choose the source explicitly +notte profiles sync --profile-id --domain bsky.app # refresh an existing profile +notte profiles sync --cdp-url http://127.0.0.1:9222 --domain bsky.app +``` + +Start a remote session with `notte sessions start --profile-id `. Re-run sync +when a local login changes. Disk reading is supported on macOS and Linux (Arc +and Helium on macOS only). Chromium cookie decryption may ask for OS keychain +access. Close the source browser and retry if its files change during a copy. + +For live **sessionStorage**, use `--cdp-url` with a Chromium browser already +running with remote debugging enabled in a dedicated profile. Sign into the +site there and keep its tab open. Accepts a debugger HTTP origin or a **page** +WebSocket URL; it reads matching top-level tabs without navigating them. Tabs +with conflicting state for the same origin are rejected rather than combining +different accounts. Disk sync cannot capture in-memory sessionStorage and leaves +previously stored sessionStorage unchanged. + +Storage imports require an API deployment supporting `POST /profiles/{id}/state`. +Older deployments return an explicit unsupported-server error; the CLI never +silently drops localStorage to fall back to cookies. Scoped cookie-only syncs +also use the state API to preserve other sites. Unscoped cookie-only disk syncs +remain compatible with the existing cookies API. `--mode replace` replaces +captured state classes within `--domain` (or all sites without a scope). +Live sync replaces web storage only for captured origins, preserving saved +state for closed tabs. `--mode append` merges cookie identities and storage keys. +Avoid importing into a profile while a remote browser is saving +its state into that same profile. + +The snapshot limit is 8 MiB. Values are never printed in summaries or JSON +output. Unreadable or undecryptable selected state fails before upload. IndexedDB, +partitioned/container storage, browser extensions, passkeys and device-bound +credentials are not transferred. Some sites also bind sessions to an IP or +require a new sign-in; copying browser state cannot guarantee every site's login. ### Files diff --git a/go.mod b/go.mod index 533d642..b9557c8 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,14 @@ go 1.25.5 require ( github.com/99designs/keyring v1.2.2 + github.com/golang/snappy v0.0.4 + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 github.com/muesli/termenv v0.16.0 github.com/oapi-codegen/runtime v1.1.2 github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 + github.com/syndtr/goleveldb v1.0.0 golang.org/x/sys v0.47.0 golang.org/x/term v0.3.0 modernc.org/sqlite v1.59.0 @@ -21,7 +25,6 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/dvsekhvalnov/jose2go v1.7.0 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect diff --git a/go.sum b/go.sum index 65d9ce0..6542094 100644 --- a/go.sum +++ b/go.sum @@ -18,16 +18,25 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dvsekhvalnov/jose2go v1.7.0 h1:bnQc8+GMnidJZA8zc6lLEAb4xNrIqHwO+9TzqvtQZPo= github.com/dvsekhvalnov/jose2go v1.7.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= @@ -48,6 +57,11 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -67,20 +81,36 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= +github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/profile_state.go b/internal/api/profile_state.go new file mode 100644 index 0000000..67010b6 --- /dev/null +++ b/internal/api/profile_state.go @@ -0,0 +1,73 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// BrowserStateUploadRequest uses the cloud profile's existing browser-state +// format. Domain-scoped replace leaves unrelated cloud logins unchanged. +type BrowserStateUploadRequest struct { + Cookies []Cookie `json:"cookies"` + LocalStorage map[string]map[string]string `json:"local_storage"` + SessionStorage *map[string]map[string]string `json:"session_storage,omitempty"` + Domains []string `json:"domains,omitempty"` + Mode string `json:"mode"` + StorageScope string `json:"storage_scope,omitempty"` +} + +type BrowserStateUploadResponse struct { + Success bool `json:"success"` + CookiesCount int `json:"cookies_count"` + LocalStorageOrigins int `json:"local_storage_origins"` + SessionStorageOrigins int `json:"session_storage_origins"` +} + +// Validate checks the final wire representation before a destination is created. +func (state BrowserStateUploadRequest) Validate() error { + body, err := json.Marshal(state) + if err != nil { + return fmt.Errorf("cannot encode browser state") + } + if len(body) > 8*1024*1024 { + return fmt.Errorf("browser state exceeds the 8 MiB import limit") + } + return nil +} + +func (c *NotteClient) ProfileStateSetWithResponse(ctx context.Context, id string, state BrowserStateUploadRequest) (*BrowserStateUploadResponse, int, error) { + if err := state.Validate(); err != nil { + return nil, 0, err + } + body, _ := json.Marshal(state) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.BaseURL(), "/")+"/profiles/"+url.PathEscape(id)+"/state", bytes.NewReader(body)) + if err != nil { + return nil, 0, err + } + req.Header.Set("Content-Type", "application/json") + // Redirects must never forward a body containing browser credentials. + httpClient := *c.HTTPClient() + httpClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } + resp, err := httpClient.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("profile state import could not be confirmed; inspect the profile before retrying") + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed { + return nil, resp.StatusCode, fmt.Errorf("this API does not support browser-state import yet; update the server before syncing storage-based logins") + } + if resp.StatusCode != http.StatusOK { + return nil, resp.StatusCode, fmt.Errorf("profile state import failed (HTTP %d); browser values were not printed", resp.StatusCode) + } + var result BrowserStateUploadResponse + if err := json.NewDecoder(io.LimitReader(resp.Body, 65536)).Decode(&result); err != nil || !result.Success { + return nil, resp.StatusCode, fmt.Errorf("profile state import returned an invalid confirmation") + } + return &result, resp.StatusCode, nil +} diff --git a/internal/api/profile_state_test.go b/internal/api/profile_state_test.go new file mode 100644 index 0000000..e3ea5a6 --- /dev/null +++ b/internal/api/profile_state_test.go @@ -0,0 +1,72 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestImportProfileStorageOnly(t *testing.T) { + for _, live := range []bool{false, true} { + t.Run(fmt.Sprint(live), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer synthetic-key" { + t.Error("missing workspace authentication") + } + if r.URL.Path != "/profiles/notte-profile-0123456789abcdef/state" { + t.Error("wrong import route") + } + var body map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if _, ok := body["session_storage"]; ok != live { + t.Error("uncaptured session storage must be omitted; captured empty state must be explicit") + } + _, _ = w.Write([]byte(`{"success":true,"cookies_count":0,"local_storage_origins":1,"session_storage_origins":0}`)) + })) + defer server.Close() + client, err := NewClientWithURL("synthetic-key", server.URL, "") + if err != nil { + t.Fatal(err) + } + state := BrowserStateUploadRequest{Cookies: []Cookie{}, LocalStorage: map[string]map[string]string{"https://bsky.app": {"BSKY_STORAGE": "synthetic-private-token"}}, Mode: "replace"} + if live { + sessions := map[string]map[string]string{} + state.SessionStorage = &sessions + } + response, status, err := client.ProfileStateSetWithResponse(context.Background(), "notte-profile-0123456789abcdef", state) + if err != nil || status != 200 || response.LocalStorageOrigins != 1 { + t.Fatalf("import failed: %v", err) + } + }) + } +} + +func TestImportProfileStateNeverLeaksBodyOrFollowsRedirects(t *testing.T) { + for _, code := range []int{307, 308, 400, 401, 403, 404, 405, 422, 500} { + t.Run(fmt.Sprint(code), func(t *testing.T) { + forwarded := false + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { forwarded = true; w.WriteHeader(200) })) + defer target.Close() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", target.URL) + w.WriteHeader(code) + _, _ = w.Write([]byte("synthetic-private-token")) + })) + defer server.Close() + client, err := NewClientWithURL("synthetic-key", server.URL, "") + if err != nil { + t.Fatal(err) + } + _, _, err = client.ProfileStateSetWithResponse(context.Background(), "profile", BrowserStateUploadRequest{LocalStorage: map[string]map[string]string{"https://bsky.app": {"token": "synthetic-private-token"}}}) + if err == nil || strings.Contains(err.Error(), "synthetic-private-token") || forwarded { + t.Fatal("import failure leaked or forwarded browser credentials") + } + }) + } +} diff --git a/internal/browser/cdp_state.go b/internal/browser/cdp_state.go new file mode 100644 index 0000000..89ad910 --- /dev/null +++ b/internal/browser/cdp_state.go @@ -0,0 +1,264 @@ +package browser + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "net/url" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/gorilla/websocket" +) + +type cdpConnection struct { + conn *websocket.Conn + id int +} + +func (c *cdpConnection) call(method string, params any, result any) error { + c.id++ + if err := c.conn.WriteJSON(map[string]any{"id": c.id, "method": method, "params": params}); err != nil { + return fmt.Errorf("could not send browser snapshot request") + } + for { + var message struct { + ID int `json:"id"` + Result json.RawMessage `json:"result"` + Error *struct { + Code int `json:"code"` + } `json:"error"` + } + if err := c.conn.ReadJSON(&message); err != nil { + return fmt.Errorf("browser disconnected or snapshot timed out; nothing was uploaded") + } + if message.ID != c.id { + continue + } + if message.Error != nil { + return fmt.Errorf("browser refused snapshot operation %s", method) + } + if err := json.Unmarshal(message.Result, result); err != nil { + return fmt.Errorf("invalid browser snapshot response") + } + return nil + } +} + +const readWebStorage = `(() => JSON.stringify({origin:location.origin,local:Object.fromEntries(Object.entries(localStorage)),session:Object.fromEntries(Object.entries(sessionStorage))}))()` + +// ReadCDPState reads live first-party contexts without navigating or modifying +// pages. Distinct tab sessions for one origin cannot fit the cloud profile +// schema: conflicting values fail rather than silently selecting an account. +func ReadCDPState(ctx context.Context, endpoint string, domains []string) (State, error) { + state := State{LocalStorage: map[string]map[string]string{}, SessionStorage: map[string]map[string]string{}} + u, err := url.Parse(endpoint) + if err != nil || u.User != nil { + return State{}, fmt.Errorf("invalid --cdp-url") + } + var targets []struct { + Type string `json:"type"` + URL string `json:"url"` + WebSocket string `json:"webSocketDebuggerUrl"` + } + if u.Scheme == "ws" || u.Scheme == "wss" { + targets = append(targets, struct { + Type string `json:"type"` + URL string `json:"url"` + WebSocket string `json:"webSocketDebuggerUrl"` + }{Type: "page", WebSocket: endpoint}) + } else { + if (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.RawQuery != "" || u.Fragment != "" || u.Path != "" && u.Path != "/" { + return State{}, fmt.Errorf("use a CDP HTTP origin or page WebSocket URL") + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(endpoint, "/")+"/json/list", nil) + if err != nil { + return State{}, err + } + client := &http.Client{Timeout: 15 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} + response, err := client.Do(request) + if err != nil { + return State{}, fmt.Errorf("cannot connect to browser debugger; enable remote debugging in a dedicated browser profile") + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode != http.StatusOK { + return State{}, fmt.Errorf("browser debugger did not return a target list") + } + if err := json.NewDecoder(http.MaxBytesReader(nil, response.Body, MaxStateBytes)).Decode(&targets); err != nil { + return State{}, fmt.Errorf("invalid browser target list") + } + } + seenCookies := map[string]Cookie{} + pages := 0 + for _, target := range targets { + if target.Type != "page" || target.WebSocket == "" { + continue + } + if target.URL != "" { + if _, ok := storageOrigin(target.URL, domains); !ok { + // Target URLs include paths, but storageOrigin deliberately accepts + // origins only. Normalize the URL before selecting the page. + pageURL, e := url.Parse(target.URL) + if e != nil { + continue + } + pageURL.Path = "" + pageURL.RawQuery = "" + pageURL.Fragment = "" + pageURL.RawPath = "" + if _, ok = storageOrigin(pageURL.String(), domains); !ok { + continue + } + } + } + pages++ + if err := readTarget(ctx, target.WebSocket, domains, &state, seenCookies); err != nil { + return State{}, err + } + } + if pages == 0 { + return State{}, fmt.Errorf("no open HTTP(S) page matches --domain; open the signed-in site and retry") + } + for _, cookie := range seenCookies { + state.Cookies = append(state.Cookies, cookie) + } + return state, checkStateSize(state) +} + +func readTarget(ctx context.Context, endpoint string, domains []string, state *State, cookies map[string]Cookie) error { + conn, _, err := websocket.DefaultDialer.DialContext(ctx, endpoint, nil) + if err != nil { + return fmt.Errorf("cannot connect to browser page debugger") + } + defer func() { _ = conn.Close() }() + stopCancellation := context.AfterFunc(ctx, func() { _ = conn.Close() }) + defer stopCancellation() + deadline := time.Now().Add(30 * time.Second) + if d, ok := ctx.Deadline(); ok && d.Before(deadline) { + deadline = d + } + if err := conn.SetReadDeadline(deadline); err != nil { + return err + } + if err := conn.SetWriteDeadline(deadline); err != nil { + return err + } + conn.SetReadLimit(MaxStateBytes) + c := cdpConnection{conn: conn} + var cookieResult struct { + Cookies []struct { + Name string `json:"name"` + Value string `json:"value"` + Domain string `json:"domain"` + Path string `json:"path"` + Secure bool `json:"secure"` + HTTPOnly bool `json:"httpOnly"` + SameSite string `json:"sameSite"` + Expires float64 `json:"expires"` + Session bool `json:"session"` + PartitionKey json.RawMessage `json:"partitionKey"` + } `json:"cookies"` + } + if err := c.call("Network.getAllCookies", map[string]any{}, &cookieResult); err != nil { + return err + } + for _, raw := range cookieResult.Cookies { + if len(raw.PartitionKey) > 0 && string(raw.PartitionKey) != "null" { + continue + } + if !domainMatches(raw.Domain, normalizeDomains(domains)) { + continue + } + if !raw.Session && raw.Expires > 0 && raw.Expires <= float64(time.Now().Unix()) { + continue + } + cookie := Cookie{Name: raw.Name, Value: raw.Value, Domain: raw.Domain, Path: raw.Path, Secure: raw.Secure, HTTPOnly: raw.HTTPOnly, SameSite: raw.SameSite, Expires: raw.Expires, Session: raw.Session} + key := raw.Domain + "\x00" + raw.Path + "\x00" + raw.Name + if prior, ok := cookies[key]; ok && prior != cookie { + return fmt.Errorf("open browser contexts have conflicting cookies; select a single account and retry") + } + cookies[key] = cookie + } + var evaluated struct { + Result struct { + Value string `json:"value"` + } `json:"result"` + Exception json.RawMessage `json:"exceptionDetails"` + } + if err := c.call("Runtime.evaluate", map[string]any{"expression": readWebStorage, "returnByValue": true}, &evaluated); err != nil { + return err + } + if len(evaluated.Exception) > 0 { + return fmt.Errorf("browser storage is unavailable on a selected page") + } + var snapshot struct { + Origin string `json:"origin"` + Local map[string]string `json:"local"` + Session map[string]string `json:"session"` + } + if !validJSONUnicode(evaluated.Result.Value) { + return fmt.Errorf("live storage contains unsupported Unicode; nothing was uploaded") + } + if err := json.Unmarshal([]byte(evaluated.Result.Value), &snapshot); err != nil { + return fmt.Errorf("invalid browser storage snapshot") + } + origin, allowed := storageOrigin(snapshot.Origin, domains) + if !allowed { + return fmt.Errorf("selected page changed origin during sync; retry") + } + for _, item := range []struct { + target map[string]map[string]string + source map[string]string + }{{state.LocalStorage, snapshot.Local}, {state.SessionStorage, snapshot.Session}} { + if prior, ok := item.target[origin]; ok && !maps.Equal(prior, item.source) { + return fmt.Errorf("open tabs have conflicting storage for one origin; select a single account/tab and retry") + } + item.target[origin] = item.source + } + return nil +} + +// encoding/json replaces unpaired UTF-16 surrogates instead of returning an +// error. Reject them in the original JSON strings before that loses information. +func validJSONUnicode(raw string) bool { + if !utf8.ValidString(raw) { + return false + } + inString := false + for i := 0; i < len(raw); i++ { + if raw[i] == '"' { + inString = !inString + } else if inString && raw[i] == '\\' { + if i+1 >= len(raw) { + return false + } + if raw[i+1] != 'u' { + i++ + continue + } + if i+6 > len(raw) { + return false + } + unit, err := strconv.ParseUint(raw[i+2:i+6], 16, 16) + if err != nil || unit >= 0xdc00 && unit <= 0xdfff { + return false + } + if unit >= 0xd800 && unit <= 0xdbff { + if i+12 > len(raw) || raw[i+6:i+8] != `\u` { + return false + } + low, err := strconv.ParseUint(raw[i+8:i+12], 16, 16) + if err != nil || low < 0xdc00 || low > 0xdfff { + return false + } + i += 6 + } + i += 5 + } + } + return true +} diff --git a/internal/browser/cdp_state_test.go b/internal/browser/cdp_state_test.go new file mode 100644 index 0000000..6b329c8 --- /dev/null +++ b/internal/browser/cdp_state_test.go @@ -0,0 +1,208 @@ +package browser + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func cdpFixture(t *testing.T, sessions []string, fail bool, rawSnapshots ...string) *httptest.Server { + t.Helper() + var server *httptest.Server + upgrader := websocket.Upgrader{CheckOrigin: func(_ *http.Request) bool { return true }} + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/json/list" { + targets := []map[string]string{} + for i := range sessions { + targets = append(targets, map[string]string{"type": "page", "url": fmt.Sprintf("https://bsky.app/feed/%d", i), "webSocketDebuggerUrl": strings.Replace(server.URL, "http:", "ws:", 1) + fmt.Sprintf("/page/%d", i)}) + } + targets = append(targets, map[string]string{"type": "page", "url": "https://unrelated.test/account", "webSocketDebuggerUrl": "ws://127.0.0.1:1/not-allowed"}) + _ = json.NewEncoder(w).Encode(targets) + return + } + var index int + if _, err := fmt.Sscanf(r.URL.Path, "/page/%d", &index); err != nil { + t.Error(err) + return + } + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer func() { _ = conn.Close() }() + for { + var req struct { + ID int `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` + } + if err := conn.ReadJSON(&req); err != nil { + return + } + if err := conn.WriteJSON(map[string]any{"method": "Network.event", "params": map[string]any{}}); err != nil { + return + } + if fail { + _ = conn.WriteJSON(map[string]any{"id": req.ID, "error": map[string]any{"code": -1, "message": "synthetic-private-token"}}) + return + } + var result any + switch req.Method { + case "Network.getAllCookies": + result = map[string]any{"cookies": []map[string]any{{"name": "login", "value": "synthetic-cookie", "domain": "bsky.app", "path": "/", "session": true}, {"name": "other", "value": "unrelated-private", "domain": "unrelated.test", "path": "/"}, {"name": "partitioned", "value": "excluded", "domain": "bsky.app", "path": "/", "partitionKey": map[string]any{"topLevelSite": "https://other.test"}}}} + case "Runtime.evaluate": + if req.Params["expression"] != readWebStorage { + t.Error("snapshot must only read storage") + } + snapshot, _ := json.Marshal(map[string]any{"origin": "https://bsky.app", "local": map[string]string{"BSKY_STORAGE": "synthetic-local-login"}, "session": map[string]string{"tab": sessions[index]}}) + if len(rawSnapshots) > 0 { + snapshot = []byte(rawSnapshots[0]) + } + result = map[string]any{"result": map[string]any{"type": "string", "value": string(snapshot)}} + default: + t.Error("unexpected mutating CDP command", req.Method) + return + } + if err := conn.WriteJSON(map[string]any{"id": req.ID, "result": result}); err != nil { + return + } + } + })) + t.Cleanup(server.Close) + return server +} + +func TestLiveStateFiltersAndDeduplicatesWithoutMutatingPages(t *testing.T) { + s := cdpFixture(t, []string{"same-tab-state", "same-tab-state"}, false) + state, err := ReadCDPState(context.Background(), s.URL, []string{"bsky.app"}) + if err != nil { + t.Fatal(err) + } + if len(state.Cookies) != 1 || state.Cookies[0].Name != "login" || state.SessionStorage["https://bsky.app"]["tab"] != "same-tab-state" { + t.Fatal("incorrect cookie or storage scope") + } +} + +func TestLiveStateRefusesConflictingTabSessions(t *testing.T) { + s := cdpFixture(t, []string{"account-one", "account-two"}, false) + state, err := ReadCDPState(context.Background(), s.URL, []string{"bsky.app"}) + if err == nil || !strings.Contains(err.Error(), "conflicting") { + t.Fatal("ambiguous account state must not be silently selected") + } + if len(state.LocalStorage) != 0 { + t.Fatal("partial snapshot escaped after failure") + } +} + +func TestLiveStateFailureDoesNotExposeBrowserValues(t *testing.T) { + s := cdpFixture(t, []string{"state"}, true) + _, err := ReadCDPState(context.Background(), s.URL, []string{"bsky.app"}) + if err == nil || strings.Contains(err.Error(), "synthetic-private-token") { + t.Fatal("browser error leaked private data") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond) + defer cancel() + _, err = ReadCDPState(ctx, "http://127.0.0.1:1", nil) + if err == nil { + t.Fatal("unreachable browser accepted") + } +} + +func TestLiveStateRefusesInvalidEndpoints(t *testing.T) { + for _, endpoint := range []string{"file:///etc/passwd", "http://user:password@localhost:9222", "http://localhost:9222/?secret=x"} { + if _, err := ReadCDPState(context.Background(), endpoint, nil); err == nil { + t.Fatal("invalid endpoint accepted") + } + } +} + +func TestLiveStateCancellationInterruptsBlockedRead(t *testing.T) { + connected := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u := websocket.Upgrader{} + conn, err := u.Upgrade(w, r, nil) + if err != nil { + return + } + defer func() { _ = conn.Close() }() + close(connected) + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + })) + defer server.Close() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { _, err := ReadCDPState(ctx, strings.Replace(server.URL, "http:", "ws:", 1), nil); done <- err }() + <-connected + cancel() + select { + case err := <-done: + if err == nil { + t.Fatal("cancelled read succeeded") + } + case <-time.After(2 * time.Second): + t.Fatal("cancellation did not interrupt browser snapshot") + } +} + +func TestConcurrentLiveSnapshotsRemainIndependent(t *testing.T) { + s := cdpFixture(t, []string{"same"}, false) + var wait sync.WaitGroup + for range 25 { + wait.Go(func() { + state, err := ReadCDPState(context.Background(), s.URL, []string{"bsky.app"}) + if err != nil || state.LocalStorage["https://bsky.app"]["BSKY_STORAGE"] != "synthetic-local-login" { + t.Error("concurrent snapshot failed", err) + } + }) + } + wait.Wait() +} + +func TestLiveStorageUnicodeValidation(t *testing.T) { + for _, raw := range []string{ + `{"local":{"\ud800":"value"}}`, + `{"session":{"key":"\udfff"}}`, + `{"local":{"key":"\ud800x"}}`, + `{"local":{"key":"\ud800\u0041"}}`, + "{\"key\":\"\xff\"}", + } { + if validJSONUnicode(raw) { + t.Error("unsupported live storage string was accepted") + } + } + for _, raw := range []string{ + `{"key":"\ud83d\udd10"}`, + `{"key":"\\ud800"}`, + `{"key":"ζ—₯本θͺžπŸ”\u0000\""}`, + } { + if !validJSONUnicode(raw) { + t.Error("valid browser string was rejected") + } + } +} + +func TestLiveStateRefusesUnsupportedUnicodeBeforeUpload(t *testing.T) { + for _, snapshot := range []string{ + `{"origin":"https://bsky.app","local":{"\ud800":"value"},"session":{}}`, + `{"origin":"https://bsky.app","local":{},"session":{"key":"\udfff"}}`, + } { + s := cdpFixture(t, []string{"state"}, false, snapshot) + state, err := ReadCDPState(context.Background(), s.URL, []string{"bsky.app"}) + if err == nil || !strings.Contains(err.Error(), "Unicode") || len(state.Cookies) > 0 || len(state.LocalStorage) > 0 { + t.Fatal("unsupported live strings must abort the entire snapshot", err) + } + } +} diff --git a/internal/browser/cookies.go b/internal/browser/cookies.go index e54453b..317c899 100644 --- a/internal/browser/cookies.go +++ b/internal/browser/cookies.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "strings" + "time" _ "modernc.org/sqlite" // pure-Go SQLite driver, no cgo ) @@ -53,16 +54,16 @@ func (p Profile) ReadCookies(domains []string) (ReadResult, error) { if p.Browser.kind == firefoxKind { return p.readFirefoxCookies(domains) } - keys, err := p.Browser.keys() - if err != nil { - return ReadResult{}, err - } - return p.readCookies(keys, domains) + return p.readCookiesWithKeys(p.Browser.keys, domains) } // readCookies is the key-independent core, split out so it can be exercised in // tests with a supplied keyset instead of the OS keystore. func (p Profile) readCookies(keys keyset, domains []string) (ReadResult, error) { + return p.readCookiesWithKeys(func() (keyset, error) { return keys, nil }, domains) +} + +func (p Profile) readCookiesWithKeys(loadKeys func() (keyset, error), domains []string) (ReadResult, error) { dbPath := p.cookieDBPath() if dbPath == "" { return ReadResult{}, fmt.Errorf("no cookie database found for profile %q", p.Name) @@ -97,6 +98,8 @@ func (p Profile) readCookies(keys keyset, domains []string) (ReadResult, error) defer func() { _ = rows.Close() }() result := ReadResult{DBVersion: version} + var keys keyset + keysLoaded := false for rows.Next() { var ( host, name, plainValue, path string @@ -121,6 +124,17 @@ func (p Profile) readCookies(keys keyset, domains []string) (ReadResult, error) if !domainMatches(host, filters) { continue } + if hasExpires != 0 && expiresUTC != 0 && float64(expiresUTC)/1_000_000-chromeEpochOffset <= float64(time.Now().Unix()) { + continue + } + if len(encrypted) > 0 && !keysLoaded { + var err error + keys, err = loadKeys() + if err != nil { + return ReadResult{}, err + } + keysLoaded = true + } value, err := decryptValue(keys, encrypted, plainValue, version) if err != nil { @@ -251,8 +265,7 @@ func decryptValue(keys keyset, encrypted []byte, plain string, version int) (str case "v11": key = keys.v11 default: - // No known version tag: an unencrypted legacy value. - return plain, nil + return "", fmt.Errorf("unsupported encrypted cookie version") } if len(key) == 0 { return "", fmt.Errorf("no decryption key available for %s cookies", prefix) @@ -280,7 +293,10 @@ func decryptValue(keys keyset, encrypted []byte, plain string, version int) (str } // Chrome >= 130 (schema version 24+) prepends the SHA-256 of the cookie's // eTLD+1 to the plaintext. Drop it to recover the real value. - if version >= 24 && len(decrypted) >= 32 { + if version >= 24 { + if len(decrypted) < 32 { + return "", fmt.Errorf("encrypted cookie is missing its domain binding") + } decrypted = decrypted[32:] } return string(decrypted), nil @@ -294,6 +310,11 @@ func stripPKCS7(b []byte) ([]byte, error) { if pad == 0 || pad > aes.BlockSize || pad > len(b) { return nil, fmt.Errorf("invalid padding") } + for _, value := range b[len(b)-pad:] { + if int(value) != pad { + return nil, fmt.Errorf("invalid padding") + } + } return b[:len(b)-pad], nil } diff --git a/internal/browser/cookies_test.go b/internal/browser/cookies_test.go index 0713e41..a9ee801 100644 --- a/internal/browser/cookies_test.go +++ b/internal/browser/cookies_test.go @@ -138,6 +138,48 @@ func TestReadCookiesRoundTrip(t *testing.T) { } } +func TestUnselectedEncryptedCookiesNeverReadKeychain(t *testing.T) { + p := buildCookieDB(t, deriveTestKey(), 24) + called := false + state, err := p.readCookiesWithKeys(func() (keyset, error) { + called = true + return keyset{}, fmt.Errorf("keychain must not be accessed") + }, []string{"plain.com"}) + if err != nil || called || len(state.Cookies) != 1 { + t.Fatal("unselected encrypted cookies triggered keychain access", err) + } +} + +func TestExpiredCookiesAreSkippedBeforeDecryption(t *testing.T) { + p := buildCookieDB(t, deriveTestKey(), 24) + db, err := sql.Open("sqlite", p.cookieDBPath()) + if err != nil { + t.Fatal(err) + } + defer func() { _ = db.Close() }() + if _, err := db.Exec(`UPDATE cookies SET has_expires=1, expires_utc=?`, int64((chromeEpochOffset+1)*1_000_000)); err != nil { + t.Fatal(err) + } + result, err := p.readCookiesWithKeys(func() (keyset, error) { t.Error("expired cookie read the keychain"); return keyset{}, nil }, nil) + if err != nil || len(result.Cookies) != 0 { + t.Fatal("expired cookies were imported", err) + } +} + +func TestUnsupportedCookieEncryptionAndInvalidPaddingFail(t *testing.T) { + if _, err := decryptValue(keyset{}, []byte("v20unsupported-device-bound"), "", 24); err == nil { + t.Fatal("unsupported encryption accepted") + } + for _, raw := range [][]byte{{1, 2}, {0}, {17}, nil} { + if _, err := stripPKCS7(raw); err == nil { + t.Fatal("invalid padding accepted") + } + } + if _, err := decryptValue(keyset{v10: deriveTestKey()}, encryptV10(t, deriveTestKey(), "short", 0), "", 24); err == nil { + t.Fatal("missing domain binding accepted") + } +} + func TestReadCookiesDomainFilter(t *testing.T) { key := deriveTestKey() profile := buildCookieDB(t, key, 24) diff --git a/internal/browser/storage.go b/internal/browser/storage.go new file mode 100644 index 0000000..777367b --- /dev/null +++ b/internal/browser/storage.go @@ -0,0 +1,391 @@ +package browser + +import ( + "bytes" + "database/sql" + "encoding/binary" + "encoding/json" + "fmt" + "net/netip" + "net/url" + "os" + "path/filepath" + "strings" + "unicode/utf16" + "unicode/utf8" + + "github.com/golang/snappy" + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/opt" +) + +const MaxStateBytes = 8 * 1024 * 1024 + +// ValidateDomains rejects malformed explicit scopes before any browser read. +// A typo must never turn a scoped sync into an unfiltered credential upload. +func ValidateDomains(domains []string) error { + for _, value := range domains { + host := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(value), ".")) + if _, err := netip.ParseAddr(host); err == nil { + continue + } + if host == "" || len(host) > 253 { + return fmt.Errorf("--domain requires a hostname or IP address without a URL, path or port") + } + for _, label := range strings.Split(host, ".") { + if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return fmt.Errorf("--domain requires a valid hostname or IP address") + } + for _, r := range label { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { + return fmt.Errorf("--domain requires a hostname or IP address without a URL, path or port") + } + } + } + } + return nil +} + +// State is the same origin-scoped snapshot saved by a cloud browser. Cookies +// are mapped to the API shape by the command layer, never printed here. +type State struct { + Cookies []Cookie + LocalStorage map[string]map[string]string + SessionStorage map[string]map[string]string + DecryptFailures int +} + +// ReadState reads a private copy of on-disk state. In-memory sessionStorage is +// available only through ReadCDPState; it is never guessed from stale tab files. +func (p Profile) ReadState(domains []string) (State, error) { + state := State{LocalStorage: map[string]map[string]string{}, SessionStorage: map[string]map[string]string{}} + var err error + if p.Browser.kind == firefoxKind { + state.LocalStorage, err = p.readFirefoxStorage(domains) + } else { + state.LocalStorage, err = p.readChromiumStorage(domains) + } + if err != nil { + return State{}, err + } + cookiePath := p.cookieDBPath() + if p.Browser.kind == firefoxKind { + cookiePath = filepath.Join(p.path, "cookies.sqlite") + } else { + for _, candidate := range []string{filepath.Join(p.path, "Network", "Cookies"), filepath.Join(p.path, "Cookies")} { + if _, err := os.Stat(candidate); err != nil && !os.IsNotExist(err) { + return State{}, fmt.Errorf("could not inspect cookie database; nothing was uploaded") + } + } + } + if _, err := os.Stat(cookiePath); err == nil && cookiePath != "" { + result, err := p.ReadCookies(domains) + if err != nil { + return State{}, err + } + state.Cookies, state.DecryptFailures = result.Cookies, result.DecryptFailures + } else if err != nil && !os.IsNotExist(err) { + return State{}, fmt.Errorf("could not read cookie database") + } + if state.DecryptFailures > 0 { + return State{}, fmt.Errorf("%d selected cookies could not be decrypted; unlock the browser keychain or use --cdp-url; nothing was uploaded", state.DecryptFailures) + } + return state, checkStateSize(state) +} + +func checkStateSize(state State) error { + raw, err := json.Marshal(state) + if err != nil || len(raw) > MaxStateBytes { + return fmt.Errorf("browser state exceeds the 8 MiB sync limit") + } + return nil +} + +func storageOrigin(raw string, domains []string) (string, bool) { + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") { + return "", false + } + if !domainMatches(u.Hostname(), normalizeDomains(domains)) { + return "", false + } + u.Host = strings.ToLower(u.Host) + u.Path = "" + return u.String(), true +} + +// Chromium stores keys as _\x00. A plain +// first-party StorageKey is its HTTP(S) origin. Partitioned StorageKeys carry +// additional separators and are excluded rather than flattened into logins. +func (p Profile) readChromiumStorage(domains []string) (map[string]map[string]string, error) { + out := map[string]map[string]string{} + source := filepath.Join(p.path, "Local Storage", "leveldb") + if _, err := os.Stat(source); os.IsNotExist(err) { + return out, nil + } else if err != nil { + return nil, fmt.Errorf("could not inspect local storage") + } + tmp, err := os.MkdirTemp("", "notte-storage-") + if err != nil { + return nil, err + } + defer func() { _ = os.RemoveAll(tmp) }() + entries, err := os.ReadDir(source) + if err != nil { + return nil, fmt.Errorf("could not read local storage directory") + } + var copied int64 + type fileVersion struct { + size int64 + modified int64 + } + versions := map[string]fileVersion{} + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || name == "LOCK" || (name != "CURRENT" && !strings.HasPrefix(name, "MANIFEST-") && !strings.HasSuffix(name, ".log") && !strings.HasSuffix(name, ".ldb") && !strings.HasSuffix(name, ".sst")) { + continue + } + info, err := entry.Info() + if err != nil { + return nil, fmt.Errorf("local storage changed while being copied; close the browser and retry") + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("local storage contains an unexpected file") + } + copied += info.Size() + versions[name] = fileVersion{info.Size(), info.ModTime().UnixNano()} + if copied > 512*1024*1024 { + return nil, fmt.Errorf("local storage database is too large; use --cdp-url with --domain") + } + if err := copyFile(filepath.Join(source, name), filepath.Join(tmp, name)); err != nil { + return nil, fmt.Errorf("local storage changed while being copied; close the browser and retry") + } + } + // A multi-file copy is valid only while the source stays unchanged. A WAL + // append, compaction or manifest rotation must fail rather than import a + // potentially incomplete mix of database versions. + after, err := os.ReadDir(source) + if err != nil { + return nil, fmt.Errorf("local storage changed while being copied; close the browser and retry") + } + for _, entry := range after { + name := entry.Name() + if entry.IsDir() || name == "LOCK" || (name != "CURRENT" && !strings.HasPrefix(name, "MANIFEST-") && !strings.HasSuffix(name, ".log") && !strings.HasSuffix(name, ".ldb") && !strings.HasSuffix(name, ".sst")) { + continue + } + info, err := entry.Info() + version, existed := versions[name] + if err != nil || !existed || !info.Mode().IsRegular() || version != (fileVersion{info.Size(), info.ModTime().UnixNano()}) { + return nil, fmt.Errorf("local storage changed while being copied; close the browser and retry") + } + delete(versions, name) + } + if len(versions) != 0 { + return nil, fmt.Errorf("local storage changed while being copied; close the browser and retry") + } + db, err := leveldb.OpenFile(tmp, &opt.Options{ReadOnly: true, ErrorIfMissing: true, Strict: opt.StrictAll}) + if err != nil { + return nil, fmt.Errorf("could not open local storage snapshot; close the browser and retry") + } + defer func() { _ = db.Close() }() + iter := db.NewIterator(nil, nil) + defer iter.Release() + bytesRead := 0 + for iter.Next() { + key := iter.Key() + if len(key) == 0 || key[0] != '_' { + continue + } + originBytes, itemKey, ok := bytes.Cut(key[1:], []byte{0}) + if !ok { + continue + } + origin, allowed := storageOrigin(string(originBytes), domains) + if !allowed { + continue + } + if len(itemKey) > 2*MaxStateBytes+1 || len(iter.Value()) > 2*MaxStateBytes+1 { + return nil, fmt.Errorf("selected local storage exceeds the 8 MiB sync limit") + } + name, err := decodeChromeString(itemKey) + if err != nil { + return nil, err + } + value, err := decodeChromeString(iter.Value()) + if err != nil { + return nil, err + } + bytesRead += len(name) + len(value) + if bytesRead > MaxStateBytes { + return nil, fmt.Errorf("selected local storage exceeds the 8 MiB sync limit") + } + if out[origin] == nil { + out[origin] = map[string]string{} + } + out[origin][name] = value + } + if err := iter.Error(); err != nil { + return nil, fmt.Errorf("local storage snapshot is corrupt; nothing was uploaded") + } + return out, nil +} + +func decodeChromeString(raw []byte) (string, error) { + if len(raw) == 0 { + return "", fmt.Errorf("local storage contains an invalid encoded string") + } + switch raw[0] { + case 1: + runes := make([]rune, len(raw)-1) + for i, b := range raw[1:] { + runes[i] = rune(b) + } + return string(runes), nil + case 0: + return decodeUTF16(raw[1:]) + default: + return "", fmt.Errorf("local storage uses an unsupported string encoding") + } +} + +func decodeUTF16(raw []byte) (string, error) { + if len(raw)%2 != 0 { + return "", fmt.Errorf("local storage contains truncated UTF-16") + } + units := make([]uint16, len(raw)/2) + for i := range units { + units[i] = binary.LittleEndian.Uint16(raw[i*2:]) + } + // JSON/UTF-8 cannot preserve unpaired DOMString surrogates. Refuse instead + // of changing a login token while reporting success. + for i := 0; i < len(units); i++ { + u := units[i] + if u >= 0xd800 && u <= 0xdbff { + if i+1 == len(units) || units[i+1] < 0xdc00 || units[i+1] > 0xdfff { + return "", fmt.Errorf("local storage contains an unsupported unpaired surrogate") + } + i++ + } else if u >= 0xdc00 && u <= 0xdfff { + return "", fmt.Errorf("local storage contains an unsupported unpaired surrogate") + } + } + return string(utf16.Decode(units)), nil +} + +func (p Profile) readFirefoxStorage(domains []string) (map[string]map[string]string, error) { + out := map[string]map[string]string{} + root := filepath.Join(p.path, "storage", "default") + entries, err := os.ReadDir(root) + if os.IsNotExist(err) { + return out, nil + } + if err != nil { + return nil, fmt.Errorf("could not inspect Firefox local storage") + } + for _, entry := range entries { + if !entry.IsDir() || strings.Contains(entry.Name(), "^") { + continue + } // containers and partitioned origins + // Firefox encodes scheme://host[:port] as scheme+++host[+port]. + // Filter the directory before touching unrelated databases, then still + // validate the stored origin inside each selected database. + parts := strings.SplitN(entry.Name(), "+++", 2) + if len(parts) != 2 || (parts[0] != "http" && parts[0] != "https") { + continue + } + if _, allowed := storageOrigin(parts[0]+"://"+strings.ReplaceAll(parts[1], "+", ":"), domains); !allowed { + continue + } + dbPath := filepath.Join(root, entry.Name(), "ls", "data.sqlite") + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + continue + } else if err != nil { + return nil, fmt.Errorf("could not inspect Firefox local storage") + } + if err := readFirefoxOrigin(dbPath, domains, out); err != nil { + return nil, err + } + if err := checkStateSize(State{LocalStorage: out}); err != nil { + return nil, err + } + } + return out, nil +} + +func readFirefoxOrigin(dbPath string, domains []string, out map[string]map[string]string) error { + tmp, err := os.MkdirTemp("", "notte-storage-") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(tmp) }() + snapshot, err := snapshotDB(dbPath, tmp) + if err != nil { + return fmt.Errorf("could not snapshot Firefox local storage") + } + db, err := sql.Open("sqlite", "file:"+snapshot+"?mode=ro") + if err != nil { + return err + } + defer func() { _ = db.Close() }() + var rawOrigin string + if err := db.QueryRow(`SELECT origin FROM database`).Scan(&rawOrigin); err != nil { + return fmt.Errorf("unsupported Firefox local storage metadata") + } + origin, allowed := storageOrigin(rawOrigin, domains) + if !allowed { + return nil + } + rows, err := db.Query(`SELECT key, conversion_type, compression_type, value FROM data`) + if err != nil { + // Firefox's earlier LSNG schema stores UTF-8 with a compressed flag. + // Read it without migrating or modifying the source database. + rows, err = db.Query(`SELECT key, 1, compressed, value FROM data`) + if err != nil { + return fmt.Errorf("unsupported Firefox local storage schema; open Firefox to migrate it and retry") + } + } + defer func() { _ = rows.Close() }() + out[origin] = map[string]string{} + var total int + for rows.Next() { + var key string + var conversion, compression int + var raw []byte + if err := rows.Scan(&key, &conversion, &compression, &raw); err != nil { + return fmt.Errorf("could not read Firefox local storage") + } + if compression == 1 { + size, err := snappy.DecodedLen(raw) + if err != nil || size > MaxStateBytes { + return fmt.Errorf("invalid or oversized compressed Firefox storage") + } + raw, err = snappy.Decode(nil, raw) + if err != nil { + return fmt.Errorf("corrupt Firefox local storage") + } + } else if compression != 0 { + return fmt.Errorf("unsupported Firefox storage compression") + } + var value string + switch conversion { + case 0: + value, err = decodeUTF16(raw) + case 1: + if !utf8.Valid(raw) { + return fmt.Errorf("invalid UTF-8 in Firefox storage") + } + value = string(raw) + default: + return fmt.Errorf("unsupported Firefox storage encoding") + } + if err != nil { + return err + } + total += len(key) + len(value) + if total > MaxStateBytes { + return fmt.Errorf("firefox local storage exceeds the sync limit") + } + out[origin][key] = value + } + return rows.Err() +} diff --git a/internal/browser/storage_test.go b/internal/browser/storage_test.go new file mode 100644 index 0000000..f85c3cd --- /dev/null +++ b/internal/browser/storage_test.go @@ -0,0 +1,296 @@ +package browser + +import ( + "database/sql" + "encoding/binary" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + "unicode/utf16" + + "github.com/golang/snappy" + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/opt" + "github.com/syndtr/goleveldb/leveldb/util" +) + +func encodedUTF16(value string) []byte { + units := utf16.Encode([]rune(value)) + out := make([]byte, len(units)*2) + for i, u := range units { + binary.LittleEndian.PutUint16(out[i*2:], u) + } + return out +} +func chromeText(value string) []byte { return append([]byte{0}, encodedUTF16(value)...) } +func localStorageProfile(t *testing.T) (Profile, *leveldb.DB) { + t.Helper() + profile := Profile{Dir: "Default", Name: "Synthetic", path: t.TempDir()} + dir := filepath.Join(profile.path, "Local Storage", "leveldb") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + db, err := leveldb.OpenFile(dir, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + return profile, db +} + +func putStorage(t *testing.T, db *leveldb.DB, origin, key, value string) { + t.Helper() + rawKey := append([]byte("_"+origin+"\x00"), chromeText(key)...) + if err := db.Put(rawKey, chromeText(value), nil); err != nil { + t.Fatal(err) + } +} + +func TestStorageOnlyProfileIncludesBlueskyLogin(t *testing.T) { + p, db := localStorageProfile(t) + value := `{"session":{"accounts":[{"accessJwt":"synthetic-access","refreshJwt":"synthetic-refresh"}]}}` + putStorage(t, db, "https://bsky.app", "BSKY_STORAGE", value) + putStorage(t, db, "https://unrelated.test", "secret", "excluded") + putStorage(t, db, "https://evilbsky.app", "secret", "excluded") + putStorage(t, db, "https://bsky.app^0https://other.test", "partitioned", "excluded") + state, err := p.ReadState([]string{"bsky.app"}) + if err != nil { + t.Fatal(err) + } + if len(state.Cookies) != 0 || len(state.LocalStorage) != 1 || state.LocalStorage["https://bsky.app"]["BSKY_STORAGE"] != value { + t.Fatal("storage-only login was lost or domain filtering failed") + } +} + +func TestChromiumSnapshotReadsLiveWALAndLatestValues(t *testing.T) { + p, db := localStorageProfile(t) + for i := 0; i < 100; i++ { + putStorage(t, db, "https://bsky.app", "login", strings.Repeat("x", i)) + } + putStorage(t, db, "https://bsky.app", "gone", "old") + if err := db.Delete(append([]byte("_https://bsky.app\x00"), chromeText("gone")...), nil); err != nil { + t.Fatal(err) + } + for i := 0; i < 20; i++ { + state, err := p.ReadState(nil) + if err != nil { + t.Fatal(err) + } + if state.LocalStorage["https://bsky.app"]["login"] != strings.Repeat("x", 99) { + t.Fatal("stale WAL state") + } + if _, ok := state.LocalStorage["https://bsky.app"]["gone"]; ok { + t.Fatal("deleted value was resurrected") + } + } + if err := db.Put([]byte("still-open"), []byte("yes"), nil); err != nil { + t.Fatal("reader modified or locked the original profile") + } +} + +func TestChromeStringEncodings(t *testing.T) { + for _, value := range []string{"", "ascii", "éàø", "ζ—₯本θͺž", "πŸ”\x00\n"} { + decoded, err := decodeChromeString(chromeText(value)) + if err != nil || decoded != value { + t.Fatalf("UTF-16 roundtrip: %v", err) + } + } + decoded, err := decodeChromeString([]byte{1, 0xe9, 0xff}) + if err != nil || decoded != "éÿ" { + t.Fatal("Chromium Latin-1 was decoded as UTF-8") + } + for _, bad := range [][]byte{nil, {2, 1}, {0, 1}, {0, 0, 0xd8}, {0, 0, 0xdc}} { + if _, err := decodeChromeString(bad); err == nil { + t.Fatal("invalid string accepted") + } + } +} + +func TestStorageCorruptionAndSizeFailBeforeUpload(t *testing.T) { + p, db := localStorageProfile(t) + if err := db.Put(append([]byte("_https://bsky.app\x00"), chromeText("key")...), []byte{9}, nil); err != nil { + t.Fatal(err) + } + if _, err := p.ReadState([]string{"bsky.app"}); err == nil { + t.Fatal("corrupt selected value accepted") + } + if _, err := p.ReadState([]string{"unrelated.test"}); err != nil { + t.Fatal("unselected corrupt value must not block scoped sync") + } + putStorage(t, db, "https://bsky.app", "key", strings.Repeat("x", MaxStateBytes+1)) + if _, err := p.ReadState(nil); err == nil { + t.Fatal("oversized storage accepted") + } +} + +func TestFirefoxStorageFormats(t *testing.T) { + for _, compressed := range []bool{false, true} { + for _, conversion := range []int{0, 1} { + t.Run(strings.Join([]string{string(rune('0' + conversion)), map[bool]string{false: "plain", true: "snappy"}[compressed]}, "-"), func(t *testing.T) { + p := Profile{Browser: Browser{kind: firefoxKind}, path: t.TempDir()} + dir := filepath.Join(p.path, "storage", "default", "https+++bsky.app", "ls") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + db, err := sql.Open("sqlite", filepath.Join(dir, "data.sqlite")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = db.Close() }() + for _, stmt := range []string{`CREATE TABLE database(origin TEXT)`, `INSERT INTO database VALUES('https://bsky.app')`, `CREATE TABLE data(key TEXT, conversion_type INTEGER, compression_type INTEGER, value BLOB)`} { + if _, err := db.Exec(stmt); err != nil { + t.Fatal(err) + } + } + value := `{"token":"synthetic-πŸ”"}` + raw := []byte(value) + if conversion == 0 { + raw = encodedUTF16(value) + } + compression := 0 + if compressed { + raw = snappy.Encode(nil, raw) + compression = 1 + } + if _, err := db.Exec(`INSERT INTO data VALUES(?,?,?,?)`, "BSKY_STORAGE", conversion, compression, raw); err != nil { + t.Fatal(err) + } + state, err := p.ReadState([]string{"bsky.app"}) + if err != nil { + t.Fatal(err) + } + if state.LocalStorage["https://bsky.app"]["BSKY_STORAGE"] != value { + t.Fatal("Firefox login changed during decoding") + } + }) + } + } +} + +func FuzzChromeStringRoundTrip(f *testing.F) { + for _, s := range []string{"", "token", "ζ—₯本θͺžπŸ”", "\x00\n\""} { + f.Add(s) + } + f.Fuzz(func(t *testing.T, value string) { + raw := chromeText(value) + decoded, err := decodeChromeString(raw) + if err != nil { + t.Fatal(err) + } + if decoded != string([]rune(value)) { + t.Fatal("UTF-16 value changed") + } + }) +} + +func TestFirefoxLegacyLSNGStorage(t *testing.T) { + for _, compressed := range []bool{false, true} { + p := Profile{Browser: Browser{kind: firefoxKind}, path: t.TempDir()} + dir := filepath.Join(p.path, "storage", "default", "https+++bsky.app", "ls") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + db, err := sql.Open("sqlite", filepath.Join(dir, "data.sqlite")) + if err != nil { + t.Fatal(err) + } + value := []byte("synthetic-legacy-πŸ”") + compression := 0 + if compressed { + value = snappy.Encode(nil, value) + compression = 1 + } + for _, statement := range []string{`CREATE TABLE database(origin TEXT)`, `INSERT INTO database VALUES('https://bsky.app')`, `CREATE TABLE data(key TEXT, compressed INTEGER, value BLOB)`} { + if _, err := db.Exec(statement); err != nil { + t.Fatal(err) + } + } + if _, err := db.Exec(`INSERT INTO data VALUES(?,?,?)`, "login", compression, value); err != nil { + t.Fatal(err) + } + _ = db.Close() + state, err := p.ReadState([]string{"bsky.app"}) + if err != nil || state.LocalStorage["https://bsky.app"]["login"] != "synthetic-legacy-πŸ”" { + t.Fatal("legacy Firefox state changed", err) + } + } +} + +func TestFirefoxScopedSyncSkipsUnrelatedCorruptDatabase(t *testing.T) { + p := Profile{Browser: Browser{kind: firefoxKind}, path: t.TempDir()} + dir := filepath.Join(p.path, "storage", "default", "https+++unrelated.test", "ls") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "data.sqlite"), []byte("broken database"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := p.ReadState([]string{"bsky.app"}); err != nil { + t.Fatal("unrelated corrupt storage must not block scoped sync", err) + } + if _, err := p.ReadState([]string{"unrelated.test"}); err == nil { + t.Fatal("selected corrupt storage must fail") + } +} + +func TestChromiumSnapshotDuringWritesAndCompaction(t *testing.T) { + p, db := localStorageProfile(t) + stop, started := make(chan struct{}), make(chan struct{}) + done := make(chan error, 1) + go func() { + for version := 0; ; version++ { + select { + case <-stop: + done <- nil + return + default: + } + batch := new(leveldb.Batch) + for _, key := range []string{"first", "second"} { + rawKey := append([]byte("_https://bsky.app\x00"), chromeText(key)...) + batch.Put(rawKey, chromeText(strconv.Itoa(version)+strings.Repeat("x", 4096))) + } + if err := db.Write(batch, &opt.WriteOptions{Sync: true}); err != nil { + done <- err + return + } + if version == 0 { + close(started) + } + if version%4 == 0 { + if err := db.CompactRange(util.Range{}); err != nil { + done <- err + return + } + } + time.Sleep(time.Millisecond) + } + }() + defer func() { + close(stop) + if err := <-done; err != nil { + t.Error("fixture writer failed", err) + } + }() + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("fixture writer did not start") + } + for range 30 { + state, err := p.ReadState([]string{"bsky.app"}) + if err != nil { + if !strings.Contains(err.Error(), "local storage") { + t.Fatal("unexpected snapshot error", err) + } + continue + } + values := state.LocalStorage["https://bsky.app"] + if values["first"] == "" || values["first"] != values["second"] { + t.Fatal("concurrent database copy returned an incomplete atomic write") + } + } +} diff --git a/internal/cmd/profiles_state_sync_test.go b/internal/cmd/profiles_state_sync_test.go new file mode 100644 index 0000000..b45ae1b --- /dev/null +++ b/internal/cmd/profiles_state_sync_test.go @@ -0,0 +1,279 @@ +package cmd + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/gorilla/websocket" + "github.com/spf13/cobra" + + "github.com/nottelabs/notte-cli/internal/browser" + "github.com/nottelabs/notte-cli/internal/testutil" +) + +func liveSyncFixture(t *testing.T, empty bool) string { + value := "synthetic-private-token" + if empty { + value = "" + } + return liveSyncValueFixture(t, value) +} + +func liveSyncValueFixture(t *testing.T, value string) string { + t.Helper() + u := websocket.Upgrader{} + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := u.Upgrade(w, r, nil) + if err != nil { + return + } + defer func() { _ = conn.Close() }() + for { + var request struct { + ID int `json:"id"` + Method string `json:"method"` + } + if err := conn.ReadJSON(&request); err != nil { + return + } + var result any = map[string]any{"cookies": []any{}} + if request.Method == "Runtime.evaluate" { + local := map[string]string{} + if value != "" { + local["BSKY_STORAGE"] = value + } + snapshot, _ := json.Marshal(map[string]any{"origin": "https://bsky.app", "local": local, "session": map[string]string{}}) + result = map[string]any{"result": map[string]any{"value": string(snapshot)}} + } + if err := conn.WriteJSON(map[string]any{"id": request.ID, "result": result}); err != nil { + return + } + } + })) + t.Cleanup(s.Close) + return strings.Replace(s.URL, "http:", "ws:", 1) +} + +func configureLiveSyncTest(t *testing.T, endpoint string) { + t.Helper() + oldID, oldCDP, oldBrowser, oldLocal := profileID, syncCDPURL, syncBrowser, syncChromeProfile + oldDomains, oldMode, oldName := syncDomains, syncMode, syncProfileName + oldConfirm, oldOutput := skipConfirmation, outputFormat + t.Cleanup(func() { + profileID, syncCDPURL, syncBrowser, syncChromeProfile = oldID, oldCDP, oldBrowser, oldLocal + syncDomains, syncMode, syncProfileName = oldDomains, oldMode, oldName + skipConfirmation, outputFormat = oldConfirm, oldOutput + }) + profileID, syncCDPURL, syncBrowser, syncChromeProfile = "", endpoint, "", "" + syncDomains, syncMode, syncProfileName = []string{"bsky.app"}, "replace", "Synthetic" + skipConfirmation, outputFormat = true, "json" +} + +func TestLiveSyncCommandStorageOnlyAndFailureCleanup(t *testing.T) { + for _, existing := range []bool{false, true} { + for _, status := range []int{200, 404, 422, 500} { + t.Run(fmt.Sprintf("existing-%t-status-%d", existing, status), func(t *testing.T) { + env := testutil.SetupTestEnv(t) + env.SetEnv("NOTTE_API_KEY", "test-key") + configureLiveSyncTest(t, liveSyncFixture(t, false)) + if existing { + profileID = profileIDTest + } + creates, uploads, deletes := 0, 0, 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-key" { + t.Error("missing authentication") + } + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/profiles/create": + creates++ + _, _ = w.Write([]byte(profileJSON())) + case strings.HasSuffix(r.URL.Path, "/state"): + uploads++ + var body map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if _, ok := body["session_storage"]; !ok { + t.Error("live sync must capture session storage explicitly") + } + if string(body["storage_scope"]) != `"origins"` { + t.Error("live snapshots must preserve storage for closed tabs") + } + w.WriteHeader(status) + if status == 200 { + _, _ = w.Write([]byte(`{"success":true,"cookies_count":0,"local_storage_origins":1,"session_storage_origins":1}`)) + } else { + _, _ = w.Write([]byte(`{"detail":"synthetic-private-token"}`)) + } + case r.Method == "DELETE": + deletes++ + _, _ = w.Write([]byte(`{"success":true}`)) + default: + t.Error("unexpected API request", r.Method, r.URL.Path) + } + })) + defer server.Close() + env.SetEnv("NOTTE_API_URL", server.URL) + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + var runErr error + stdout, stderr := testutil.CaptureOutput(func() { runErr = runProfilesSync(cmd, nil) }) + if status == 200 { + if runErr != nil { + t.Fatal(runErr) + } + var metadata map[string]any + if err := json.Unmarshal([]byte(stdout), &metadata); err != nil { + t.Fatal(err) + } + if metadata["profile_id"] != profileIDTest || metadata["cookies_count"] != float64(0) { + t.Fatal("invalid metadata") + } + } else if runErr == nil || strings.Contains(runErr.Error(), "synthetic-private-token") { + t.Fatal("failed import reported success or leaked values") + } + if strings.Contains(stdout+stderr, "synthetic-private-token") { + t.Fatal("browser credentials appeared in output") + } + wantCreates, wantDeletes := 0, 0 + if !existing { + wantCreates = 1 + if status >= 400 && status < 500 { + wantDeletes = 1 + } + } + if creates != wantCreates || deletes != wantDeletes || uploads != 1 { + t.Fatalf("creates=%d deletes=%d uploads=%d", creates, deletes, uploads) + } + }) + } + } +} + +func TestOversizedFinalRequestNeverCreatesProfile(t *testing.T) { + env := testutil.SetupTestEnv(t) + env.SetEnv("NOTTE_API_KEY", "test-key") + configureLiveSyncTest(t, liveSyncValueFixture(t, strings.Repeat("x", browser.MaxStateBytes-4096))) + // The browser snapshot fits; adding the requested domain scope does not. + syncDomains = make([]string, 1000) + for i := range syncDomains { + syncDomains[i] = "bsky.app" + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("oversized request accessed the API", r.URL.Path) + })) + defer server.Close() + env.SetEnv("NOTTE_API_URL", server.URL) + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + if err := runProfilesSync(cmd, nil); err == nil || !strings.Contains(err.Error(), "import limit") { + t.Fatal("final wire size must be checked before profile creation", err) + } +} + +func TestEmptyLiveSyncNeverCreatesProfile(t *testing.T) { + configureLiveSyncTest(t, liveSyncFixture(t, true)) + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + if err := runProfilesSync(cmd, nil); err == nil || !strings.Contains(err.Error(), "no cookies or web storage") { + t.Fatal("empty snapshot must fail before accessing the API") + } +} + +func TestCookieOnlyEndpointCompatibilityAndScope(t *testing.T) { + state := browser.State{LocalStorage: map[string]map[string]string{"https://bsky.app": {}}} + if needsStateImport(state, nil, false) { + t.Fatal("empty storage tables must not require a new server for unscoped disk cookie sync") + } + if !needsStateImport(state, []string{"bsky.app"}, false) { + t.Fatal("scoped cookies require the state API to preserve other sites") + } + if !needsStateImport(state, nil, true) { + t.Fatal("live snapshots must replace captured origins without clearing closed ones") + } + state.LocalStorage["https://bsky.app"]["token"] = "synthetic" + if !needsStateImport(state, nil, false) { + t.Fatal("storage values must never be silently dropped for cookie API compatibility") + } +} + +func TestLegacyCookieSyncRefusesRedirectsAndRedactsErrors(t *testing.T) { + if !browser.SupportedPlatform() { + t.Skip("disk browser sync is not supported on this OS") + } + for _, status := range []int{200, 307, 308, 422} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + env := testutil.SetupTestEnv(t) + env.SetEnv("HOME", env.TempDir) + env.SetEnv("NOTTE_API_KEY", "test-key") + configureLiveSyncTest(t, "") + profileID, syncDomains, syncBrowser, syncChromeProfile = profileIDTest, nil, "firefox", "Synthetic" + root := filepath.Join(env.TempDir, ".mozilla", "firefox") + if runtime.GOOS == "darwin" { + root = filepath.Join(env.TempDir, "Library", "Application Support", "Firefox") + } + dir := filepath.Join(root, "synthetic") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "profiles.ini"), []byte("[Profile0]\nName=Synthetic\nIsRelative=1\nPath=synthetic\n"), 0o600); err != nil { + t.Fatal(err) + } + db, err := sql.Open("sqlite", filepath.Join(dir, "cookies.sqlite")) + if err != nil { + t.Fatal(err) + } + for _, statement := range []string{ + `CREATE TABLE moz_cookies(host TEXT, name TEXT, value TEXT, path TEXT, expiry INTEGER, isSecure INTEGER, isHttpOnly INTEGER, sameSite INTEGER, originAttributes TEXT)`, + `INSERT INTO moz_cookies VALUES('bsky.app','login','synthetic-private-token','/',1893456000,1,1,1,'')`, + } { + if _, err := db.Exec(statement); err != nil { + t.Fatal(err) + } + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("browser credentials were forwarded on redirect") + })) + defer target.Close() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/cookies") { + t.Error("unscoped cookie-only sync did not use the compatible endpoint", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Location", target.URL) + w.WriteHeader(status) + if status == 200 { + _, _ = w.Write([]byte(`{"success":true,"cookies_count":1,"mode":"replace","message":"imported"}`)) + } else { + _, _ = w.Write([]byte(`{"detail":"synthetic-private-token"}`)) + } + })) + defer server.Close() + env.SetEnv("NOTTE_API_URL", server.URL) + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + var runErr error + stdout, stderr := testutil.CaptureOutput(func() { runErr = runProfilesSync(cmd, nil) }) + if (runErr == nil) != (status == 200) { + t.Fatal("cookie import reported an incorrect result", runErr) + } + if strings.Contains(stdout+stderr+fmt.Sprint(runErr), "synthetic-private-token") { + t.Fatal("cookie values leaked into output") + } + }) + } +} diff --git a/internal/cmd/profiles_sync.go b/internal/cmd/profiles_sync.go index e835faf..6b34f9e 100644 --- a/internal/cmd/profiles_sync.go +++ b/internal/cmd/profiles_sync.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "net/http" "os" "sort" "strconv" @@ -23,18 +24,21 @@ var ( syncDomains []string syncMode string syncProfileName string + syncCDPURL string ) var profilesSyncCmd = &cobra.Command{ Use: "sync", - Short: "Upload cookies from your local browser into a Notte profile", - Long: "Read the cookies from a local Firefox, Chrome, Brave, Edge, Chromium, Arc or\n" + + Short: "Sync local browser cookies and web storage into a Notte profile", + Long: "Read cookies and localStorage from a local Firefox, Chrome, Brave, Edge, Chromium, Arc or\n" + "Helium profile and upload them into a Notte cloud profile, so remote sessions\n" + "and agents start already logged in. Arc and Helium are supported on macOS only.\n\n" + - "Cookies are read from a copy of the browser's own files on this machine and\n" + + "Browser state is read from a private copy of the browser's files on this machine; cookies are\n" + "decrypted locally. Nothing leaves your computer until you confirm the upload,\n" + "and with --domain you choose exactly which sites are included.\n\n" + - "With --profile-id the cookies go into an existing profile; without it a new\n" + + "With --cdp-url, read a running Chromium browser, including sessionStorage.\n\n" + + "IndexedDB, partitioned storage and device-bound credentials are not transferred.\n\n" + + "With --profile-id, state goes into an existing profile; without it a new\n" + "profile is created and its id is printed for you to reuse next time.", Args: cobra.NoArgs, RunE: runProfilesSync, @@ -45,13 +49,16 @@ func init() { profilesSyncCmd.Flags().StringVar(&profileID, "profile-id", "", "Existing Notte profile to sync into (creates a new one if omitted)") profilesSyncCmd.Flags().StringVar(&syncBrowser, "browser", "", "Browser to read from (firefox, chrome, brave, edge, chromium, arc, helium)") profilesSyncCmd.Flags().StringVar(&syncChromeProfile, "local-profile", "", "Local browser profile, by name or directory (prompts if there is more than one)") - profilesSyncCmd.Flags().StringSliceVar(&syncDomains, "domain", nil, "Only sync cookies for these domains and their subdomains (repeatable)") - profilesSyncCmd.Flags().StringVar(&syncMode, "mode", "replace", "How to apply the cookies: replace the profile's cookies, or append to them") + profilesSyncCmd.Flags().StringSliceVar(&syncDomains, "domain", nil, "Only sync state for these domains and their subdomains (repeatable)") + profilesSyncCmd.Flags().StringVar(&syncMode, "mode", "replace", "Replace captured state or append/update its keys") profilesSyncCmd.Flags().StringVar(&syncProfileName, "name", "", "Name for the new profile when --profile-id is omitted") + profilesSyncCmd.Flags().StringVar(&syncCDPURL, "cdp-url", "", "Read live Chromium cookies, localStorage and sessionStorage from a debugger HTTP origin or page WebSocket URL") + profilesSyncCmd.MarkFlagsMutuallyExclusive("cdp-url", "browser") + profilesSyncCmd.MarkFlagsMutuallyExclusive("cdp-url", "local-profile") } func runProfilesSync(cmd *cobra.Command, args []string) error { - if !browser.SupportedPlatform() { + if syncCDPURL == "" && !browser.SupportedPlatform() { return fmt.Errorf("syncing cookies from a local browser is supported on macOS and Linux only") } if syncMode != "replace" && syncMode != "append" { @@ -60,29 +67,46 @@ func runProfilesSync(cmd *cobra.Command, args []string) error { // An explicit --domain that normalizes to nothing (e.g. \".\" or a blank) // must be rejected rather than silently treated as "all domains", which // would upload every cookie under --yes. - if len(syncDomains) > 0 && !hasValidDomain(syncDomains) { - return fmt.Errorf("--domain was given but contains no valid domain") - } - - profile, err := resolveLocalProfile() - if err != nil { + if err := browser.ValidateDomains(syncDomains); err != nil { return err } - PrintInfo(fmt.Sprintf("Reading cookies from %s profile %q ...", profile.Browser.DisplayName, profile.Name)) - result, err := profile.ReadCookies(syncDomains) + readCtx, readCancel := GetContextWithTimeout(cmd.Context()) + defer readCancel() + var result browser.State + var err error + label := "Live browser" + if syncCDPURL != "" { + result, err = browser.ReadCDPState(readCtx, syncCDPURL, syncDomains) + } else { + profile, resolveErr := resolveLocalProfile() + if resolveErr != nil { + return resolveErr + } + label = fmt.Sprintf("%s - %s", profile.Browser.DisplayName, profile.Name) + PrintInfo(fmt.Sprintf("Reading browser state from %s ...", label)) + result, err = profile.ReadState(syncDomains) + } if err != nil { return err } - if len(result.Cookies) == 0 { - if len(syncDomains) > 0 { - return fmt.Errorf("no cookies found for %s in that profile", strings.Join(syncDomains, ", ")) - } - return fmt.Errorf("no cookies found in that profile") + if len(result.Cookies) == 0 && storageItems(result.LocalStorage) == 0 && storageItems(result.SessionStorage) == 0 { + return fmt.Errorf("no cookies or web storage found for the selected sites") } - cookies := toAPICookies(result.Cookies) - printCookieSummary(result, cookies) + useStateImport := needsStateImport(result, syncDomains, syncCDPURL != "") + stateRequest := buildStateUpload(result, cookies, syncDomains, syncMode, syncCDPURL != "") + if useStateImport { + if err := stateRequest.Validate(); err != nil { + return err + } + } + printCookieSummary(browser.ReadResult{Cookies: result.Cookies}, cookies) + PrintInfo(fmt.Sprintf("Web storage: %d localStorage origins, %d sessionStorage origins.", len(result.LocalStorage), len(result.SessionStorage))) + if syncCDPURL == "" { + PrintInfo("Disk sync does not include in-memory sessionStorage; use --cdp-url for live tab state.") + } + PrintInfo("IndexedDB, partitioned storage and device-bound credentials are not transferred.") // Decide where the cookies will go and confirm before touching the network, // so a decline (or a dry run) never creates anything. @@ -91,20 +115,23 @@ func runProfilesSync(cmd *cobra.Command, args []string) error { if target == "" { newName = syncProfileName if newName == "" { - newName = fmt.Sprintf("%s - %s", profile.Browser.DisplayName, profile.Name) + newName = label } target = fmt.Sprintf("a new profile named %q", newName) } - if !confirmSync(len(cookies), target) { + if !confirmStateSync(len(cookies), len(result.LocalStorage), len(result.SessionStorage), target) { return PrintResult("Cancelled.", map[string]any{"cancelled": true}) } + readCancel() + ctx, cancel := GetContextWithTimeout(cmd.Context()) + defer cancel() client, err := GetClient() if err != nil { return err } - ctx, cancel := GetContextWithTimeout(cmd.Context()) - defer cancel() + // Cookie-only uploads also contain credentials and must refuse redirects. + client.HTTPClient().CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } targetID := profileID created := false @@ -117,6 +144,16 @@ func runProfilesSync(cmd *cobra.Command, args []string) error { created = true } + if useStateImport { + response, status, importErr := client.ProfileStateSetWithResponse(ctx, targetID, stateRequest) + if importErr != nil { + if created && status >= 400 && status < 500 { + _, _ = client.Client().ProfileDeleteWithResponse(ctx, targetID, &api.ProfileDeleteParams{}) + } + return fmt.Errorf("profile %s: %w", targetID, importErr) + } + return PrintResult(fmt.Sprintf("Synced browser state into profile %s (%d cookies, %d localStorage origins, %d sessionStorage origins).", targetID, response.CookiesCount, response.LocalStorageOrigins, response.SessionStorageOrigins), map[string]any{"profile_id": targetID, "created": created, "synced": len(cookies), "cookies_count": response.CookiesCount, "local_storage_origins": response.LocalStorageOrigins, "session_storage_origins": response.SessionStorageOrigins, "mode": syncMode}) + } mode := api.ProfileCookiesImportRequestMode(syncMode) // The cookies are already normalized to the Playwright shape (string // sameSite, Unix-seconds expiry) in toAPICookies, so we declare that shape @@ -131,10 +168,16 @@ func runProfilesSync(cmd *cobra.Command, args []string) error { resp, err := client.Client().ProfileCookiesSetWithResponse(ctx, targetID, &api.ProfileCookiesSetParams{}, body) if err != nil { - return fmt.Errorf("API request failed: %w", err) + return fmt.Errorf("cookie import could not be confirmed; inspect profile %s before retrying", targetID) } - if err := HandleAPIResponse(resp.HTTPResponse, resp.Body); err != nil { - return err + if resp.StatusCode() != http.StatusOK { + if created && resp.StatusCode() >= 400 && resp.StatusCode() < 500 { + _, _ = client.Client().ProfileDeleteWithResponse(ctx, targetID, &api.ProfileDeleteParams{}) + } + return fmt.Errorf("cookie import failed (HTTP %d); browser values were not printed", resp.StatusCode()) + } + if resp.JSON200 == nil || !resp.JSON200.Success { + return fmt.Errorf("cookie import returned an invalid confirmation for profile %s", targetID) } if IsJSONOutput() { @@ -154,6 +197,46 @@ func runProfilesSync(cmd *cobra.Command, args []string) error { return nil } +func needsStateImport(state browser.State, domains []string, live bool) bool { + return storageItems(state.LocalStorage) > 0 || storageItems(state.SessionStorage) > 0 || len(domains) > 0 || live +} + +func buildStateUpload(state browser.State, cookies []api.Cookie, domains []string, mode string, live bool) api.BrowserStateUploadRequest { + if cookies == nil { + cookies = []api.Cookie{} + } + if state.LocalStorage == nil { + state.LocalStorage = map[string]map[string]string{} + } + request := api.BrowserStateUploadRequest{Cookies: cookies, LocalStorage: state.LocalStorage, Domains: domains, Mode: mode} + if live { + if state.SessionStorage == nil { + state.SessionStorage = map[string]map[string]string{} + } + request.SessionStorage = &state.SessionStorage + request.StorageScope = "origins" + } + return request +} + +func storageItems(storage map[string]map[string]string) int { + count := 0 + for _, values := range storage { + count += len(values) + } + return count +} + +func confirmStateSync(cookies, local, session int, target string) bool { + if skipConfirmation { + return true + } + _, _ = fmt.Fprintf(os.Stderr, "Upload %d cookies, %d localStorage origins and %d sessionStorage origins into %s in mode %q? [y/N]: ", cookies, local, session, target, syncMode) + reader := bufio.NewReader(os.Stdin) + answer, err := reader.ReadString('\n') + return (err == nil || err == io.EOF) && (strings.EqualFold(strings.TrimSpace(answer), "y") || strings.EqualFold(strings.TrimSpace(answer), "yes")) +} + // resolveLocalProfile picks which local browser profile to read, from flags or // by prompting when the choice is ambiguous. func resolveLocalProfile() (browser.Profile, error) { @@ -202,17 +285,6 @@ func resolveLocalProfile() (browser.Profile, error) { return pickProfile(candidates) } -// hasValidDomain reports whether at least one --domain argument survives the -// same normalization the reader applies (trim, drop a leading dot). -func hasValidDomain(domains []string) bool { - for _, d := range domains { - if strings.TrimPrefix(strings.TrimSpace(d), ".") != "" { - return true - } - } - return false -} - func filterByProfileName(profiles []browser.Profile, query string) []browser.Profile { q := strings.ToLower(query) var matched []browser.Profile @@ -353,13 +425,6 @@ func createProfile(ctx context.Context, client *api.NotteClient, name string) (s return resp.JSON200.ProfileId, nil } -func confirmSync(cookieCount int, target string) bool { - if skipConfirmation { - return true - } - return confirmSyncWithIO(os.Stdin, os.Stderr, cookieCount, target) -} - func confirmSyncWithIO(in io.Reader, out io.Writer, cookieCount int, target string) bool { _, _ = fmt.Fprintf(out, "Upload %d cookies into %s in mode %q? [y/N]: ", cookieCount, target, syncMode) diff --git a/internal/cmd/profiles_sync_test.go b/internal/cmd/profiles_sync_test.go index 8f3c56f..0b3d375 100644 --- a/internal/cmd/profiles_sync_test.go +++ b/internal/cmd/profiles_sync_test.go @@ -85,22 +85,30 @@ func TestFilterByProfileName(t *testing.T) { func TestHasValidDomain(t *testing.T) { valid := [][]string{ {"github.com"}, - {" ", "github.com"}, + {"GitHub.com", " localhost "}, {".github.com"}, + {"127.0.0.1", "::1"}, } for _, in := range valid { - if !hasValidDomain(in) { - t.Errorf("hasValidDomain(%q) = false, want true", in) + if err := browser.ValidateDomains(in); err != nil { + t.Errorf("ValidateDomains(%q): %v", in, err) } } invalid := [][]string{ {" "}, {"."}, {"", " ", "."}, + {"github.com", " "}, + {"https://github.com"}, + {"github.com:443"}, + {"git..com"}, + {"-github.com"}, + {"github.com/path"}, + {"github.com?secret=x"}, } for _, in := range invalid { - if hasValidDomain(in) { - t.Errorf("hasValidDomain(%q) = true, want false", in) + if browser.ValidateDomains(in) == nil { + t.Errorf("ValidateDomains(%q) accepted invalid scope", in) } } } diff --git a/scripts/endpoint-coverage.txt b/scripts/endpoint-coverage.txt index 0806634..a6e8a51 100644 --- a/scripts/endpoint-coverage.txt +++ b/scripts/endpoint-coverage.txt @@ -72,7 +72,6 @@ skip POST /anything/start # anything.notte.cc entry point, not a CLI workflow # point of this file, not an endorsement. skip GET /audit-events # audit-event listing is not yet exposed by the CLI skip POST /profiles/{profile_id}/duplicate # `profiles duplicate` is proposed in #53 -skip POST /profiles/{profile_id}/state # importing full browser state is not yet exposed by the CLI; profiles cookies-set imports cookies only skip GET /functions/{function_id}/runs/{run_id}/payloads/{field} # downloading overflow run payloads is not yet exposed by the CLI skip POST /functions/{function_id}/runs/{run_id}/payloads/{field}/upload # preparing run payload uploads is not exposed by the CLI diff --git a/tests/integration/payments_test.go b/tests/integration/payments_test.go index 443b480..f535229 100644 --- a/tests/integration/payments_test.go +++ b/tests/integration/payments_test.go @@ -38,7 +38,19 @@ func TestPaymentSandboxLifecycle(t *testing.T) { t.Fatalf("payment preflight failed: status=%d error=%s", apiError.StatusCode, apiError.Error) } - started := runCLI(t, "sessions", "start", "--proxy=false", "--no-solve-captchas", "--idle-timeout-minutes", "5", "--max-duration-minutes", "10") + // Payments require the session's worker connection and card vault. An + // explicit empty vault selects that path instead of a workerless session. + createdVault := runCLI(t, "vaults", "create", "--name", "CLI sandbox payment test") + requireSuccess(t, createdVault) + var vault struct { + ID string `json:"vault_id"` + } + if err := json.Unmarshal([]byte(createdVault.Stdout), &vault); err != nil || vault.ID == "" { + t.Fatal("vault create returned no vault ID") + } + t.Cleanup(func() { cleanupVault(t, vault.ID) }) + + started := runCLI(t, "sessions", "start", "--vault-id", vault.ID, "--proxy=false", "--no-solve-captchas", "--idle-timeout-minutes", "5", "--max-duration-minutes", "10") requireSuccess(t, started) var session struct { ID string `json:"session_id"` @@ -116,7 +128,7 @@ func TestPaymentSandboxLifecycle(t *testing.T) { denied := runCLI(t, args...) requireFailure(t, denied) if !strings.Contains(denied.Stderr, "wallet_not_connected") || !strings.Contains(denied.Stderr, "payment connect") { - t.Fatal("missing connect-first guidance") + t.Fatalf("missing connect-first guidance: %s", denied.Stderr) } return // Manual wallet authorization is intentionally not automated in CI. } diff --git a/tests/integration/profiles_sync_test.go b/tests/integration/profiles_sync_test.go new file mode 100644 index 0000000..3f58d72 --- /dev/null +++ b/tests/integration/profiles_sync_test.go @@ -0,0 +1,105 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gorilla/websocket" +) + +// A local CDP fixture supplies synthetic browser state; profile creation, +// authenticated state import and cleanup use the real integration API. +func TestProfilesSyncStorageOnlyAndMixed(t *testing.T) { + for _, withCookie := range []bool{false, true} { + name := "storage_only" + if withCookie { + name = "cookies_and_storage" + } + t.Run(name, func(t *testing.T) { + created := runCLI(t, "profiles", "create", "--name", "test-profile-state-sync") + requireSuccess(t, created) + var profile struct { + ID string `json:"profile_id"` + } + if err := json.Unmarshal([]byte(created.Stdout), &profile); err != nil || profile.ID == "" { + t.Fatal("profile creation returned no usable profile ID") + } + t.Cleanup(func() { cleanupProfile(t, profile.ID) }) + + cookies := []any{} + if withCookie { + cookies = append(cookies, map[string]any{ + "name": "login", "value": "synthetic-cookie-token", "domain": "example.com", + "path": "/", "secure": true, "httpOnly": true, "sameSite": "Lax", "expires": 1893456000, + }) + } + snapshot, err := json.Marshal(map[string]any{ + "origin": "https://example.com", + "local": map[string]string{"login": "synthetic-local-token"}, + "session": map[string]string{ + "login": "synthetic-session-token", + }, + }) + if err != nil { + t.Fatal(err) + } + upgrader := websocket.Upgrader{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer func() { _ = conn.Close() }() + for { + var request struct { + ID int `json:"id"` + Method string `json:"method"` + } + if err := conn.ReadJSON(&request); err != nil { + return + } + var result any + switch request.Method { + case "Network.getAllCookies": + result = map[string]any{"cookies": cookies} + case "Runtime.evaluate": + result = map[string]any{"result": map[string]any{"value": string(snapshot)}} + default: + t.Errorf("unexpected browser operation %q", request.Method) + return + } + if err := conn.WriteJSON(map[string]any{"id": request.ID, "result": result}); err != nil { + return + } + } + })) + t.Cleanup(server.Close) + result := runCLI(t, "profiles", "sync", "--profile-id", profile.ID, + "--cdp-url", strings.Replace(server.URL, "http:", "ws:", 1), "--domain", "example.com") + for _, token := range []string{"synthetic-cookie-token", "synthetic-local-token", "synthetic-session-token"} { + if strings.Contains(result.Stdout+result.Stderr, token) { + t.Fatal("browser values were printed during sync") + } + } + requireSuccess(t, result) + var synced struct { + ProfileID string `json:"profile_id"` + Created bool `json:"created"` + Cookies int `json:"cookies_count"` + LocalOrigins int `json:"local_storage_origins"` + SessionOrigins int `json:"session_storage_origins"` + } + if err := json.Unmarshal([]byte(result.Stdout), &synced); err != nil { + t.Fatal("sync returned invalid JSON") + } + if synced.ProfileID != profile.ID || synced.Created || synced.Cookies != len(cookies) || synced.LocalOrigins != 1 || synced.SessionOrigins != 1 { + t.Fatalf("unexpected imported state counts: %+v", synced) + } + }) + } +}