From aae35c568ef8763c4ce2203406abd60ff7e6ac92 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Wed, 14 Oct 2026 23:05:44 +0200 Subject: [PATCH 1/2] feat(api): enforce durable request budgets and refusal stops #15 --- cmd/lnk/main.go | 102 ++++- cmd/lnk/main_test.go | 109 +++++ cmd/lnk/review_test.go | 116 +++++ internal/api/client.go | 41 +- internal/api/policy_regression_test.go | 29 ++ internal/api/policy_test.go | 476 ++++++++++++++++++++ internal/api/redirect_review_test.go | 57 +++ internal/api/request.go | 212 +++++---- internal/api/request_test.go | 54 ++- internal/api/rest.go | 15 +- internal/cmd/apply_test.go | 7 + internal/cmd/profile.go | 28 +- internal/cmd/review_test.go | 22 + internal/cmd/safety.go | 60 +++ internal/cmd/status.go | 8 +- internal/config/config.go | 25 +- internal/config/config_test.go | 33 ++ internal/safety/acl_darwin_test.go | 45 ++ internal/safety/acl_linux_test.go | 114 +++++ internal/safety/platform_darwin.go | 59 +++ internal/safety/platform_linux.go | 34 ++ internal/safety/policy.go | 381 ++++++++++++++++ internal/safety/policy_test.go | 522 ++++++++++++++++++++++ internal/safety/review_regression_test.go | 177 ++++++++ internal/safety/store.go | 193 ++++++++ internal/types/errors.go | 45 +- 26 files changed, 2834 insertions(+), 130 deletions(-) create mode 100644 cmd/lnk/main_test.go create mode 100644 cmd/lnk/review_test.go create mode 100644 internal/api/policy_regression_test.go create mode 100644 internal/api/policy_test.go create mode 100644 internal/api/redirect_review_test.go create mode 100644 internal/cmd/review_test.go create mode 100644 internal/cmd/safety.go create mode 100644 internal/config/config_test.go create mode 100644 internal/safety/acl_darwin_test.go create mode 100644 internal/safety/acl_linux_test.go create mode 100644 internal/safety/platform_darwin.go create mode 100644 internal/safety/platform_linux.go create mode 100644 internal/safety/policy.go create mode 100644 internal/safety/policy_test.go create mode 100644 internal/safety/review_regression_test.go create mode 100644 internal/safety/store.go diff --git a/cmd/lnk/main.go b/cmd/lnk/main.go index 81c2228..584a9aa 100644 --- a/cmd/lnk/main.go +++ b/cmd/lnk/main.go @@ -4,12 +4,17 @@ package main import ( + "encoding/json" + "errors" "fmt" + "io" "os" "github.com/spf13/cobra" lnkcmd "github.com/yashiels/linkedin-cli/internal/cmd" + "github.com/yashiels/linkedin-cli/internal/config" + "github.com/yashiels/linkedin-cli/internal/types" ) // version is set at build time via -ldflags. @@ -18,8 +23,7 @@ var version = "dev" func main() { root := newRootCmd() if err := root.Execute(); err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(1) + os.Exit(writeError(root, os.Stderr, err)) } } @@ -50,18 +54,30 @@ Common flags: --plain Output as plain tab-separated text (pipe-friendly) --no-color Disable ANSI colour --quiet / -q Suppress informational output - --verbose Show HTTP request details on stderr - --debug Show full request/response bodies on stderr`, + --verbose Show bounded HTTP method/status diagnostics on stderr + --debug Show bounded HTTP method/status diagnostics on stderr`, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + if flagConfig != "" { + if err := os.Setenv("LNK_CONFIG", flagConfig); err != nil { + return types.NewError(2, "cannot select configuration") + } + } + if _, err := config.Load(); err != nil { + return types.NewError(2, "configuration could not be loaded") + } + return nil + }, SilenceUsage: true, SilenceErrors: true, } + root.SetFlagErrorFunc(func(*cobra.Command, error) error { return types.NewError(2, "invalid command flags") }) pf := root.PersistentFlags() pf.BoolVar(&flagJSON, "json", false, "Output as JSON") pf.BoolVar(&flagPlain, "plain", false, "Output as plain tab-separated text") pf.BoolVarP(&flagQuiet, "quiet", "q", false, "Suppress informational output") - pf.BoolVar(&flagVerbose, "verbose", false, "Show HTTP request details on stderr") - pf.BoolVar(&flagDebug, "debug", false, "Show full request/response bodies on stderr") + pf.BoolVar(&flagVerbose, "verbose", false, "Show bounded HTTP method/status diagnostics on stderr") + pf.BoolVar(&flagDebug, "debug", false, "Show bounded HTTP method/status diagnostics on stderr") pf.BoolVar(&flagNoColor, "no-color", false, "Disable ANSI colour output") pf.BoolVar(&flagNoInput, "no-input", false, "Disable interactive prompts (fail instead)") pf.StringVar(&flagConfig, "config", "", "Path to config file (default ~/.config/lnk/config.toml)") @@ -76,6 +92,7 @@ Common flags: root.AddCommand(lnkcmd.NewProfileCmd()) root.AddCommand(lnkcmd.NewAlertsCmd()) root.AddCommand(lnkcmd.NewStatusCmd()) + root.AddCommand(lnkcmd.NewSafetyCmd()) // Shell completion. root.AddCommand(&cobra.Command{ @@ -121,3 +138,76 @@ Fish: return root } + +func writeError(root *cobra.Command, w io.Writer, err error) int { + code := types.ExitGeneral + var coded *types.LnkError + reason := types.ReasonUnspecified + if errors.As(err, &coded) { + if coded.Code >= types.ExitGeneral && coded.Code <= types.ExitRateLimit { + code = coded.Code + } + reason = coded.Reason + } + message, reasonName := safeDiagnosticReason(code, reason) + fields := map[string]any{"error": message, "code": code, "reason": reasonName} + if coded != nil { + if coded.Status >= 100 && coded.Status <= 999 { + fields["status"] = coded.Status + } + switch coded.Method { + case "GET", "POST", "DELETE": + fields["method"] = coded.Method + } + switch coded.Operation { + case "read", "graphql", "normalized_read", "post", "delete": + fields["operation"] = coded.Operation + } + } + jsonMode, _ := root.PersistentFlags().GetBool("json") + if jsonMode { + _ = json.NewEncoder(w).Encode(fields) + } else { + fmt.Fprintf(w, "error: %s", message) + for _, key := range []string{"reason", "method", "operation", "status"} { + if value, ok := fields[key]; ok { + fmt.Fprintf(w, " %s=%v", key, value) + } + } + fmt.Fprintln(w) + } + return code +} + +func safeDiagnosticReason(code int, reason types.DiagnosticReason) (string, string) { + switch reason { + case types.ReasonHTTPStatus: + return "LinkedIn returned an unsuccessful HTTP status", "http_status" + case types.ReasonRedirect: + return "LinkedIn redirect rejected", "redirect" + case types.ReasonTransport: + return "LinkedIn request failed", "transport" + case types.ReasonResponseRead: + return "reading LinkedIn response failed", "response_read" + case types.ReasonResponseSize: + return "LinkedIn response exceeds size limit", "response_size" + case types.ReasonJSON: + return "LinkedIn returned invalid JSON", "invalid_json" + case types.ReasonGraphQL: + return "LinkedIn returned GraphQL errors", "graphql_errors" + case types.ReasonRefusal: + return "LinkedIn refused request; inspect safety status", "refusal" + } + switch code { + case types.ExitUsage: + return "invalid arguments or configuration", "usage" + case types.ExitAuth: + return "authentication or refusal stop; inspect safety status", "auth_stop" + case types.ExitNetwork: + return "network or safety state failure; inspect safety status", "network_state" + case types.ExitRateLimit: + return "request budget, cooldown or rate limit reached; inspect safety status", "rate_limit" + default: + return "command failed", "general" + } +} diff --git a/cmd/lnk/main_test.go b/cmd/lnk/main_test.go new file mode 100644 index 0000000..f36e305 --- /dev/null +++ b/cmd/lnk/main_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/yashiels/linkedin-cli/internal/types" +) + +func isolatedHome(t *testing.T) string { + t.Helper() + home, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("LNK_CONFIG", filepath.Join(home, "config.toml")) + return home +} +func TestErrorOutputModes(t *testing.T) { + for _, jsonMode := range []bool{false, true} { + for _, plain := range []bool{false, true} { + for _, verbose := range []bool{false, true} { + for _, debug := range []bool{false, true} { + for _, code := range []int{1, 2, 3, 4, 5} { + t.Run(fmt.Sprintf("%t/%t/%t/%t/%d", jsonMode, plain, verbose, debug, code), func(t *testing.T) { + isolatedHome(t) + root := newRootCmd() + for flag, value := range map[string]bool{"json": jsonMode, "plain": plain, "verbose": verbose, "debug": debug} { + if err := root.PersistentFlags().Set(flag, fmt.Sprint(value)); err != nil { + t.Fatal(err) + } + } + var output bytes.Buffer + err := fmt.Errorf("PERSONAL-SENTINEL https://URL-SENTINEL: %w", types.WrapError(code, "CODED-MESSAGE-SENTINEL https://PRIVATE-SENTINEL", &url.Error{URL: "https://URL-SENTINEL", Err: fmt.Errorf("BODY-SENTINEL")})) + if got := writeError(root, &output, err); got != code { + t.Fatalf("exit %d expected %d", got, code) + } + if strings.Contains(output.String(), "SENTINEL") { + t.Fatal("sentinel leaked") + } + if jsonMode { + var result map[string]any + if err = json.Unmarshal(output.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result["code"] != float64(code) || result["error"] != map[int]string{1: "command failed", 2: "invalid arguments or configuration", 3: "authentication or refusal stop; inspect safety status", 4: "network or safety state failure; inspect safety status", 5: "request budget, cooldown or rate limit reached; inspect safety status"}[code] { + t.Fatalf("wrong error shape: %v", result) + } + } + }) + } + } + } + } + } +} +func TestSafetyCommandsAreOfflineAndJSON(t *testing.T) { + home := isolatedHome(t) + for _, args := range [][]string{{"safety", "status", "--json"}, {"--json", "safety", "status"}, {"safety", "resume", "--confirm", "--json"}} { + var output bytes.Buffer + root := newRootCmd() + root.SetOut(&output) + root.SetErr(&output) + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var state map[string]any + if err := json.Unmarshal(output.Bytes(), &state); err != nil { + t.Fatal(err) + } + if state["admissions"] != float64(0) || state["budget"] != float64(100) { + t.Fatalf("unexpected local state %v", state) + } + } + if _, err := os.Stat(filepath.Join(home, ".config", "lnk", "credentials.json")); !os.IsNotExist(err) { + t.Fatal("safety touched credentials") + } + root := newRootCmd() + root.SetArgs([]string{"safety", "resume", "--json"}) + err := root.Execute() + var output bytes.Buffer + if got := writeError(root, &output, err); got != 2 || !json.Valid(output.Bytes()) { + t.Fatalf("confirmation: exit=%d %s", got, output.String()) + } +} +func TestUnsafeConfigAndFlagsExitUsage(t *testing.T) { + home := isolatedHome(t) + path := filepath.Join(home, "unsafe.toml") + if err := os.WriteFile(path, []byte("[safety]\nbudget=101\n"), 0600); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"--config", path, "safety", "status"}, {"--unknown"}} { + root := newRootCmd() + root.SetArgs(args) + err := root.Execute() + var out bytes.Buffer + if got := writeError(root, &out, err); got != 2 { + t.Fatalf("exit=%d error=%v", got, err) + } + } +} diff --git a/cmd/lnk/review_test.go b/cmd/lnk/review_test.go new file mode 100644 index 0000000..a717259 --- /dev/null +++ b/cmd/lnk/review_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/yashiels/linkedin-cli/internal/api" + "github.com/yashiels/linkedin-cli/internal/auth" + "github.com/yashiels/linkedin-cli/internal/types" +) + +type reviewTransport func(*http.Request) (*http.Response, error) + +func (f reviewTransport) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } + +func TestAPIStatusDiagnosticsAcrossModes(t *testing.T) { + for _, jsonMode := range []bool{false, true} { + for _, plain := range []bool{false, true} { + for _, verbose := range []bool{false, true} { + for _, debug := range []bool{false, true} { + for _, status := range []int{418, 500, 401, 429, 999, 302} { + for _, method := range []string{"GET", "POST", "PING"} { + t.Run(fmt.Sprintf("%t/%t/%t/%t/%d/%s", jsonMode, plain, verbose, debug, status, method), func(t *testing.T) { + isolatedHome(t) + var logs bytes.Buffer + client := api.New(auth.Credentials{LiAt: "COOKIE-SENTINEL", CSRFToken: "CSRF-SENTINEL"}, api.WithHTTPClient(&http.Client{Transport: reviewTransport(func(*http.Request) (*http.Response, error) { + headers := make(http.Header) + headers.Set("Location", "/authwall?URL-SENTINEL") + return &http.Response{StatusCode: status, Header: headers, Body: io.NopCloser(strings.NewReader("BODY-SENTINEL"))}, nil + })}), api.WithVerbose(verbose), api.WithDebug(debug), api.WithErrWriter(&logs)) + var err error + switch method { + case "GET": + _, err = client.Get("/voyager/api/fixture", nil) + case "POST": + _, err = client.Post("/voyager/api/fixture", map[string]string{"private": "REQUEST-SENTINEL"}) + case "PING": + _, err = client.Ping() + } + if err == nil { + t.Fatal("unexpected status accepted") + } + root := newRootCmd() + for flag, value := range map[string]bool{"json": jsonMode, "plain": plain, "verbose": verbose, "debug": debug} { + if flagErr := root.PersistentFlags().Set(flag, fmt.Sprint(value)); flagErr != nil { + t.Fatal(flagErr) + } + } + var output bytes.Buffer + got := writeError(root, &output, fmt.Errorf("WRAPPER-SENTINEL: %w", err)) + expected := 1 + if status == 401 || status == 999 || status == 302 { + expected = 3 + } else if status == 429 { + expected = 5 + } + if got != expected { + t.Fatalf("code=%d expected=%d", got, expected) + } + if strings.Contains(output.String()+logs.String(), "SENTINEL") { + t.Fatal("private diagnostic leaked") + } + httpMethod := method + operation := "normalized_read" + if method == "POST" { + operation = "post" + } else if method == "PING" { + httpMethod = "GET" + operation = "read" + } + if jsonMode { + var fields map[string]any + if err = json.Unmarshal(output.Bytes(), &fields); err != nil { + t.Fatal(err) + } + if fields["status"] != float64(status) || fields["method"] != httpMethod || fields["operation"] != operation { + t.Fatalf("structured HTTP context lost: %v", fields) + } + } else { + for _, field := range []string{fmt.Sprintf("status=%d", status), "method=" + httpMethod, "operation=" + operation} { + if !strings.Contains(output.String(), field) { + t.Fatalf("safe HTTP context lost: %s", output.String()) + } + } + } + }) + } + } + } + } + } + } +} + +func TestUntrustedDiagnosticMetadataIsFiltered(t *testing.T) { + for _, jsonMode := range []bool{false, true} { + isolatedHome(t) + root := newRootCmd() + if err := root.PersistentFlags().Set("json", fmt.Sprint(jsonMode)); err != nil { + t.Fatal(err) + } + coded := &types.LnkError{Code: 4, Message: "MESSAGE-SENTINEL", Cause: fmt.Errorf("CAUSE-SENTINEL"), Reason: types.DiagnosticReason(255), Method: "METHOD-SENTINEL", Operation: "OPERATION-SENTINEL", Status: 123456789} + var output bytes.Buffer + if got := writeError(root, &output, coded); got != 4 { + t.Fatal("exit code lost") + } + if strings.Contains(output.String(), "SENTINEL") || strings.Contains(output.String(), "123456789") { + t.Fatal("untrusted diagnostic metadata escaped allowlist") + } + } +} diff --git a/internal/api/client.go b/internal/api/client.go index 7a9206c..7ac1925 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -3,10 +3,15 @@ package api import ( "context" "encoding/json" + "errors" "fmt" + "github.com/yashiels/linkedin-cli/internal/config" + "github.com/yashiels/linkedin-cli/internal/safety" + "github.com/yashiels/linkedin-cli/internal/types" "io" "net/http" "net/url" + "sync" "time" "github.com/yashiels/linkedin-cli/internal/auth" @@ -17,8 +22,6 @@ const ( baseURL = "https://www.linkedin.com" graphQLPath = "/voyager/api/graphql" defaultTimeout = 30 * time.Second - maxRetries = 4 - baseBackoff = time.Second legacyAppVersion = "4.1.1209" legacyAppBuild = "211700" userAgent = "com.linkedin.android/" + legacyAppBuild + " (Linux; U; Android 16; en_ZA; " + @@ -38,12 +41,16 @@ type trackingHeader struct { } type Client struct { - http *http.Client - creds auth.Credentials - verbose bool - debug bool - errOut io.Writer - timeout *time.Duration + policy *safety.Policy + policyMutex sync.Mutex + policyReady bool + policyErr error + http *http.Client + creds auth.Credentials + verbose bool + debug bool + errOut io.Writer + timeout *time.Duration } type Option func(*Client) @@ -80,7 +87,7 @@ func New(creds auth.Credentials, opts ...Option) *Client { func (c *Client) QueryGraphQL(queryName, queryID string, variables interface{}) (json.RawMessage, error) { varStr, err := restli.Encode(variables) if err != nil { - return nil, fmt.Errorf("api: cannot encode variables: %w", err) + return nil, errors.New("api: cannot encode query variables") } query := "queryId=" + url.QueryEscape(queryID) + "&queryName=" + url.QueryEscape(queryName) + "&variables=" + varStr return c.readJSON(context.Background(), graphQLReadOperation, baseURL+graphQLPath+"?"+query) @@ -111,3 +118,19 @@ func (c *Client) injectHeaders(req *http.Request) { func (c *Client) logf(format string, args ...interface{}) { fmt.Fprintf(c.errOut, "[lnk] "+format+"\n", args...) } + +func (c *Client) requestPolicy() (*safety.Policy, error) { + c.policyMutex.Lock() + defer c.policyMutex.Unlock() + if c.policyReady || c.policy != nil { + return c.policy, c.policyErr + } + c.policyReady = true + cfg, err := config.Load() + if err != nil { + c.policyErr = types.NewError(2, "safety configuration could not be loaded") + return nil, c.policyErr + } + c.policy, c.policyErr = safety.Default(safety.Config{Delay: time.Duration(cfg.Safety.DelaySeconds) * time.Second, Budget: cfg.Safety.Budget}) + return c.policy, c.policyErr +} diff --git a/internal/api/policy_regression_test.go b/internal/api/policy_regression_test.go new file mode 100644 index 0000000..a3cfea7 --- /dev/null +++ b/internal/api/policy_regression_test.go @@ -0,0 +1,29 @@ +package api + +import ( + "errors" + "github.com/yashiels/linkedin-cli/internal/types" + "net/http" + "testing" +) + +func TestM2SingleShotRefusals(t *testing.T) { + for _, tc := range requestWrappers() { + for _, status := range []int{429, 401, 403, 999} { + t.Run(tc.name+http.StatusText(status), func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + calls := 0 + c := fixtureClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) { calls++; return fixtureResponse(status, ""), nil })) + err := tc.call(c) + expected := types.ExitAuth + if status == 429 { + expected = types.ExitRateLimit + } + var coded *types.LnkError + if !errors.As(err, &coded) || coded.Code != expected || calls != 1 { + t.Fatalf("expected exit %d and one admission; error=%v calls=%d", expected, err, calls) + } + }) + } + } +} diff --git a/internal/api/policy_test.go b/internal/api/policy_test.go new file mode 100644 index 0000000..d32aabc --- /dev/null +++ b/internal/api/policy_test.go @@ -0,0 +1,476 @@ +package api + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/yashiels/linkedin-cli/internal/auth" + "github.com/yashiels/linkedin-cli/internal/safety" + "github.com/yashiels/linkedin-cli/internal/types" +) + +type controlledBody struct { + reader io.Reader + close func() +} + +func (b *controlledBody) Read(p []byte) (int, error) { return b.reader.Read(p) } +func (b *controlledBody) Close() error { b.close(); return nil } + +type brokenReader struct{} + +func (brokenReader) Read([]byte) (int, error) { return 0, errors.New("BODY-READ-SENTINEL") } + +func TestRefusalPersistsAcrossClientsForEveryWrapper(t *testing.T) { + for _, tc := range requestWrappers() { + for _, status := range []int{429, 401, 403, 999, 302} { + t.Run(fmt.Sprintf("%s/%d", tc.name, status), func(t *testing.T) { + count := 0 + c := fixtureClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) { count++; return fixtureResponse(status, "/authwall"), nil })) + if err := tc.call(c); err == nil { + t.Fatal("first refusal succeeded") + } + fresh := New(auth.Credentials{}, WithHTTPClient(c.http), func(next *Client) { next.policy = c.policy }) + if err := tc.call(fresh); err == nil || count != 1 { + t.Fatalf("refusal not persistent: %v calls=%d", err, count) + } + }) + } + } +} +func TestRefusalSurvivesBodyFailures(t *testing.T) { + for _, status := range []int{200, 429, 401, 403, 999, 302} { + for _, kind := range []string{"read", "oversize"} { + t.Run(fmt.Sprintf("%d/%s", status, kind), func(t *testing.T) { + closed := 0 + var reader io.Reader = brokenReader{} + if kind == "oversize" { + reader = io.LimitReader(infiniteReader{}, safety.MaxBody+2) + } + c := fixtureClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) { + resp := fixtureResponse(status, "/authwall") + resp.Body = &controlledBody{reader: reader, close: func() { closed++ }} + return resp, nil + })) + _, err := c.Ping() + expected := 4 + if status == 429 { + expected = 5 + } else if status != 200 { + expected = 3 + } + var coded *types.LnkError + if !errors.As(err, &coded) || coded.Code != expected || closed != 1 { + t.Fatalf("error=%v closed=%d", err, closed) + } + state, e := c.policy.Status(context.Background()) + if e != nil { + t.Fatal(e) + } + if state.Admissions != 1 { + t.Fatal("failed read not counted") + } + if status != 200 && state.Block == "" && state.Cooldown.IsZero() { + t.Fatal("lost refusal during read error") + } + }) + } + } +} + +type infiniteReader struct{} + +func (infiniteReader) Read(b []byte) (int, error) { + for i := range b { + b[i] = 'x' + } + return len(b), nil +} + +type blockingReader struct { + entered chan struct{} + release chan struct{} + once sync.Once +} + +func (b *blockingReader) Read(p []byte) (int, error) { + b.once.Do(func() { close(b.entered) }) + <-b.release + return 0, io.EOF +} + +func TestBodyReadRetainsLeaseAndCancellationSendsZero(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err = os.Chmod(root, 0700); err != nil { + t.Fatal(err) + } + p, err := safety.New(root, safety.Config{}) + if err != nil { + t.Fatal(err) + } + body := &blockingReader{entered: make(chan struct{}), release: make(chan struct{})} + var calls atomic.Int32 + var closed atomic.Bool + c := New(auth.Credentials{}, WithHTTPClient(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + calls.Add(1) + resp := fixtureResponse(200, "") + resp.Body = &controlledBody{reader: body, close: func() { closed.Store(true) }} + return resp, nil + })}), func(c *Client) { c.policy = p }) + done := make(chan error, 1) + go func() { + resp, err := c.send(context.Background(), readOperation, baseURL+"/voyager/api/me", nil) + if resp != nil { + resp.Body.Close() + } + done <- err + }() + select { + case <-body.entered: + case <-time.After(3 * time.Second): + t.Fatal("body not entered") + } + fresh := New(auth.Credentials{}, WithHTTPClient(c.http), func(c *Client) { c.policy = p }) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + _, err = fresh.send(ctx, readOperation, baseURL+"/voyager/api/me", nil) + if err == nil || calls.Load() != 1 || closed.Load() { + t.Fatal("lease released before original body completed") + } + close(body.release) + if err = <-done; err != nil { + t.Fatal(err) + } + if !closed.Load() { + t.Fatal("original body not closed") + } +} + +func TestAdmissionCountsFailedSends(t *testing.T) { + c := fixtureClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("failed") })) + for i := 0; i < 100; i++ { + if _, err := c.Ping(); err == nil { + t.Fatal("failed send succeeded") + } + } + _, err := c.Ping() + var coded *types.LnkError + if !errors.As(err, &coded) || coded.Code != 5 { + t.Fatalf("failed sends did not consume quota: %v", err) + } +} + +func TestJSONFailuresAndEmptyMutation(t *testing.T) { + for _, body := range []string{"RESPONSE-SENTINEL", `{"errors":[{"message":"RESPONSE-SENTINEL"}]}`, `{"errors":"RESPONSE-SENTINEL"}`, `{"bad":`, "null", "[]"} { + c := fixtureClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) { + r := fixtureResponse(200, "") + r.Body = io.NopCloser(strings.NewReader(body)) + return r, nil + })) + _, err := c.Get("/voyager/api/me", nil) + if err == nil || strings.Contains(err.Error(), "SENTINEL") { + t.Fatalf("bad JSON accepted or leaked: %v", err) + } + status, e := c.policy.Status(context.Background()) + if e != nil { + t.Fatal(e) + } + if status.Block != "" || !status.Cooldown.IsZero() { + t.Fatal("body text latched auth stop") + } + } + c := fixtureClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) { + r := fixtureResponse(204, "") + r.Body = io.NopCloser(strings.NewReader("")) + return r, nil + })) + if _, err := c.Post("/voyager/api/fixture", nil); err != nil { + t.Fatal(err) + } +} + +func TestDiagnosticSentinelMatrix(t *testing.T) { + for _, flags := range []struct{ verbose, debug bool }{{false, false}, {true, false}, {false, true}, {true, true}} { + for _, kind := range []string{"success", "429", "401", "403", "999", "redirect", "oversize", "malformed", "transport", "url-error", "body-error"} { + t.Run(fmt.Sprintf("%v/%s", flags, kind), func(t *testing.T) { + var logs bytes.Buffer + c := fixtureClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) { + switch kind { + case "transport": + return nil, errors.New("TRANSPORT-SENTINEL https://sensitive.invalid/FULL-URL-SENTINEL") + case "url-error": + return nil, &url.Error{Op: "GET", URL: "https://sensitive.invalid/FULL-URL-SENTINEL", Err: errors.New("URL-ERROR-SENTINEL")} + } + r := fixtureResponse(200, "") + switch kind { + case "429": + r.StatusCode = 429 + case "401": + r.StatusCode = 401 + case "403": + r.StatusCode = 403 + case "999": + r.StatusCode = 999 + case "redirect": + r.StatusCode = 302 + r.Header.Set("Location", "/login?REDIRECT-SENTINEL") + case "oversize": + r.Body = io.NopCloser(io.LimitReader(infiniteReader{}, safety.MaxBody+1)) + case "malformed": + r.Body = io.NopCloser(strings.NewReader("BODY-SENTINEL")) + case "body-error": + r.Body = io.NopCloser(brokenReader{}) + } + return r, nil + }), WithVerbose(flags.verbose), WithDebug(flags.debug), WithErrWriter(&logs)) + root, rootErr := filepath.EvalSymlinks(t.TempDir()) + if rootErr != nil { + t.Fatal(rootErr) + } + if err := os.Chmod(root, 0700); err != nil { + t.Fatal(err) + } + policy, policyErr := safety.New(root, safety.Config{}) + if policyErr != nil { + t.Fatal(policyErr) + } + c.policy = policy + c.creds = auth.Credentials{LiAt: "COOKIE-SENTINEL", CSRFToken: "CSRF-SENTINEL"} + _, err := c.PostJSON("/voyager/api/fixture", map[string]string{"key": "QUERY-SENTINEL"}, map[string]string{"field": "REQUEST-BODY-SENTINEL", "urn": "urn:li:PERSONAL-SENTINEL"}) + if (kind == "success") != (err == nil) { + t.Fatalf("unexpected success/error: %v", err) + } + state, _ := c.policy.Status(context.Background()) + persisted, readErr := os.ReadFile(filepath.Join(root, "state.json")) + if readErr != nil { + t.Fatal(readErr) + } + diagnostics := fmt.Sprint(err) + logs.String() + fmt.Sprint(state) + string(persisted) + for _, sentinel := range []string{"COOKIE-SENTINEL", "CSRF-SENTINEL", "QUERY-SENTINEL", "REQUEST-BODY-SENTINEL", "PERSONAL-SENTINEL", "BODY-SENTINEL", "FULL-URL-SENTINEL", "TRANSPORT-SENTINEL", "URL-ERROR-SENTINEL", "REDIRECT-SENTINEL", "BODY-READ-SENTINEL"} { + if strings.Contains(diagnostics, sentinel) { + t.Fatalf("diagnostic leaked %s", sentinel) + } + } + }) + } + } +} + +func TestProductionClientInjectionCannotBypassPolicy(t *testing.T) { + home, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("LNK_CONFIG", filepath.Join(home, "alternate.toml")) + if err = os.WriteFile(filepath.Join(home, "alternate.toml"), []byte("[safety]\nbudget=1\ndelay_seconds=6\n"), 0600); err != nil { + t.Fatal(err) + } + calls := 0 + transport := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { calls++; return fixtureResponse(200, ""), nil })} + first := New(auth.Credentials{LiAt: "FIRST"}, WithHTTPClient(transport)) + if _, err = first.Ping(); err != nil { + t.Fatal(err) + } + second := New(auth.Credentials{LiAt: "SECOND"}, WithHTTPClient(transport)) + _, err = second.Ping() + var coded *types.LnkError + if !errors.As(err, &coded) || coded.Code != 5 || calls != 1 { + t.Fatalf("injection bypassed policy: %v calls=%d", err, calls) + } + if _, err = os.Stat(filepath.Join(home, ".local", "share", "lnk", "safety", "state.json")); err != nil { + t.Fatal("config moved policy root", err) + } +} + +func TestNegativeControlHarness(t *testing.T) { + if os.Getenv("LNK_TEST_NEGATIVE_CONTROL") == "1" { + t.Fatal("intentional false assertion") + } + command := exec.Command(os.Args[0], "-test.run=^TestNegativeControlHarness$") + command.Env = append(os.Environ(), "LNK_TEST_NEGATIVE_CONTROL=1") + output, err := command.CombinedOutput() + if err == nil || !strings.Contains(string(output), "intentional false assertion") || strings.Contains(string(output), "PASS") { + t.Fatalf("negative-control harness gave false success: %v %s", err, output) + } +} + +func TestAPIExchangeSubprocess(t *testing.T) { + root := os.Getenv("LNK_TEST_EXCHANGE_ROOT") + if root == "" { + return + } + clockPath := filepath.Join(filepath.Dir(root), "clock") + readClock := func() time.Time { + b, err := os.ReadFile(clockPath) + if err != nil { + panic(err) + } + var now time.Time + if err = now.UnmarshalText(b); err != nil { + panic(err) + } + return now + } + policy, err := safety.New(root, safety.Config{Budget: 3, Now: readClock, Wait: func(ctx context.Context, d time.Duration) error { + if d < time.Second { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } + } + data, _ := readClock().Add(d).MarshalText() + return os.WriteFile(clockPath, data, 0600) + }}) + if err != nil { + t.Fatal(err) + } + c := New(auth.Credentials{}, WithHTTPClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + local, err := http.NewRequestWithContext(req.Context(), http.MethodGet, os.Getenv("LNK_TEST_EXCHANGE_URL"), nil) + if err != nil { + return nil, err + } + return http.DefaultTransport.RoundTrip(local) + })}), func(c *Client) { c.policy = policy }) + _, err = c.Ping() + if err != nil { + var coded *types.LnkError + if !errors.As(err, &coded) || coded.Code != 5 { + t.Fatal(err) + } + fmt.Println("REFUSED") + return + } + fmt.Println("SENT") +} + +func TestAPIProcessesSerializeCompleteHTTPExchanges(t *testing.T) { + for run := 0; run < 5; run++ { + t.Run(fmt.Sprint(run), func(t *testing.T) { + parent, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + data, _ := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC).MarshalText() + if err = os.WriteFile(filepath.Join(parent, "clock"), data, 0600); err != nil { + t.Fatal(err) + } + var active, peak, calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + current := active.Add(1) + calls.Add(1) + for prior := peak.Load(); current > prior; prior = peak.Load() { + if peak.CompareAndSwap(prior, current) { + break + } + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + w.Write([]byte(`{"ok":`)) + w.(http.Flusher).Flush() + time.Sleep(10 * time.Millisecond) + active.Add(-1) + w.Write([]byte(`true}`)) + })) + defer server.Close() + var wg sync.WaitGroup + var sent, refused atomic.Int32 + for i := 0; i < 6; i++ { + wg.Add(1) + go func() { + defer wg.Done() + command := exec.Command(os.Args[0], "-test.run=^TestAPIExchangeSubprocess$", "-test.count=1") + command.Env = append(os.Environ(), "HOME="+parent, "LNK_TEST_EXCHANGE_ROOT="+filepath.Join(parent, "policy"), "LNK_TEST_EXCHANGE_URL="+server.URL) + output, err := command.CombinedOutput() + if err != nil { + t.Errorf("child failed %v %s", err, output) + } + if strings.Contains(string(output), "SENT") { + sent.Add(1) + } + if strings.Contains(string(output), "REFUSED") { + refused.Add(1) + } + }() + } + wg.Wait() + if calls.Load() != 3 || sent.Load() != 3 || refused.Load() != 3 || peak.Load() != 1 { + t.Fatalf("calls=%d sent=%d refused=%d peak=%d", calls.Load(), sent.Load(), refused.Load(), peak.Load()) + } + }) + } +} + +func TestUnsafeStateSendsZero(t *testing.T) { + for _, kind := range []string{"corrupt", "missing", "symlink", "permissions"} { + t.Run(kind, func(t *testing.T) { + home, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("LNK_CONFIG", filepath.Join(home, "missing.toml")) + calls := 0 + c := New(auth.Credentials{}, WithHTTPClient(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { calls++; return fixtureResponse(200, ""), nil })})) + if _, err = c.Ping(); err != nil { + t.Fatal(err) + } + state := filepath.Join(home, ".local", "share", "lnk", "safety", "state.json") + switch kind { + case "corrupt": + err = os.WriteFile(state, []byte("broken"), 0600) + case "missing": + err = os.Remove(state) + case "symlink": + if err = os.Remove(state); err == nil { + err = os.Symlink("/dev/null", state) + } + case "permissions": + err = os.Chmod(state, 0644) + } + if err != nil { + t.Fatal(err) + } + _, err = c.Ping() + var coded *types.LnkError + if !errors.As(err, &coded) || coded.Code != 4 || calls != 1 { + t.Fatalf("unsafe state sent: %v calls=%d", err, calls) + } + }) + } +} + +func TestRequestedProfileDataStillReturnedWithoutDiagnosticEcho(t *testing.T) { + const body = `{"firstName":"REQUESTED-PERSONAL-DATA","headline":"REQUESTED-PERSONAL-DATA"}` + var logs bytes.Buffer + c := fixtureClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) { + resp := fixtureResponse(200, "") + resp.Body = io.NopCloser(strings.NewReader(body)) + return resp, nil + }), WithDebug(true), WithVerbose(true), WithErrWriter(&logs)) + raw, err := c.GetProfileByIdentity("fixture") + if err != nil || string(raw) != body || strings.Contains(logs.String(), "REQUESTED-PERSONAL-DATA") { + t.Fatalf("data contract or diagnostic redaction failed: %v", err) + } +} diff --git a/internal/api/redirect_review_test.go b/internal/api/redirect_review_test.go new file mode 100644 index 0000000..7f85574 --- /dev/null +++ b/internal/api/redirect_review_test.go @@ -0,0 +1,57 @@ +package api + +import ( + "fmt" + "github.com/yashiels/linkedin-cli/internal/auth" + "net/http" + "strings" + "testing" +) + +func TestDeepAuthRedirectsPersistStop(t *testing.T) { + for _, depth := range []int{6, 12, 48, 4096} { + for _, authPath := range []string{"/%6cogin/step", "/%61uthwall", "/uas/%6cogin", "/voyager/api/../../checkpoint%2fchallenge/step", "/x/%2e%2e/%6cogin"} { + target := authPath + for i := 1; i < depth; i++ { + target = strings.ReplaceAll(target, "%", "%25") + } + t.Run(fmt.Sprintf("%d/%s", depth, authPath), func(t *testing.T) { + count := 0 + c := fixtureClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) { + count++ + return fixtureResponse(302, target+"?PRIVATE-REDIRECT-SENTINEL"), nil + })) + if _, err := c.Ping(); err == nil { + t.Fatal("redirect accepted") + } + fresh := New(auth.Credentials{}, WithHTTPClient(c.http), func(next *Client) { next.policy = c.policy }) + _, err := fresh.Ping() + if err == nil || count != 1 { + t.Fatalf("deep auth redirect did not latch: depth=%d count=%d error=%v", depth, count, err) + } + }) + } + } +} + +func TestOrdinaryAndMalformedRedirectsRemainRejected(t *testing.T) { + for _, target := range []string{"/ordinary?PRIVATE-SENTINEL", "/%zz?PRIVATE-SENTINEL", "/%252?PRIVATE-SENTINEL", "https://external.invalid/%256cogin?PRIVATE-SENTINEL"} { + t.Run(target, func(t *testing.T) { + calls := 0 + c := fixtureClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) { + calls++ + if calls == 1 { + return fixtureResponse(302, target), nil + } + return fixtureResponse(200, ""), nil + })) + _, err := c.Ping() + if err == nil || strings.Contains(err.Error(), "SENTINEL") { + t.Fatalf("redirect accepted or leaked: %v", err) + } + if _, err = c.Ping(); err != nil || calls != 2 { + t.Fatalf("ordinary redirect latched or followed: %v count=%d", err, calls) + } + }) + } +} diff --git a/internal/api/request.go b/internal/api/request.go index 9f09acc..c1706cb 100644 --- a/internal/api/request.go +++ b/internal/api/request.go @@ -5,15 +5,14 @@ import ( "context" "encoding/json" "errors" - "fmt" "io" - "net" "net/http" "net/url" "path" "strings" "time" + "github.com/yashiels/linkedin-cli/internal/safety" "github.com/yashiels/linkedin-cli/internal/types" ) @@ -61,16 +60,9 @@ func authRedirect(origin *url.URL, location string) bool { if err != nil || !linkedinOrigin(u) { return false } - p := u.EscapedPath() - for range 5 { - decoded, err := url.PathUnescape(p) - if err != nil { - return false - } - if decoded == p { - break - } - p = decoded + p, ok := decodeRedirectPath(u.EscapedPath()) + if !ok { + return false } p = path.Clean(p) for _, prefix := range []string{"/login", "/uas/login", "/authwall", "/checkpoint/challenge"} { @@ -81,15 +73,72 @@ func authRedirect(origin *url.URL, location string) bool { return false } -type responseBody struct { - io.ReadCloser - cancel context.CancelFunc +func decodeRedirectPath(p string) (string, bool) { + decoded := make([]byte, 0, len(p)) + remaining := len(p) / 2 + for i := range len(p) { + decoded = append(decoded, p[i]) + for len(decoded) >= 3 && decoded[len(decoded)-3] == '%' { + high, highOK := hexDigit(decoded[len(decoded)-2]) + low, lowOK := hexDigit(decoded[len(decoded)-1]) + if !highOK || !lowOK { + break + } + if remaining == 0 { + return "", false + } + remaining-- + decoded[len(decoded)-3] = high<<4 | low + decoded = decoded[:len(decoded)-2] + } + } + if bytes.IndexByte(decoded, '%') >= 0 { + return "", false + } + return string(decoded), true } -func (b *responseBody) Close() error { - err := b.ReadCloser.Close() - b.cancel() - return err +func hexDigit(b byte) (byte, bool) { + switch { + case b >= '0' && b <= '9': + return b - '0', true + case b >= 'a' && b <= 'f': + return b - 'a' + 10, true + case b >= 'A' && b <= 'F': + return b - 'A' + 10, true + default: + return 0, false + } +} + +func (op operation) name() string { + switch op { + case readOperation: + return "read" + case graphQLReadOperation: + return "graphql" + case normalizedReadOperation: + return "normalized_read" + case postOperation: + return "post" + case deleteOperation: + return "delete" + default: + return "" + } +} +func (op operation) method() string { + switch op { + case postOperation: + return http.MethodPost + case deleteOperation: + return http.MethodDelete + default: + return http.MethodGet + } +} +func requestFailure(op operation, status, code int, reason types.DiagnosticReason) error { + return types.WithRequestDetails(types.NewError(code, "LinkedIn request failed"), op.method(), op.name(), status, reason) } func (c *Client) send(ctx context.Context, op operation, target string, payload []byte) (*http.Response, error) { @@ -111,7 +160,17 @@ func (c *Client) send(ctx context.Context, op operation, target string, payload if timeout <= 0 { timeout = defaultTimeout } - requestCtx, cancel := context.WithTimeout(ctx, timeout) + policy, err := c.requestPolicy() + if err != nil { + return nil, err + } + requestCtx, cancel := context.WithTimeout(ctx, timeout+30*time.Second+policy.Delay()) + defer cancel() + lease, err := policy.Admit(requestCtx) + if err != nil { + return nil, err + } + defer lease.Close() req, err := http.NewRequestWithContext(requestCtx, method, u.String(), bytes.NewReader(payload)) if err != nil { cancel() @@ -131,72 +190,70 @@ func (c *Client) send(ctx context.Context, op operation, target string, payload c.logf("→ %s LinkedIn API", method) } resp, err := c.http.Do(req) + if err != nil { - cancel() - var timed net.Error - if errors.As(err, &timed) && timed.Timeout() { - return nil, types.NetworkError("LinkedIn request timed out", context.DeadlineExceeded) - } - return nil, types.NetworkError("LinkedIn request failed", nil) - } - if resp.StatusCode >= 300 && resp.StatusCode < 400 { - resp.Body.Close() - cancel() - if authRedirect(u, resp.Header.Get("Location")) { - return nil, types.AuthError("session expired — run 'lnk auth login' to refresh") + if resp != nil && resp.Body != nil { + resp.Body.Close() } - return nil, types.NetworkError("LinkedIn redirect rejected", nil) + return nil, requestFailure(op, 0, types.ExitNetwork, types.ReasonTransport) } - resp.Body = &responseBody{ReadCloser: resp.Body, cancel: cancel} + refusal := lease.Refusal(resp.StatusCode, resp.StatusCode >= 300 && resp.StatusCode < 400 && authRedirect(u, resp.Header.Get("Location")), resp.Header.Get("Retry-After")) + body, readErr := io.ReadAll(io.LimitReader(resp.Body, safety.MaxBody+1)) + closeErr := resp.Body.Close() if c.verbose || c.debug { c.logf("← %d LinkedIn API", resp.StatusCode) } + if refusal != nil { + return nil, types.WithRequestDetails(refusal, method, op.name(), resp.StatusCode, types.ReasonRefusal) + } + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + return nil, requestFailure(op, resp.StatusCode, types.ExitNetwork, types.ReasonRedirect) + } + if readErr != nil || closeErr != nil || requestCtx.Err() != nil { + return nil, requestFailure(op, resp.StatusCode, types.ExitNetwork, types.ReasonResponseRead) + } + if len(body) > safety.MaxBody { + return nil, requestFailure(op, resp.StatusCode, types.ExitNetwork, types.ReasonResponseSize) + } + resp.Body = io.NopCloser(bytes.NewReader(body)) return resp, nil } func (c *Client) readJSON(ctx context.Context, op operation, target string) (json.RawMessage, error) { - var lastErr error - for attempt := 0; attempt <= maxRetries; attempt++ { - if attempt > 0 { - wait := baseBackoff * time.Duration(1<<(attempt-1)) - c.logf("retrying LinkedIn read in %s (attempt %d/%d)", wait, attempt, maxRetries) - timer := time.NewTimer(wait) - select { - case <-ctx.Done(): - timer.Stop() - return nil, types.NetworkError("LinkedIn read canceled", nil) - case <-timer.C: - } - } - resp, err := c.send(ctx, op, target, nil) - if err != nil { - if errors.Is(err, context.DeadlineExceeded) { - lastErr = err - continue - } - return nil, err - } - if resp.StatusCode == http.StatusTooManyRequests { - resp.Body.Close() - lastErr = types.RateLimitError() - continue - } - if resp.StatusCode != http.StatusOK { - resp.Body.Close() - return nil, fmt.Errorf("api: unexpected status %d", resp.StatusCode) - } - body, readErr := io.ReadAll(resp.Body) - resp.Body.Close() - if readErr != nil { - lastErr = types.NetworkError("reading LinkedIn response failed", nil) - if op == graphQLReadOperation { - return nil, lastErr - } - continue + + resp, err := c.send(ctx, op, target, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, requestFailure(op, resp.StatusCode, types.ExitGeneral, types.ReasonHTTPStatus) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, requestFailure(op, resp.StatusCode, types.ExitNetwork, types.ReasonResponseRead) + } + if err = validateJSON(body); err != nil { + return nil, types.WithRequestDetails(err, op.method(), op.name(), resp.StatusCode, types.ReasonUnspecified) + } + return json.RawMessage(body), nil +} + +func validateJSON(body []byte) error { + if !json.Valid(body) { + return &types.LnkError{Code: types.ExitGeneral, Message: "api: invalid JSON response", Reason: types.ReasonJSON} + } + var envelope map[string]json.RawMessage + if json.Unmarshal(body, &envelope) != nil || envelope == nil { + return &types.LnkError{Code: types.ExitGeneral, Message: "api: invalid JSON response envelope", Reason: types.ReasonJSON} + } + if raw, exists := envelope["errors"]; exists && string(raw) != "null" { + var entries []json.RawMessage + if json.Unmarshal(raw, &entries) != nil || len(entries) > 0 { + return &types.LnkError{Code: types.ExitGeneral, Message: "api: GraphQL response contains errors", Reason: types.ReasonGraphQL} } - return json.RawMessage(body), nil } - return nil, lastErr + return nil } func (c *Client) mutateJSON(ctx context.Context, op operation, target string, payload interface{}, strictDelete bool) (json.RawMessage, error) { @@ -217,14 +274,17 @@ func (c *Client) mutateJSON(ctx context.Context, op operation, target string, pa return nil, types.RateLimitError() } if resp.StatusCode < 200 || resp.StatusCode >= 300 || (strictDelete && resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent) { - return nil, fmt.Errorf("api: unexpected status %d", resp.StatusCode) + return nil, requestFailure(op, resp.StatusCode, types.ExitGeneral, types.ReasonHTTPStatus) } body, err := io.ReadAll(resp.Body) if err != nil { - return nil, types.NetworkError("reading LinkedIn response failed", nil) + return nil, requestFailure(op, resp.StatusCode, types.ExitNetwork, types.ReasonResponseRead) } if len(body) == 0 { body = []byte("{}") } + if err := validateJSON(body); err != nil { + return nil, types.WithRequestDetails(err, op.method(), op.name(), resp.StatusCode, types.ReasonUnspecified) + } return json.RawMessage(body), nil } diff --git a/internal/api/request_test.go b/internal/api/request_test.go index 228b107..e998b41 100644 --- a/internal/api/request_test.go +++ b/internal/api/request_test.go @@ -6,12 +6,14 @@ import ( "encoding/json" "errors" "fmt" + "github.com/yashiels/linkedin-cli/internal/safety" "go/ast" "go/parser" "go/token" "io" "io/fs" "net/http" + "os" "path/filepath" "reflect" "strconv" @@ -60,7 +62,27 @@ func fixtureResponse(code int, location string) *http.Response { return &http.Response{StatusCode: code, Header: h, Body: io.NopCloser(strings.NewReader(`{}`))} } -func fixtureClient(rt http.RoundTripper, opts ...Option) *Client { +func fixtureClient(t *testing.T, rt http.RoundTripper, opts ...Option) *Client { + t.Helper() + root, err := filepath.EvalSymlinks(t.TempDir()) + if err := os.Chmod(root, 0700); err != nil { + t.Fatal(err) + } + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC) + policy, err := safety.New(root, safety.Config{Now: func() time.Time { return now }, Wait: func(ctx context.Context, d time.Duration) error { + if ctx.Err() != nil { + return ctx.Err() + } + now = now.Add(d) + return nil + }}) + if err != nil { + t.Fatal(err) + } + opts = append(opts, func(c *Client) { c.policy = policy }) return New(auth.Credentials{LiAt: "synthetic-li-at", CSRFToken: "synthetic-csrf"}, append([]Option{WithHTTPClient(&http.Client{Transport: rt})}, opts...)...) } @@ -101,7 +123,7 @@ func TestRequestWrappersHeaders(t *testing.T) { for _, tc := range requestWrappers() { t.Run(tc.name, func(t *testing.T) { calls := 0 - c := fixtureClient(roundTripFunc(func(req *http.Request) (*http.Response, error) { + c := fixtureClient(t, roundTripFunc(func(req *http.Request) (*http.Response, error) { calls++ if req.Method != tc.method || req.Header.Get("Accept") != tc.accept { t.Errorf("method/accept: %s %s", req.Method, req.Header.Get("Accept")) @@ -166,7 +188,7 @@ func TestRequestRedirects(t *testing.T) { t.Run(fmt.Sprintf("%s/%d/%s", tc.name, status, loc.target), func(t *testing.T) { calls := 0 var logs bytes.Buffer - c := fixtureClient(roundTripFunc(func(*http.Request) (*http.Response, error) { + c := fixtureClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) { calls++ if calls > 1 { return fixtureResponse(200, ""), nil @@ -210,7 +232,7 @@ func TestRequestClientOptions(t *testing.T) { if timeoutFirst { opts[0], opts[1] = opts[1], opts[0] } - c := New(auth.Credentials{}, opts...) + c := fixtureClient(t, caller.Transport, opts...) if c.http == caller || c.http.Timeout != 2*time.Second { t.Error("client ownership or explicit timeout lost") } @@ -233,7 +255,7 @@ func TestRequestMutationFailures(t *testing.T) { } t.Run(tc.name, func(t *testing.T) { calls := 0 - c := fixtureClient(roundTripFunc(func(*http.Request) (*http.Response, error) { + c := fixtureClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) { calls++ return nil, errors.New("transport synthetic-secret https://external.invalid/private") })) @@ -357,7 +379,7 @@ func TestRequestDestinations(t *testing.T) { t.Run(fmt.Sprintf("%d/%s", op, target), func(t *testing.T) { calls := 0 var logs bytes.Buffer - c := fixtureClient(roundTripFunc(func(*http.Request) (*http.Response, error) { calls++; return fixtureResponse(200, ""), nil }), WithDebug(true), WithErrWriter(&logs)) + c := fixtureClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) { calls++; return fixtureResponse(200, ""), nil }), WithDebug(true), WithErrWriter(&logs)) _, err := c.send(context.Background(), op, target, nil) if err == nil || calls != 0 { t.Fatalf("error=%v admissions=%d", err, calls) @@ -377,7 +399,7 @@ func TestRequestDestinations(t *testing.T) { func TestRequestPublicPathValidation(t *testing.T) { for _, target := range []string{"/login", "/voyager/api/../login", "/voyager/api/%2e%2e/login", "//external.invalid/voyager/api/me", "/voyager/api/me#synthetic-secret", "https://external.invalid/voyager/api/me"} { - c := fixtureClient(roundTripFunc(func(*http.Request) (*http.Response, error) { + c := fixtureClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) { t.Error("unsafe wrapper reached transport") return fixtureResponse(200, ""), nil })) @@ -402,7 +424,7 @@ func TestRequestPublicPathValidation(t *testing.T) { func TestRequestQueryEncoding(t *testing.T) { params := map[string]string{"action": "submit & continue?x=1#value", "key&=": "a+b / snow ☃"} calls := 0 - c := fixtureClient(roundTripFunc(func(req *http.Request) (*http.Response, error) { + c := fixtureClient(t, roundTripFunc(func(req *http.Request) (*http.Response, error) { calls++ for key, value := range params { if req.URL.Query().Get(key) != value { @@ -424,15 +446,15 @@ func TestRequestQueryEncoding(t *testing.T) { func TestRequestContextLifecycle(t *testing.T) { var request *http.Request - c := fixtureClient(roundTripFunc(func(req *http.Request) (*http.Response, error) { request = req; return fixtureResponse(200, ""), nil })) + c := fixtureClient(t, roundTripFunc(func(req *http.Request) (*http.Response, error) { request = req; return fixtureResponse(200, ""), nil })) ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() response, err := c.send(ctx, readOperation, baseURL+"/voyager/api/me", nil) if err != nil { t.Fatal(err) } - if request.Context().Err() != nil { - t.Fatal("context canceled before body consumed") + if !errors.Is(request.Context().Err(), context.Canceled) { + t.Fatal("context not canceled after buffering original body") } response.Body.Close() if !errors.Is(request.Context().Err(), context.Canceled) { @@ -447,7 +469,7 @@ func TestRequestMutationRateLimits(t *testing.T) { } t.Run(tc.name, func(t *testing.T) { calls := 0 - c := fixtureClient(roundTripFunc(func(*http.Request) (*http.Response, error) { calls++; return fixtureResponse(429, ""), nil })) + c := fixtureClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) { calls++; return fixtureResponse(429, ""), nil })) err := tc.call(c) var coded *types.LnkError if !errors.As(err, &coded) || coded.Code != types.ExitRateLimit || calls != 1 { @@ -477,17 +499,17 @@ func TestRequestReadRateLimitCompatibility(t *testing.T) { } t.Run(tc.name, func(t *testing.T) { calls := 0 - c := fixtureClient(roundTripFunc(func(*http.Request) (*http.Response, error) { + c := fixtureClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) { calls++ if calls == 1 { return fixtureResponse(429, ""), nil } return fixtureResponse(200, ""), nil })) - if err := tc.call(c); err != nil { - t.Fatal(err) + if err := tc.call(c); err == nil { + t.Fatal("refusal ignored") } - if calls != 2 { + if calls != 1 { t.Fatalf("admissions=%d", calls) } }) diff --git a/internal/api/rest.go b/internal/api/rest.go index 39653a9..94498de 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -3,7 +3,6 @@ package api import ( "context" "encoding/json" - "fmt" "io" "net/http" "net/url" @@ -37,20 +36,18 @@ func (c *Client) Ping() (string, error) { return "", err } defer resp.Body.Close() - if _, err := io.Copy(io.Discard, resp.Body); err != nil { + body, err := io.ReadAll(resp.Body) + if err != nil { return "", types.NetworkError("reading LinkedIn ping failed", nil) } if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { return "", types.AuthError("session expired or credentials invalid") } if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("api: ping returned %d", resp.StatusCode) - } - if pop := resp.Header.Get("x-li-pop"); pop != "" { - return pop, nil + return "", requestFailure(readOperation, resp.StatusCode, types.ExitGeneral, types.ReasonHTTPStatus) } - if via := resp.Header.Get("via"); via != "" { - return via, nil + if err := validateJSON(body); err != nil { + return "", types.WithRequestDetails(err, http.MethodGet, readOperation.name(), resp.StatusCode, types.ReasonUnspecified) } - return "unknown", nil + return "connected", nil } diff --git a/internal/cmd/apply_test.go b/internal/cmd/apply_test.go index 75423e4..005d04a 100644 --- a/internal/cmd/apply_test.go +++ b/internal/cmd/apply_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "io" "net/http" + "path/filepath" "strings" "testing" @@ -59,6 +60,12 @@ func TestApplyUnavailableCheckJSONIsUnverified(t *testing.T) { func runApplyJSONCommand(t *testing.T, detailBody, applyBody string, flags ...string) map[string]interface{} { t.Helper() + home, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("LNK_CONFIG", filepath.Join(home, "config.toml")) t.Setenv("LNK_LI_AT", "test-session") t.Setenv("LNK_CSRF_TOKEN", "test-csrf") originalFactory := newApplyClient diff --git a/internal/cmd/profile.go b/internal/cmd/profile.go index 43543f9..b86ab3d 100644 --- a/internal/cmd/profile.go +++ b/internal/cmd/profile.go @@ -2,6 +2,7 @@ package cmd import ( "encoding/json" + "errors" "fmt" "net/url" "strings" @@ -129,6 +130,9 @@ func fetchProfile(client *api.Client, vanityName, profileURN string, miniProfile // skills, certifications, and honors in a single request. // The old /voyager/api/identity/profiles/ is deprecated (410). dashRaw, dashErr := client.GetProfileByIdentity(vanityName) + if isRequestFailure(dashErr) { + return nil, dashErr + } if dashErr == nil { p, parseErr := parseDashProfile(dashRaw) if parseErr == nil && (p.FirstName != "" || p.LastName != "") { @@ -148,20 +152,26 @@ func fetchProfile(client *api.Client, vanityName, profileURN string, miniProfile } basicRaw, err := client.Get("/voyager/api/identity/profiles/"+url.PathEscape(vanityName), nil) + if isRequestFailure(err) { + return nil, err + } if err == nil { parseBasicProfile(p, basicRaw) } else { // REST endpoint is deprecated (410 Gone) — fall back to GraphQL. graphRaw, gerr := fetchProfileGraphQL(client, vanityName, profileURN) + if isRequestFailure(gerr) { + return nil, gerr + } if gerr == nil { parseBasicProfile(p, graphRaw) } // If all fetches failed, report the dash error (most informative). if p.FirstName == "" && p.LastName == "" && miniProfileRaw == nil { if dashErr != nil { - return nil, fmt.Errorf("fetching profile for %q: %w", vanityName, dashErr) + return nil, fmt.Errorf("fetching profile: %w", dashErr) } - return nil, fmt.Errorf("fetching profile for %q: %w", vanityName, err) + return nil, fmt.Errorf("fetching profile: %w", err) } } @@ -170,6 +180,9 @@ func fetchProfile(client *api.Client, vanityName, profileURN string, miniProfile "/voyager/api/identity/profiles/"+url.PathEscape(vanityName)+"/profileView", nil, ) + if isRequestFailure(viewErr) { + return nil, viewErr + } if viewErr == nil { parseProfileView(p, viewRaw) } @@ -179,6 +192,9 @@ func fetchProfile(client *api.Client, vanityName, profileURN string, miniProfile "/voyager/api/identity/profiles/"+url.PathEscape(vanityName)+"/networkinfo", nil, ) + if isRequestFailure(netErr) { + return nil, netErr + } if netErr == nil { parseNetworkInfo(p, netRaw) } @@ -322,6 +338,9 @@ func fetchProfileGraphQL(client *api.Client, vanityName, profileURN string) (jso "voyagerIdentityDashProfiles.f3eabbfa5c523c4af4d29c7de3a4a33e", vars, ) + if isRequestFailure(err) { + return nil, err + } if err == nil { if result := extractProfileFromGraphQL(raw); result != nil { return result, nil @@ -644,3 +663,8 @@ func firstNonEmpty(candidates ...string) string { } return "" } + +func isRequestFailure(err error) bool { + var coded *types.LnkError + return errors.As(err, &coded) && !(coded.Code == types.ExitGeneral && coded.Reason == types.ReasonHTTPStatus) +} diff --git a/internal/cmd/review_test.go b/internal/cmd/review_test.go new file mode 100644 index 0000000..03feb00 --- /dev/null +++ b/internal/cmd/review_test.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "testing" + + "github.com/yashiels/linkedin-cli/internal/types" +) + +func TestTypedHTTPStatusPreservesProfileFallbackContract(t *testing.T) { + for _, tc := range []struct { + code int + reason types.DiagnosticReason + stop bool + }{ + {1, types.ReasonHTTPStatus, false}, {1, types.ReasonJSON, true}, {1, types.ReasonGraphQL, true}, {3, types.ReasonRefusal, true}, {4, types.ReasonTransport, true}, {5, types.ReasonRefusal, true}, + } { + err := types.WithRequestDetails(types.NewError(tc.code, "untrusted"), "GET", "read", 410, tc.reason) + if got := isRequestFailure(err); got != tc.stop { + t.Fatalf("code=%d reason=%d stop=%t", tc.code, tc.reason, got) + } + } +} diff --git a/internal/cmd/safety.go b/internal/cmd/safety.go new file mode 100644 index 0000000..0cd7d57 --- /dev/null +++ b/internal/cmd/safety.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/spf13/cobra" + "github.com/yashiels/linkedin-cli/internal/config" + "github.com/yashiels/linkedin-cli/internal/safety" + "github.com/yashiels/linkedin-cli/internal/types" +) + +func NewSafetyCmd() *cobra.Command { + group := &cobra.Command{Use: "safety", Short: "Inspect or resume local request safety controls"} + var confirmed bool + for _, name := range []string{"status", "resume"} { + command := &cobra.Command{Use: name, Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 0 { + return types.NewError(2, "safety command accepts no arguments") + } + return nil + }, Short: "Show local request safety state"} + if name == "resume" { + command.Short = "Clear manual-review stop after inspecting the official app" + command.Flags().BoolVar(&confirmed, "confirm", false, "Confirm manual review; preserve cooldown and request budget") + } + + command.RunE = func(cmd *cobra.Command, args []string) error { + if name == "resume" && !confirmed { + return types.NewError(2, "safety resume requires --confirm after manual review") + } + cfg, err := config.Load() + if err != nil { + return types.NewError(2, "safety configuration could not be loaded") + } + policy, err := safety.Default(safety.Config{Delay: time.Duration(cfg.Safety.DelaySeconds) * time.Second, Budget: cfg.Safety.Budget}) + if err != nil { + return err + } + var state safety.Status + if name == "resume" { + state, err = policy.Resume(cmd.Context(), confirmed) + } else { + state, err = policy.Status(cmd.Context()) + } + if err != nil { + return err + } + jsonMode, _ := cmd.Root().PersistentFlags().GetBool("json") + if jsonMode { + return json.NewEncoder(cmd.OutOrStdout()).Encode(state) + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "Admissions: %d/%d (rolling 24h)\nMinimum delay: %.0fs\nManual stop: %s\nCooldown until: %s\n", state.Admissions, state.Budget, state.DelaySeconds, state.Block, state.Cooldown.UTC().Format(time.RFC3339)) + return err + } + group.AddCommand(command) + } + return group +} diff --git a/internal/cmd/status.go b/internal/cmd/status.go index 5b9779c..b855032 100644 --- a/internal/cmd/status.go +++ b/internal/cmd/status.go @@ -63,12 +63,14 @@ func runStatus(cmd *cobra.Command) error { apiStatus = "connected" apiPop = pop } else { - apiStatus = "error: " + pingErr.Error() + return pingErr } - // Best-effort: fetch own name. if apiStatus == "connected" { meRaw, meErr := client.Get("/voyager/api/me", nil) + if meErr != nil { + return meErr + } if meErr == nil { firstName := jstr(jget(meRaw, "miniProfile", "firstName")) lastName := jstr(jget(meRaw, "miniProfile", "lastName")) @@ -78,7 +80,7 @@ func runStatus(cmd *cobra.Command) error { } } } else { - apiStatus = "error: " + clientErr.Error() + return clientErr } } diff --git a/internal/config/config.go b/internal/config/config.go index 1b956eb..3712ef1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,8 +3,10 @@ package config import ( "fmt" + "github.com/yashiels/linkedin-cli/internal/safety" "os" "path/filepath" + "time" "github.com/BurntSushi/toml" ) @@ -17,6 +19,7 @@ const ( // Config is the top-level configuration structure. type Config struct { + Safety SafetyConfig `toml:"safety"` Defaults DefaultsConfig `toml:"defaults"` Display DisplayConfig `toml:"display"` } @@ -33,9 +36,14 @@ type DisplayConfig struct { Color bool `toml:"color"` } -// defaults returns a Config populated with built-in defaults. +type SafetyConfig struct { + DelaySeconds int `toml:"delay_seconds"` + Budget int `toml:"budget"` +} + func defaults() Config { return Config{ + Safety: SafetyConfig{DelaySeconds: 5, Budget: 100}, Defaults: DefaultsConfig{ Sort: "relevant", Limit: 25, @@ -61,13 +69,22 @@ func Load() (Config, error) { return cfg, nil } if err != nil { - return cfg, fmt.Errorf("config: cannot read %s: %w", path, err) + return cfg, fmt.Errorf("config: cannot read configuration") } - if _, err := toml.Decode(string(data), &cfg); err != nil { - return cfg, fmt.Errorf("config: cannot parse %s: %w", path, err) + metadata, err := toml.Decode(string(data), &cfg) + if err != nil { + return cfg, fmt.Errorf("config: cannot parse configuration") } + for _, key := range metadata.Undecoded() { + if len(key) > 0 && key[0] == "safety" { + return cfg, fmt.Errorf("config: unknown safety setting") + } + } + if cfg.Safety.DelaySeconds < 5 || cfg.Safety.DelaySeconds > int(safety.MaxDelay/time.Second) || cfg.Safety.Budget < 1 || cfg.Safety.Budget > 100 { + return cfg, fmt.Errorf("config: safety requires representable delay_seconds >= 5 and budget in [1,100]") + } return cfg, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..46de3bc --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,33 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSafetyConfiguration(t *testing.T) { + for _, tc := range []struct { + body string + valid bool + }{ + {"", true}, {"[safety]\nbudget=10\ndelay_seconds=20", true}, + {"[safety]\nbudget=101", false}, {"[safety]\nbudget=0", false}, + {"[safety]\ndelay_seconds=4", false}, {"[safety]\ndelay_seconds=0", false}, + {"[safety]\ndelay_seconds=172800", true}, {"[safety]\ndelay_seconds=9223372036854775807", false}, {"[safety]\nforce=true", false}, + } { + t.Run(tc.body, func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + path := filepath.Join(home, "config.toml") + t.Setenv("LNK_CONFIG", path) + if err := os.WriteFile(path, []byte(tc.body), 0600); err != nil { + t.Fatal(err) + } + cfg, err := Load() + if (err == nil) != tc.valid { + t.Fatalf("cfg=%v error=%v", cfg, err) + } + }) + } +} diff --git a/internal/safety/acl_darwin_test.go b/internal/safety/acl_darwin_test.go new file mode 100644 index 0000000..7ace4ed --- /dev/null +++ b/internal/safety/acl_darwin_test.go @@ -0,0 +1,45 @@ +package safety + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/unix" +) + +func TestNativeACLRefusesBeforeModeChecks(t *testing.T) { + p, _ := fixture(t, 100) + admit(t, p) + name := filepath.Join(p.root, "state.json") + command := exec.Command("/bin/chmod", "+a", "everyone allow read", name) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("native ACL fixture unavailable: %v %s", err, output) + } + defer exec.Command("/bin/chmod", "-N", name).Run() + if err := os.Chmod(name, 0600); err != nil { + t.Fatal(err) + } + fd, err := unix.Open(name, unix.O_RDONLY|unix.O_NOFOLLOW, 0) + if err != nil { + t.Fatal(err) + } + defer unix.Close(fd) + var st unix.Stat_t + if err = unix.Fstat(fd, &st); err != nil { + t.Fatal(err) + } + if st.Mode&07777 != 0600 || st.Uid != uint32(os.Getuid()) || st.Nlink != 1 { + t.Fatal("fixture would fail mode or ownership before ACL guard") + } + err = validateACL(fd) + code(t, err, 4) + if !strings.Contains(err.Error(), "ACL entries") { + t.Fatalf("did not reach native ACL entry guard: %v", err) + } + _, err = p.Admit(context.Background()) + code(t, err, 4) +} diff --git a/internal/safety/acl_linux_test.go b/internal/safety/acl_linux_test.go new file mode 100644 index 0000000..7a71fa9 --- /dev/null +++ b/internal/safety/acl_linux_test.go @@ -0,0 +1,114 @@ +package safety + +import ( + "bytes" + "context" + "encoding/binary" + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/sys/unix" +) + +func linuxACL(ownerPermissions uint16, namedUID uint32) []byte { + data := make([]byte, 4+5*8) + binary.LittleEndian.PutUint32(data[:4], 2) + for i, entry := range []struct { + tag, permissions uint16 + id uint32 + }{ + {0x01, ownerPermissions, 0xffffffff}, {0x02, 0, namedUID}, {0x04, 0, 0xffffffff}, {0x10, 0, 0xffffffff}, {0x20, 0, 0xffffffff}, + } { + offset := 4 + i*8 + binary.LittleEndian.PutUint16(data[offset:offset+2], entry.tag) + binary.LittleEndian.PutUint16(data[offset+2:offset+4], entry.permissions) + binary.LittleEndian.PutUint32(data[offset+4:offset+8], entry.id) + } + return data +} + +func TestNativeLinuxACLRefusal(t *testing.T) { + for _, kind := range []string{"access", "directory-default"} { + t.Run(kind, func(t *testing.T) { + p, _ := fixture(t, 100) + admit(t, p) + statePath := filepath.Join(p.root, "state.json") + beforeState, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + name := statePath + attribute := "system.posix_acl_access" + mode := uint32(0600) + fileType := uint32(unix.S_IFREG) + permissions := uint16(6) + if kind == "directory-default" { + name = p.root + attribute = "system.posix_acl_default" + mode = 0700 + fileType = unix.S_IFDIR + permissions = 7 + } + fd, err := unix.Open(name, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0) + if err != nil { + t.Fatal(err) + } + defer unix.Close(fd) + if os.Getuid() != os.Geteuid() { + t.Fatal("native ACL fixture requires matching real/effective UID") + } + acl := linuxACL(permissions, uint32(os.Geteuid())+1) + if err = unix.Fsetxattr(fd, attribute, acl, 0); err != nil { + t.Fatalf("supported Linux filesystem must exercise native %s ACL: %v", kind, err) + } + defer unix.Fremovexattr(fd, attribute) + var stat unix.Stat_t + if err = unix.Fstat(fd, &stat); err != nil { + t.Fatal(err) + } + if stat.Uid != uint32(os.Geteuid()) || uint32(stat.Mode)&07777 != mode || uint32(stat.Mode)&unix.S_IFMT != fileType || (fileType != unix.S_IFDIR && stat.Nlink != 1) || (fileType == unix.S_IFDIR && stat.Nlink == 0) { + t.Fatalf("ACL fixture rejected before ACL check: uid=%d mode=%o links=%d", stat.Uid, stat.Mode, stat.Nlink) + } + buffer := make([]byte, 1024) + n, err := unix.Fgetxattr(fd, attribute, buffer) + if err != nil || n < 44 || binary.LittleEndian.Uint32(buffer[:4]) != 2 { + t.Fatalf("native ACL not installed: bytes=%d error=%v", n, err) + } + err = validateACL(fd) + code(t, err, 4) + if !strings.Contains(err.Error(), "ACL entries") { + t.Fatalf("native ACL guard not reached: %v", err) + } + exchanges := 0 + lease, err := p.Admit(context.Background()) + if err == nil { + exchanges++ + lease.Close() + } + if exchanges != 0 { + t.Fatalf("unsafe ACL admitted %d exchanges: %v", exchanges, err) + } + code(t, err, 4) + afterState, err := os.ReadFile(statePath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(beforeState, afterState) { + t.Fatal("refused ACL admission changed persisted state") + } + if err = unix.Fremovexattr(fd, attribute); err != nil { + t.Fatalf("remove injected ACL: %v", err) + } + lease, err = p.Admit(context.Background()) + if err != nil { + t.Fatalf("valid-state control refused after removing injected ACL: %v", err) + } + lease.Close() + if got := len(readState(t, p).Admissions); got != 2 { + t.Fatalf("valid-state control admission count=%d, want 2", got) + } + }) + } +} diff --git a/internal/safety/platform_darwin.go b/internal/safety/platform_darwin.go new file mode 100644 index 0000000..6c7affb --- /dev/null +++ b/internal/safety/platform_darwin.go @@ -0,0 +1,59 @@ +package safety + +import ( + "encoding/binary" + "syscall" + "unsafe" + + "golang.org/x/sys/unix" +) + +func validateACL(fd int) error { + attrs := struct { + Count uint16 + Reserved uint16 + Common uint32 + Volume uint32 + Directory uint32 + File uint32 + Fork uint32 + }{Count: 5, Common: unix.ATTR_CMN_EXTENDED_SECURITY} + var buffer [4096]byte + _, _, errno := syscall.Syscall6(unix.SYS_FGETATTRLIST, uintptr(fd), uintptr(unsafe.Pointer(&attrs)), uintptr(unsafe.Pointer(&buffer[0])), uintptr(len(buffer)), 0, 0) + if errno != 0 { + return failure(4, "safety ACL inspection unsupported; use a supported local filesystem") + } + size := int(binary.LittleEndian.Uint32(buffer[:4])) + offset := 4 + int(int32(binary.LittleEndian.Uint32(buffer[4:8]))) + length := int(binary.LittleEndian.Uint32(buffer[8:12])) + if size < 12 || size > len(buffer) { + return stateError() + } + if length == 0 { + return nil + } + if offset < 12 || offset+length > size { + return stateError() + } + + if length < 44 || binary.LittleEndian.Uint32(buffer[offset:offset+4]) != 0x012cc16d { + return stateError() + } + entries := binary.LittleEndian.Uint32(buffer[offset+36 : offset+40]) + if entries != 0 && entries != 0xffffffff { + return failure(4, "safety files must have no extended ACL entries; review local ACLs") + } + return nil +} + +func validateFilesystem(fd int) error { + var st unix.Statfs_t + if unix.Fstatfs(fd, &st) != nil { + return stateError() + } + name := unix.ByteSliceToString(st.Fstypename[:]) + if st.Flags&unix.MNT_LOCAL == 0 || (name != "apfs" && name != "hfs") { + return failure(4, "safety requires a supported local APFS or HFS filesystem") + } + return nil +} diff --git a/internal/safety/platform_linux.go b/internal/safety/platform_linux.go new file mode 100644 index 0000000..dd7bb2e --- /dev/null +++ b/internal/safety/platform_linux.go @@ -0,0 +1,34 @@ +package safety + +import ( + "errors" + "golang.org/x/sys/unix" +) + +func validateACL(fd int) error { + for _, name := range []string{"system.posix_acl_access", "system.posix_acl_default"} { + n, err := unix.Fgetxattr(fd, name, nil) + if errors.Is(err, unix.ENODATA) { + continue + } + if err != nil { + return failure(4, "safety ACL inspection unsupported; use a supported local filesystem") + } + if n > 0 { + return failure(4, "safety files must have no extended ACL entries; review local ACLs") + } + } + return nil +} + +func validateFilesystem(fd int) error { + var st unix.Statfs_t + if unix.Fstatfs(fd, &st) != nil { + return stateError() + } + switch uint64(st.Type) { + case unix.EXT4_SUPER_MAGIC, unix.XFS_SUPER_MAGIC, unix.BTRFS_SUPER_MAGIC, unix.TMPFS_MAGIC, unix.OVERLAYFS_SUPER_MAGIC: + return nil + } + return failure(4, "safety requires a supported local filesystem") +} diff --git a/internal/safety/policy.go b/internal/safety/policy.go new file mode 100644 index 0000000..4cff406 --- /dev/null +++ b/internal/safety/policy.go @@ -0,0 +1,381 @@ +package safety + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "math" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/yashiels/linkedin-cli/internal/types" + "golang.org/x/sys/unix" +) + +const MaxBody = 16 << 20 +const MaxDelay = time.Duration(1<<63-1) - 2*time.Minute + +type Config struct { + Delay time.Duration + Budget int + Now func() time.Time + Wait func(context.Context, time.Duration) error +} + +type Policy struct { + root string + cfg Config + lockTimeout time.Duration +} + +type State struct { + Version int `json:"version"` + Admissions []time.Time `json:"admissions"` + Cooldown time.Time `json:"cooldown"` + Block string `json:"block"` + LastClock time.Time `json:"last_clock"` + LastAdmission time.Time `json:"last_admission"` +} + +type Status struct { + Admissions int `json:"admissions"` + Budget int `json:"budget"` + DelaySeconds float64 `json:"delay_seconds"` + Cooldown time.Time `json:"cooldown"` + Block string `json:"block"` +} + +type Lease struct { + policy *Policy + storage *store + state State +} + +func failure(code int, message string) error { return types.NewError(code, message) } + +func New(root string, cfg Config) (*Policy, error) { + if cfg.Delay == 0 { + cfg.Delay = 5 * time.Second + } + if cfg.Budget == 0 { + cfg.Budget = 100 + } + if cfg.Delay < 5*time.Second || cfg.Delay > MaxDelay || cfg.Budget < 1 || cfg.Budget > 100 { + return nil, failure(2, "safety configuration requires representable delay >= 5 seconds and budget in [1,100]") + } + if cfg.Now == nil { + cfg.Now = time.Now + } + if cfg.Wait == nil { + cfg.Wait = wait + } + return &Policy{root: root, cfg: cfg, lockTimeout: 30 * time.Second}, nil +} + +func Default(cfg Config) (*Policy, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, stateError() + } + return New(filepath.Join(home, ".local", "share", "lnk", "safety"), cfg) +} + +func wait(ctx context.Context, d time.Duration) error { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return failure(4, "safety wait canceled") + case <-timer.C: + return nil + } +} + +func (p *Policy) Delay() time.Duration { return p.cfg.Delay } + +func (p *Policy) lock(ctx context.Context) (*Lease, error) { + if ctx.Err() != nil { + return nil, failure(4, "safety request canceled") + } + s, err := openStore(p.root) + if err != nil { + return nil, err + } + acquired := false + defer func() { + if !acquired { + s.close() + } + }() + deadline := time.Now().Add(p.lockTimeout) + for { + if ctx.Err() != nil { + return nil, failure(4, "safety request canceled") + } + err = unix.Flock(int(s.lock.Fd()), unix.LOCK_EX|unix.LOCK_NB) + if err == nil { + break + } + if !errors.Is(err, unix.EWOULDBLOCK) && !errors.Is(err, unix.EAGAIN) { + return nil, stateError() + } + remaining := time.Until(deadline) + if remaining <= 0 { + return nil, failure(5, "safety request lock timed out") + } + if err = p.cfg.Wait(ctx, min(remaining, 25*time.Millisecond)); err != nil { + return nil, err + } + } + l := &Lease{policy: p, storage: s} + if err = l.load(); err != nil { + unix.Flock(int(s.lock.Fd()), unix.LOCK_UN) + return nil, err + } + acquired = true + return l, nil +} + +func (l *Lease) Close() { unix.Flock(int(l.storage.lock.Fd()), unix.LOCK_UN); l.storage.close() } + +func (l *Lease) load() error { + marker, me := l.storage.read("initialized") + data, se := l.storage.read("state.json") + if errors.Is(me, os.ErrNotExist) && errors.Is(se, os.ErrNotExist) { + if err := l.storage.create("initialized", []byte("lnk-safety-v2\n")); err != nil { + return err + } + l.state = State{Version: 2, Admissions: []time.Time{}} + return l.save() + } + if me != nil || se != nil || string(marker) != "lnk-safety-v2\n" { + return stateError() + } + var fields map[string]json.RawMessage + if json.Unmarshal(data, &fields) != nil || len(fields) != 6 { + return stateError() + } + for _, key := range []string{"version", "admissions", "cooldown", "block", "last_clock", "last_admission"} { + if _, present := fields[key]; !present { + return stateError() + } + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if decoder.Decode(&l.state) != nil { + return stateError() + } + var extra any + if decoder.Decode(&extra) != io.EOF { + return stateError() + } + s := l.state + if s.Version != 2 || s.Admissions == nil || len(s.Admissions) > 100 { + return stateError() + } + switch s.Block { + case "", "auth", "retry_after_overflow": + default: + return stateError() + } + if string(fields["last_admission"]) == "null" || !validTime(s.LastAdmission) || s.LastAdmission.After(s.LastClock) || !validTime(s.LastClock) || !validTime(s.Cooldown) { + return stateError() + } + for i, admission := range s.Admissions { + if admission.IsZero() || !validTime(admission) || admission.After(s.LastClock) || (i > 0 && admission.Before(s.Admissions[i-1])) { + return stateError() + } + } + if n := len(s.Admissions); n > 0 { + if !s.LastAdmission.Equal(s.Admissions[n-1]) { + return stateError() + } + } else if !s.LastAdmission.IsZero() && s.LastAdmission.After(s.LastClock.Add(-24*time.Hour)) { + return stateError() + } + if !s.Cooldown.IsZero() && s.LastClock.IsZero() { + return stateError() + } + return nil +} + +func validTime(t time.Time) bool { return t.IsZero() || (t.Year() >= 1970 && t.Year() <= 9999) } + +func (l *Lease) save() error { + b, err := json.Marshal(l.state) + if err != nil { + return stateError() + } + return l.storage.write(b) +} + +func (l *Lease) observe() (time.Time, error) { + now := l.policy.cfg.Now().UTC() + if !validTime(now) || now.IsZero() || now.Before(l.state.LastClock) { + return time.Time{}, failure(4, "safety clock moved backward or is invalid; review system time") + } + l.state.LastClock = now + first := 0 + for first < len(l.state.Admissions) && !l.state.Admissions[first].After(now.Add(-24*time.Hour)) { + first++ + } + l.state.Admissions = append([]time.Time{}, l.state.Admissions[first:]...) + if err := l.save(); err != nil { + return time.Time{}, err + } + return now, nil +} + +func (l *Lease) blocked(now time.Time) error { + if l.state.Block == "retry_after_overflow" { + return failure(5, "safety Retry-After exceeds supported time; manual review required") + } + if l.state.Block != "" { + return failure(3, "safety manual review required; inspect official app before safety resume --confirm") + } + if now.Before(l.state.Cooldown) { + return failure(5, "safety cooldown active") + } + return nil +} + +func (p *Policy) Admit(ctx context.Context) (*Lease, error) { + l, err := p.lock(ctx) + if err != nil { + return nil, err + } + success := false + defer func() { + if !success { + l.Close() + } + }() + for { + now, err := l.observe() + if err != nil { + return nil, err + } + if err = l.blocked(now); err != nil { + return nil, err + } + if len(l.state.Admissions) >= p.cfg.Budget { + return nil, failure(5, "safety rolling request budget exhausted") + } + if !l.state.LastAdmission.IsZero() { + delay := l.state.LastAdmission.Add(p.cfg.Delay).Sub(now) + if delay > 0 { + if err = p.cfg.Wait(ctx, delay); err != nil { + return nil, err + } + continue + } + } + if ctx.Err() != nil { + return nil, failure(4, "safety request canceled") + } + l.state.Admissions = append(l.state.Admissions, now) + l.state.LastAdmission = now + if err = l.save(); err != nil { + return nil, err + } + success = true + return l, nil + } +} + +func retryDeadline(raw string, now time.Time) (time.Time, bool) { + floor := now.Add(30 * time.Minute) + if !validTime(floor) { + return time.Time{}, false + } + raw = strings.TrimSpace(raw) + if raw != "" && strings.Trim(raw, "0123456789") == "" { + seconds, err := strconv.ParseUint(raw, 10, 64) + if err != nil || seconds > uint64(math.MaxInt64/int64(time.Second)) { + return time.Time{}, false + } + result := now.Add(time.Duration(seconds) * time.Second) + if !validTime(result) { + return time.Time{}, false + } + if result.After(floor) { + return result, true + } + return floor, true + } + if date, err := http.ParseTime(raw); err == nil { + if !validTime(date) { + return time.Time{}, false + } + if date.After(floor) { + return date.UTC(), true + } + } + return floor, true +} + +func (l *Lease) Refusal(status int, authRedirect bool, retryAfter string) error { + if status != 429 && status != 401 && status != 403 && status != 999 && !authRedirect { + return nil + } + now := l.policy.cfg.Now().UTC() + rollback := !validTime(now) || now.IsZero() || now.Before(l.state.LastClock) + if rollback { + now = l.state.LastClock + } else { + l.state.LastClock = now + } + code := 3 + if status == 429 { + code = 5 + deadline, ok := retryDeadline(retryAfter, now) + if !ok { + l.state.Block = "retry_after_overflow" + } else if deadline.After(l.state.Cooldown) { + l.state.Cooldown = deadline + } + } else if l.state.Block != "retry_after_overflow" { + l.state.Block = "auth" + } + if err := l.save(); err != nil { + return err + } + if rollback { + return failure(4, "safety clock moved backward; refusal persisted") + } + return failure(code, "LinkedIn refused request; safety stop persisted") +} + +func (p *Policy) Status(ctx context.Context) (Status, error) { return p.inspect(ctx, false) } +func (p *Policy) Resume(ctx context.Context, confirmed bool) (Status, error) { + if !confirmed { + return Status{}, failure(2, "safety resume requires --confirm after manual review") + } + return p.inspect(ctx, true) +} +func (p *Policy) inspect(ctx context.Context, resume bool) (Status, error) { + l, err := p.lock(ctx) + if err != nil { + return Status{}, err + } + defer l.Close() + if _, err = l.observe(); err != nil { + return Status{}, err + } + if resume { + if l.state.Block == "retry_after_overflow" { + return Status{}, failure(5, "safety Retry-After requires manual state review; resume cannot clear it") + } + l.state.Block = "" + if err = l.save(); err != nil { + return Status{}, err + } + } + return Status{Admissions: len(l.state.Admissions), Budget: p.cfg.Budget, DelaySeconds: p.cfg.Delay.Seconds(), Cooldown: l.state.Cooldown, Block: l.state.Block}, nil +} diff --git a/internal/safety/policy_test.go b/internal/safety/policy_test.go new file mode 100644 index 0000000..30ab204 --- /dev/null +++ b/internal/safety/policy_test.go @@ -0,0 +1,522 @@ +package safety + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/yashiels/linkedin-cli/internal/types" + "golang.org/x/sys/unix" +) + +type clock struct { + now time.Time + waits []time.Duration +} + +func fixture(t *testing.T, budget int) (*Policy, *clock) { + t.Helper() + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err = os.Chmod(root, 0700); err != nil { + t.Fatal(err) + } + c := &clock{now: time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)} + p, err := New(root, Config{Budget: budget, Now: func() time.Time { return c.now }, Wait: func(ctx context.Context, d time.Duration) error { + if ctx.Err() != nil { + return failure(4, "canceled") + } + c.waits = append(c.waits, d) + c.now = c.now.Add(d) + return nil + }}) + if err != nil { + t.Fatal(err) + } + return p, c +} +func code(t *testing.T, err error, want int) { + t.Helper() + var coded *types.LnkError + if !errors.As(err, &coded) || coded.Code != want { + t.Fatalf("expected exit %d; got %v", want, err) + } +} +func admit(t *testing.T, p *Policy) { + t.Helper() + lease, err := p.Admit(context.Background()) + if err != nil { + t.Fatal(err) + } + lease.Close() +} +func readState(t *testing.T, p *Policy) State { + t.Helper() + b, err := os.ReadFile(filepath.Join(p.root, "state.json")) + if err != nil { + t.Fatal(err) + } + var s State + if err = json.Unmarshal(b, &s); err != nil { + t.Fatal(err) + } + return s +} + +func TestSpacingBoundaries(t *testing.T) { + for _, elapsed := range []time.Duration{0, 5*time.Second - time.Nanosecond, 5 * time.Second, 6 * time.Second} { + t.Run(elapsed.String(), func(t *testing.T) { + p, c := fixture(t, 100) + admit(t, p) + start := c.now + c.now = c.now.Add(elapsed) + admit(t, p) + expected := max(elapsed, 5*time.Second) + if c.now.Sub(start) != expected { + t.Fatalf("elapsed=%s expected=%s", c.now.Sub(start), expected) + } + if elapsed >= 5*time.Second && len(c.waits) != 0 { + t.Fatal("waited at allowed boundary") + } + }) + } +} +func TestRollingBudgetAndExpiry(t *testing.T) { + p, c := fixture(t, 100) + first := c.now + for i := 0; i < 100; i++ { + admit(t, p) + } + _, err := p.Admit(context.Background()) + code(t, err, 5) + if len(readState(t, p).Admissions) != 100 { + t.Fatal("budget not durable") + } + c.now = first.Add(24*time.Hour - time.Nanosecond) + _, err = p.Admit(context.Background()) + code(t, err, 5) + c.now = first.Add(24 * time.Hour) + admit(t, p) + s := readState(t, p) + if len(s.Admissions) != 100 || !s.Admissions[99].Equal(c.now) { + t.Fatal("24h boundary incorrect") + } +} +func TestBackwardClock(t *testing.T) { + p, c := fixture(t, 100) + admit(t, p) + before := readState(t, p) + c.now = c.now.Add(-time.Nanosecond) + _, err := p.Admit(context.Background()) + code(t, err, 4) + _, err = p.Resume(context.Background(), true) + code(t, err, 4) + after := readState(t, p) + if !before.LastClock.Equal(after.LastClock) || len(after.Admissions) != 1 { + t.Fatal("clock rollback rewrote history") + } +} +func TestRetryAfter(t *testing.T) { + now := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC) + for _, tc := range []struct { + name, header string + want time.Duration + overflow bool + }{ + {"missing", "", 30 * time.Minute, false}, {"invalid", "garbage", 30 * time.Minute, false}, + {"negative", "-10", 30 * time.Minute, false}, {"past", now.Add(-time.Hour).Format(time.RFC1123), 30 * time.Minute, false}, + {"seconds", "3600", time.Hour, false}, {"floor", "1", 30 * time.Minute, false}, + {"date", now.Add(48 * time.Hour).Format("Mon, 02 Jan 2006 15:04:05 GMT"), 48 * time.Hour, false}, + {"long", "3153600000", 100 * 365 * 24 * time.Hour, false}, + {"overflow", "18446744073709551616", 0, true}, {"duration-overflow", "9223372036854775807", 0, true}, + } { + t.Run(tc.name, func(t *testing.T) { + p, c := fixture(t, 100) + c.now = now + l, err := p.Admit(context.Background()) + if err != nil { + t.Fatal(err) + } + code(t, l.Refusal(429, false, tc.header), 5) + l.Close() + fresh, err := New(p.root, p.cfg) + if err != nil { + t.Fatal(err) + } + s := readState(t, p) + if tc.overflow { + if s.Block != "retry_after_overflow" { + t.Fatal("overflow not latched") + } + _, err = fresh.Resume(context.Background(), true) + code(t, err, 5) + } else { + if !s.Cooldown.Equal(now.Add(tc.want)) { + t.Fatalf("deadline %v expected %v", s.Cooldown, now.Add(tc.want)) + } + _, err = fresh.Resume(context.Background(), true) + if err != nil { + t.Fatal(err) + } + if !readState(t, p).Cooldown.Equal(s.Cooldown) { + t.Fatal("resume changed cooldown") + } + } + _, err = fresh.Admit(context.Background()) + code(t, err, 5) + if len(readState(t, p).Admissions) != 1 { + t.Fatal("refused admission counted") + } + if !tc.overflow { + c.now = s.Cooldown + admit(t, fresh) + } + }) + } +} +func TestResumePreservesBudgetAndMarker(t *testing.T) { + p, _ := fixture(t, 1) + l, err := p.Admit(context.Background()) + if err != nil { + t.Fatal(err) + } + code(t, l.Refusal(403, false, ""), 3) + l.Close() + before := readState(t, p) + _, err = p.Resume(context.Background(), false) + code(t, err, 2) + _, err = p.Admit(context.Background()) + code(t, err, 3) + status, err := p.Resume(context.Background(), true) + if err != nil { + t.Fatal(err) + } + if status.Block != "" || status.Admissions != 1 { + t.Fatal("resume reset budget") + } + _, err = p.Admit(context.Background()) + code(t, err, 5) + after := readState(t, p) + if !after.Admissions[0].Equal(before.Admissions[0]) { + t.Fatal("resume changed history") + } + if _, err = os.Stat(filepath.Join(p.root, "initialized")); err != nil { + t.Fatal(err) + } +} +func TestCanceledAndLockTimeout(t *testing.T) { + p, _ := fixture(t, 100) + l, err := p.Admit(context.Background()) + if err != nil { + t.Fatal(err) + } + defer l.Close() + fresh, err := New(p.root, Config{}) + if err != nil { + t.Fatal(err) + } + fresh.lockTimeout = 20 * time.Millisecond + _, err = fresh.Admit(context.Background()) + code(t, err, 5) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = fresh.Admit(ctx) + code(t, err, 4) + if len(readState(t, p).Admissions) != 1 { + t.Fatal("wait consumed quota") + } +} +func TestUnsafeStateFixtures(t *testing.T) { + for _, kind := range []string{"marker-missing", "state-missing", "corrupt", "unknown-field", "missing-field", "file-mode", "directory-mode", "state-symlink", "marker-symlink", "lock-symlink", "ancestor-symlink", "owner", "hardlink"} { + t.Run(kind, func(t *testing.T) { + p, _ := fixture(t, 100) + admit(t, p) + statePath := filepath.Join(p.root, "state.json") + switch kind { + case "marker-missing": + os.Remove(filepath.Join(p.root, "initialized")) + case "state-missing": + os.Remove(statePath) + case "corrupt": + os.WriteFile(statePath, []byte("not json"), 0600) + case "unknown-field": + os.WriteFile(statePath, []byte(`{"version":2,"admissions":[],"secret":"synthetic"}`), 0600) + case "missing-field": + os.WriteFile(statePath, []byte(`{"version":2,"admissions":[]}`), 0600) + case "file-mode": + os.Chmod(statePath, 0644) + case "directory-mode": + os.Chmod(p.root, 0755) + case "state-symlink", "marker-symlink", "lock-symlink": + name := map[string]string{"state-symlink": "state.json", "marker-symlink": "initialized", "lock-symlink": "lock"}[kind] + os.Remove(filepath.Join(p.root, name)) + if err := os.Symlink("/dev/null", filepath.Join(p.root, name)); err != nil { + t.Fatal(err) + } + case "ancestor-symlink": + parent := filepath.Dir(p.root) + link := filepath.Join(parent, "link") + if err := os.Symlink(p.root, link); err != nil { + t.Fatal(err) + } + p.root = link + case "owner": + fd, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY, 0) + if err != nil { + t.Fatal(err) + } + defer unix.Close(fd) + var st unix.Stat_t + if err = unix.Fstat(fd, &st); err != nil { + t.Fatal(err) + } + if st.Uid == uint32(os.Getuid()) { + t.Skip("test requires a non-root caller") + } + code(t, validateFD(fd, true, true), 4) + return + + case "hardlink": + if err := os.Link(statePath, filepath.Join(p.root, "alias")); err != nil { + t.Fatal(err) + } + } + _, err := p.Admit(context.Background()) + code(t, err, 4) + }) + } +} +func TestStrictConfig(t *testing.T) { + for _, cfg := range []Config{{Delay: time.Second}, {Budget: 101}, {Budget: -1}, {Delay: -time.Second}, {Delay: MaxDelay + time.Second}} { + _, err := New("/unused", cfg) + code(t, err, 2) + } +} +func TestStatePermissions(t *testing.T) { + p, _ := fixture(t, 100) + admit(t, p) + for _, name := range []string{"lock", "initialized", "state.json"} { + info, err := os.Stat(filepath.Join(p.root, name)) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("unsafe %s mode %v", name, info.Mode()) + } + } +} + +func TestPolicySubprocess(t *testing.T) { + mode := os.Getenv("LNK_TEST_CHILD_MODE") + if mode == "" { + return + } + root := os.Getenv("LNK_TEST_POLICY_ROOT") + p, err := New(root, Config{Budget: 6, Now: func() time.Time { + b, e := os.ReadFile(filepath.Join(filepath.Dir(root), "clock")) + if e != nil { + panic(e) + } + var now time.Time + if e = now.UnmarshalText(b); e != nil { + panic(e) + } + return now + }, Wait: func(ctx context.Context, d time.Duration) error { + if d < time.Second { + return wait(ctx, d) + } + b, e := os.ReadFile(filepath.Join(filepath.Dir(root), "clock")) + if e != nil { + return e + } + var now time.Time + if e = now.UnmarshalText(b); e != nil { + return e + } + next, _ := now.Add(d).MarshalText() + return os.WriteFile(filepath.Join(filepath.Dir(root), "clock"), next, 0600) + }}) + if err != nil { + t.Fatal(err) + } + if mode == "marker" { + s, e := openStore(root) + if e != nil { + t.Fatal(e) + } + if unix.Flock(int(s.lock.Fd()), unix.LOCK_EX) != nil { + t.Fatal("lock") + } + if e = s.create("initialized", []byte("lnk-safety-v2\n")); e != nil { + t.Fatal(e) + } + os.Exit(0) + } + l, err := p.Admit(context.Background()) + if err != nil { + var coded *types.LnkError + if errors.As(err, &coded) && coded.Code == 5 { + fmt.Println("BUDGET") + return + } + t.Fatal(err) + } + if mode == "429" { + if err = l.Refusal(429, false, "7200"); err == nil { + t.Fatal("missing refusal") + } + l.Close() + return + } + if mode == "crash" { + os.Exit(0) + } + probe := filepath.Join(filepath.Dir(root), "exchange") + f, e := os.OpenFile(probe, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if e != nil { + t.Fatal("OVERLAPPING EXCHANGE", e) + } + time.Sleep(10 * time.Millisecond) + f.Close() + os.Remove(probe) + l.Close() + fmt.Println("ADMITTED") +} +func child(t *testing.T, root, mode string) *exec.Cmd { + t.Helper() + cmd := exec.Command(os.Args[0], "-test.run=^TestPolicySubprocess$", "-test.count=1") + cmd.Env = append(os.Environ(), "HOME="+filepath.Dir(root), "LNK_TEST_CHILD_MODE="+mode, "LNK_TEST_POLICY_ROOT="+root) + return cmd +} +func subprocessRoot(t *testing.T) string { + t.Helper() + parent, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + b, _ := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC).MarshalText() + if err = os.WriteFile(filepath.Join(parent, "clock"), b, 0600); err != nil { + t.Fatal(err) + } + return filepath.Join(parent, "policy") +} +func TestConcurrentStoreInitialization(t *testing.T) { + parent, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + root := filepath.Join(parent, "policy") + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + s, err := openStore(root) + if err != nil { + t.Error(err) + return + } + s.close() + }() + } + wg.Wait() +} + +func TestSubprocessSharedBudgetStress(t *testing.T) { + for run := 0; run < 5; run++ { + t.Run(fmt.Sprint(run), func(t *testing.T) { + root := subprocessRoot(t) + var wg sync.WaitGroup + outputs := make(chan string, 10) + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + b, err := child(t, root, "exchange").CombinedOutput() + if err != nil { + t.Errorf("child: %v %s", err, b) + } + outputs <- string(b) + }() + } + wg.Wait() + close(outputs) + admitted, refused := 0, 0 + for output := range outputs { + admitted += strings.Count(output, "ADMITTED") + refused += strings.Count(output, "BUDGET") + } + if admitted != 6 || refused != 4 { + t.Fatalf("admitted %d refused %d", admitted, refused) + } + }) + } +} +func TestSubprocessCrashDurability(t *testing.T) { + for _, mode := range []string{"crash", "marker"} { + t.Run(mode, func(t *testing.T) { + root := subprocessRoot(t) + if b, err := child(t, root, mode).CombinedOutput(); err != nil { + t.Fatalf("child: %v %s", err, b) + } + p, err := New(root, Config{Budget: 1, Now: func() time.Time { return time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC) }}) + if err != nil { + t.Fatal(err) + } + _, err = p.Admit(context.Background()) + expected := 5 + if mode == "marker" { + expected = 4 + } + code(t, err, expected) + }) + } +} + +func TestCooldownSurvivesFreshProcess(t *testing.T) { + root := subprocessRoot(t) + if output, err := child(t, root, "429").CombinedOutput(); err != nil { + t.Fatalf("%v %s", err, output) + } + output, err := child(t, root, "exchange").CombinedOutput() + if err != nil || !strings.Contains(string(output), "BUDGET") || strings.Contains(string(output), "ADMITTED") { + t.Fatalf("fresh process ignored cooldown: %v %s", err, output) + } +} + +func TestCanceledWhilePacingConsumesNoAdmission(t *testing.T) { + p, _ := fixture(t, 100) + admit(t, p) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + p.cfg.Wait = func(ctx context.Context, d time.Duration) error { cancel(); return failure(4, "safety wait canceled") } + _, err := p.Admit(ctx) + code(t, err, 4) + if len(readState(t, p).Admissions) != 1 { + t.Fatal("pacing cancellation consumed quota") + } +} +func TestFarFutureHTTPDateIsNotCapped(t *testing.T) { + p, _ := fixture(t, 100) + lease, err := p.Admit(context.Background()) + if err != nil { + t.Fatal(err) + } + deadline := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC) + code(t, lease.Refusal(429, false, deadline.Format("Mon, 02 Jan 2006 15:04:05 GMT")), 5) + lease.Close() + if !readState(t, p).Cooldown.Equal(deadline) { + t.Fatal("long server deadline was capped") + } +} diff --git a/internal/safety/review_regression_test.go b/internal/safety/review_regression_test.go new file mode 100644 index 0000000..7fc5a1a --- /dev/null +++ b/internal/safety/review_regression_test.go @@ -0,0 +1,177 @@ +package safety + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +func TestLongSpacingSurvivesPruningAndRestart(t *testing.T) { + for _, elapsed := range []time.Duration{24 * time.Hour, 25 * time.Hour} { + for _, action := range []string{"none", "status", "resume"} { + t.Run(elapsed.String()+"/"+action, func(t *testing.T) { + p, c := fixture(t, 1) + p.cfg.Delay = 48 * time.Hour + first := c.now + lease, err := p.Admit(context.Background()) + if err != nil { + t.Fatal(err) + } + if action == "resume" { + code(t, lease.Refusal(401, false, ""), 3) + } + lease.Close() + c.now = first.Add(elapsed) + fresh, err := New(p.root, p.cfg) + if err != nil { + t.Fatal(err) + } + if action != "none" { + var status Status + if action == "status" { + status, err = fresh.Status(context.Background()) + } else { + status, err = fresh.Resume(context.Background(), true) + } + if err != nil { + t.Fatal(err) + } + if status.Admissions != 0 { + t.Fatal("expired admission counted in budget") + } + } + c.waits = nil + fresh.cfg.Wait = func(ctx context.Context, d time.Duration) error { + if len(readState(t, fresh).Admissions) != 0 { + t.Fatal("expired admission counted during pacing") + } + c.waits = append(c.waits, d) + c.now = c.now.Add(d) + return nil + } + admit(t, fresh) + if len(c.waits) != 1 || c.waits[0] != 48*time.Hour-elapsed || !c.now.Equal(first.Add(48*time.Hour)) { + t.Fatalf("spacing shortened after prune/restart: waits=%v now=%v", c.waits, c.now) + } + if len(readState(t, fresh).Admissions) != 1 { + t.Fatal("rolling history not pruned") + } + }) + } + } +} + +func TestLongSpacingFreshProcessHelper(t *testing.T) { + root := os.Getenv("LNK_TEST_LONG_SPACING_ROOT") + if root == "" { + return + } + elapsed, err := strconv.Atoi(os.Getenv("LNK_TEST_LONG_SPACING_HOURS")) + if err != nil { + t.Fatal(err) + } + first := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC) + now := first.Add(time.Duration(elapsed) * time.Hour) + var waits []time.Duration + p, err := New(root, Config{Delay: 48 * time.Hour, Budget: 1, Now: func() time.Time { return now }, Wait: func(ctx context.Context, d time.Duration) error { + waits = append(waits, d) + now = now.Add(d) + return nil + }}) + if err != nil { + t.Fatal(err) + } + status, err := p.Status(context.Background()) + if err != nil || status.Admissions != 0 { + t.Fatalf("expired budget: %v %v", status, err) + } + admit(t, p) + if !now.Equal(first.Add(48*time.Hour)) || len(waits) != 1 || waits[0] != (48-time.Duration(elapsed))*time.Hour { + t.Fatalf("fresh process released spacing: now=%v waits=%v", now, waits) + } + fmt.Println("LONG_SPACING_PRESERVED") +} + +func TestLongSpacingFreshProcess(t *testing.T) { + for _, hours := range []int{24, 25} { + t.Run(strconv.Itoa(hours), func(t *testing.T) { + p, _ := fixture(t, 1) + p.cfg.Delay = 48 * time.Hour + admit(t, p) + command := exec.Command(os.Args[0], "-test.run=^TestLongSpacingFreshProcessHelper$", "-test.count=1") + command.Env = append(os.Environ(), "HOME="+filepath.Dir(p.root), "LNK_TEST_LONG_SPACING_ROOT="+p.root, "LNK_TEST_LONG_SPACING_HOURS="+strconv.Itoa(hours)) + output, err := command.CombinedOutput() + if err != nil || !strings.Contains(string(output), "LONG_SPACING_PRESERVED") { + t.Fatalf("fresh-process spacing failed: %v %s", err, output) + } + }) + } +} + +func TestLastAdmissionMetadataValidation(t *testing.T) { + for _, kind := range []string{"missing", "null", "future", "older-than-history", "newer-than-history", "zero"} { + t.Run(kind, func(t *testing.T) { + p, c := fixture(t, 100) + admit(t, p) + filename := filepath.Join(p.root, "state.json") + data, err := os.ReadFile(filename) + if err != nil { + t.Fatal(err) + } + var state map[string]any + if err = json.Unmarshal(data, &state); err != nil { + t.Fatal(err) + } + if _, ok := state["last_admission"]; !ok { + t.Fatal("latest admission is not separately persisted") + } + switch kind { + case "missing": + delete(state, "last_admission") + case "null": + state["last_admission"] = nil + case "future": + state["last_admission"] = c.now.Add(time.Hour).Format(time.RFC3339Nano) + case "older-than-history": + state["last_admission"] = c.now.Add(-time.Hour).Format(time.RFC3339Nano) + case "newer-than-history": + state["last_clock"] = c.now.Add(time.Hour).Format(time.RFC3339Nano) + state["last_admission"] = c.now.Add(time.Minute).Format(time.RFC3339Nano) + case "zero": + state["last_admission"] = time.Time{}.Format(time.RFC3339Nano) + } + data, err = json.Marshal(state) + if err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filename, data, 0600); err != nil { + t.Fatal(err) + } + _, err = p.Admit(context.Background()) + code(t, err, 4) + }) + } +} + +func TestLongSpacingExactBoundary(t *testing.T) { + p, c := fixture(t, 1) + p.cfg.Delay = 48 * time.Hour + admit(t, p) + c.now = c.now.Add(48 * time.Hour) + fresh, err := New(p.root, p.cfg) + if err != nil { + t.Fatal(err) + } + fresh.cfg.Wait = func(context.Context, time.Duration) error { t.Fatal("waited at exact pacing boundary"); return nil } + admit(t, fresh) + if len(readState(t, fresh).Admissions) != 1 { + t.Fatal("expired request counted at pacing boundary") + } +} diff --git a/internal/safety/store.go b/internal/safety/store.go new file mode 100644 index 0000000..816c2a6 --- /dev/null +++ b/internal/safety/store.go @@ -0,0 +1,193 @@ +package safety + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "io" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" +) + +type store struct { + dir int + lock *os.File +} + +func stateError() error { + return failure(4, "safety state unavailable or unsafe; review local ownership, permissions, ACLs and initialization") +} + +func validateFD(fd int, directory, private bool) error { + var st unix.Stat_t + if unix.Fstat(fd, &st) != nil { + return stateError() + } + expected := uint32(unix.S_IFREG) + if directory { + expected = unix.S_IFDIR + } + if uint32(st.Mode)&unix.S_IFMT != expected || (st.Uid != uint32(os.Getuid()) && (private || st.Uid != 0)) { + return stateError() + } + if private { + mode := uint32(0600) + if directory { + mode = 0700 + } + if uint32(st.Mode)&07777 != mode || (!directory && st.Nlink != 1) { + return stateError() + } + if err := validateACL(fd); err != nil { + return err + } + } else if st.Mode&0022 != 0 && st.Mode&unix.S_ISVTX == 0 { + return stateError() + } + return nil +} + +func openStore(root string) (*store, error) { + if !filepath.IsAbs(root) || filepath.Clean(root) != root || root == "/" { + return nil, stateError() + } + fd, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) + if err != nil { + return nil, stateError() + } + for _, part := range strings.Split(strings.TrimPrefix(root, "/"), "/") { + next, e := unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if errors.Is(e, unix.ENOENT) { + e = unix.Mkdirat(fd, part, 0700) + if e == nil || errors.Is(e, unix.EEXIST) { + if unix.Fsync(fd) != nil { + unix.Close(fd) + return nil, stateError() + } + next, e = unix.Openat(fd, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + } + } + unix.Close(fd) + if e != nil { + return nil, stateError() + } + fd = next + if e = validateFD(fd, true, false); e != nil { + unix.Close(fd) + return nil, e + } + } + if err = validateFD(fd, true, true); err != nil { + unix.Close(fd) + return nil, err + } + if err = validateFilesystem(fd); err != nil { + unix.Close(fd) + return nil, err + } + s := &store{dir: fd} + lock, err := s.lockFile() + if err != nil { + s.close() + return nil, err + } + s.lock = lock + if unix.Fsync(fd) != nil { + s.close() + return nil, stateError() + } + return s, nil +} + +func (s *store) lockFile() (*os.File, error) { + fd, err := unix.Openat(s.dir, "lock", unix.O_RDWR|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, 0) + if errors.Is(err, unix.ENOENT) { + fd, err = unix.Openat(s.dir, "lock", unix.O_RDWR|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, 0600) + if errors.Is(err, unix.EEXIST) { + fd, err = unix.Openat(s.dir, "lock", unix.O_RDWR|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, 0) + } + } + if err != nil { + return nil, stateError() + } + if err = validateFD(fd, false, true); err != nil { + unix.Close(fd) + return nil, err + } + return os.NewFile(uintptr(fd), "lock"), nil +} + +func (s *store) close() { + if s.lock != nil { + s.lock.Close() + } + unix.Close(s.dir) +} + +func (s *store) open(name string, flags int) (*os.File, error) { + fd, err := unix.Openat(s.dir, name, flags|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, 0600) + if errors.Is(err, unix.ENOENT) { + if flags&unix.O_CREAT != 0 { + return nil, stateError() + } + return nil, os.ErrNotExist + } + if err != nil { + return nil, stateError() + } + if err = validateFD(fd, false, true); err != nil { + unix.Close(fd) + return nil, err + } + return os.NewFile(uintptr(fd), name), nil +} + +func (s *store) read(name string) ([]byte, error) { + f, err := s.open(name, unix.O_RDONLY) + if err != nil { + return nil, err + } + defer f.Close() + b, err := io.ReadAll(io.LimitReader(f, 32769)) + if err != nil || len(b) > 32768 { + return nil, stateError() + } + return b, nil +} + +func (s *store) create(name string, b []byte) error { + f, err := s.open(name, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL) + if err != nil { + return err + } + defer f.Close() + if unix.Fchmod(int(f.Fd()), 0600) != nil { + return stateError() + } + if _, err = f.Write(b); err != nil { + return stateError() + } + if f.Sync() != nil || unix.Fsync(s.dir) != nil { + return stateError() + } + return nil +} + +func (s *store) write(b []byte) error { + var entropy [16]byte + if _, err := rand.Read(entropy[:]); err != nil { + return stateError() + } + name := ".state-" + hex.EncodeToString(entropy[:]) + defer unix.Unlinkat(s.dir, name, 0) + if err := s.create(name, b); err != nil { + return err + } + if unix.Renameat(s.dir, name, s.dir, "state.json") != nil || unix.Fsync(s.dir) != nil { + return stateError() + } + return nil +} diff --git a/internal/types/errors.go b/internal/types/errors.go index 3ccc3e3..ef69bba 100644 --- a/internal/types/errors.go +++ b/internal/types/errors.go @@ -1,6 +1,9 @@ package types -import "fmt" +import ( + "errors" + "fmt" +) // Exit code constants used throughout lnk. const ( @@ -12,11 +15,28 @@ const ( ExitRateLimit = 5 // Rate-limited by LinkedIn (HTTP 429) ) -// LnkError is an error that carries an exit code. +type DiagnosticReason uint8 + +const ( + ReasonUnspecified DiagnosticReason = iota + ReasonHTTPStatus + ReasonRedirect + ReasonTransport + ReasonResponseRead + ReasonResponseSize + ReasonJSON + ReasonGraphQL + ReasonRefusal +) + type LnkError struct { - Code int - Message string - Cause error + Status int + Method string + Operation string + Reason DiagnosticReason + Code int + Message string + Cause error } func (e *LnkError) Error() string { @@ -48,3 +68,18 @@ func NetworkError(msg string, cause error) *LnkError { return WrapError(ExitNetw func RateLimitError() *LnkError { return NewError(ExitRateLimit, "rate limited by LinkedIn (HTTP 429)") } + +func WithRequestDetails(err error, method, operation string, status int, reason DiagnosticReason) error { + result := LnkError{Code: ExitGeneral, Message: "LinkedIn request failed"} + var coded *LnkError + if errors.As(err, &coded) { + result = *coded + } + result.Method = method + result.Operation = operation + result.Status = status + if reason != ReasonUnspecified { + result.Reason = reason + } + return &result +} From 054b6ef4590a94672a8f471ef0f67394a9c290fa Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 15 Oct 2026 00:12:13 +0200 Subject: [PATCH 2/2] fix(api): stop failure fallbacks and preserve auth latches #15 --- internal/api/redirect_review_test.go | 60 +++++++++- internal/api/request.go | 3 - internal/cmd/profile.go | 9 +- internal/cmd/profile_review_test.go | 166 +++++++++++++++++++++++++++ internal/cmd/review_test.go | 2 +- 5 files changed, 232 insertions(+), 8 deletions(-) create mode 100644 internal/cmd/profile_review_test.go diff --git a/internal/api/redirect_review_test.go b/internal/api/redirect_review_test.go index 7f85574..c732b3b 100644 --- a/internal/api/redirect_review_test.go +++ b/internal/api/redirect_review_test.go @@ -1,11 +1,19 @@ package api import ( + "context" + "errors" "fmt" - "github.com/yashiels/linkedin-cli/internal/auth" "net/http" + "os" + "path/filepath" "strings" "testing" + "time" + + "github.com/yashiels/linkedin-cli/internal/auth" + "github.com/yashiels/linkedin-cli/internal/safety" + "github.com/yashiels/linkedin-cli/internal/types" ) func TestDeepAuthRedirectsPersistStop(t *testing.T) { @@ -35,7 +43,7 @@ func TestDeepAuthRedirectsPersistStop(t *testing.T) { } func TestOrdinaryAndMalformedRedirectsRemainRejected(t *testing.T) { - for _, target := range []string{"/ordinary?PRIVATE-SENTINEL", "/%zz?PRIVATE-SENTINEL", "/%252?PRIVATE-SENTINEL", "https://external.invalid/%256cogin?PRIVATE-SENTINEL"} { + for _, target := range []string{"/ordinary?PRIVATE-SENTINEL", "/ordinary/%25zz", "/login-other/%25zz", "/authwall-other/%25zz", "/checkpoint/challenges/%25zz", "/other/login/%25zz", "/login/%25zz/../../ordinary", "/login/%zz", "https://external.invalid/login/%25zz", "http://www.linkedin.com/login/%25zz", "https://user@www.linkedin.com/login/%25zz", "https://www.linkedin.com:443/login/%25zz", "/%zz?PRIVATE-SENTINEL", "/%252?PRIVATE-SENTINEL", "https://external.invalid/%256cogin?PRIVATE-SENTINEL"} { t.Run(target, func(t *testing.T) { calls := 0 c := fixtureClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) { @@ -55,3 +63,51 @@ func TestOrdinaryAndMalformedRedirectsRemainRejected(t *testing.T) { }) } } + +func TestLiteralPercentAuthRedirectsPersistAcrossPolicies(t *testing.T) { + for _, depth := range []int{1, 6, 48, 4096} { + for _, authPath := range []string{"/login/%25zz", "/uas/login/%25zz", "/authwall/%25zz", "/checkpoint/challenge/%25zz", "/%6cogin/%25zz", "/uas/%6cogin/%25zz", "/%61uthwall/%25zz", "/checkpoint%2fchallenge/%25zz", "/x/%2e%2e/%6cogin/%25zz"} { + target := authPath + for i := 1; i < depth; i++ { + target = strings.ReplaceAll(target, "%", "%25") + } + t.Run(fmt.Sprintf("%d/%s", depth, authPath), func(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := os.Chmod(root, 0700); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC) + cfg := safety.Config{Now: func() time.Time { return now }, Wait: func(_ context.Context, d time.Duration) error { now = now.Add(d); return nil }} + calls := 0 + transport := roundTripFunc(func(*http.Request) (*http.Response, error) { calls++; return fixtureResponse(302, target), nil }) + newClient := func() *Client { + policy, err := safety.New(root, cfg) + if err != nil { + t.Fatal(err) + } + return New(auth.Credentials{LiAt: "synthetic-percent", CSRFToken: "synthetic-csrf"}, WithHTTPClient(&http.Client{Transport: transport}), func(c *Client) { c.policy = policy }) + } + _, err = newClient().Ping() + var coded *types.LnkError + if !errors.As(err, &coded) || coded.Status != 302 || coded.Code != types.ExitAuth || coded.Reason != types.ReasonRefusal || calls != 1 { + t.Fatalf("redirect error=%v calls=%d", err, calls) + } + _, err = newClient().Ping() + if !errors.As(err, &coded) || coded.Code != types.ExitAuth || coded.Status != 0 || calls != 1 { + t.Fatalf("fresh policy failed to honor persisted stop: error=%v calls=%d", err, calls) + } + persisted, policyErr := safety.New(root, cfg) + if policyErr != nil { + t.Fatal(policyErr) + } + state, stateErr := persisted.Status(context.Background()) + if stateErr != nil || state.Block != "auth" || state.Admissions != 1 { + t.Fatalf("persisted state=%+v error=%v", state, stateErr) + } + }) + } + } +} diff --git a/internal/api/request.go b/internal/api/request.go index c1706cb..5f2cdc1 100644 --- a/internal/api/request.go +++ b/internal/api/request.go @@ -92,9 +92,6 @@ func decodeRedirectPath(p string) (string, bool) { decoded = decoded[:len(decoded)-2] } } - if bytes.IndexByte(decoded, '%') >= 0 { - return "", false - } return string(decoded), true } diff --git a/internal/cmd/profile.go b/internal/cmd/profile.go index b86ab3d..0ebd8eb 100644 --- a/internal/cmd/profile.go +++ b/internal/cmd/profile.go @@ -152,7 +152,7 @@ func fetchProfile(client *api.Client, vanityName, profileURN string, miniProfile } basicRaw, err := client.Get("/voyager/api/identity/profiles/"+url.PathEscape(vanityName), nil) - if isRequestFailure(err) { + if isRequestFailure(err) && !isDeprecatedProfileEndpoint(err) { return nil, err } if err == nil { @@ -666,5 +666,10 @@ func firstNonEmpty(candidates ...string) string { func isRequestFailure(err error) bool { var coded *types.LnkError - return errors.As(err, &coded) && !(coded.Code == types.ExitGeneral && coded.Reason == types.ReasonHTTPStatus) + return errors.As(err, &coded) +} + +func isDeprecatedProfileEndpoint(err error) bool { + var coded *types.LnkError + return errors.As(err, &coded) && coded.Code == types.ExitGeneral && coded.Reason == types.ReasonHTTPStatus && coded.Status == 410 } diff --git a/internal/cmd/profile_review_test.go b/internal/cmd/profile_review_test.go new file mode 100644 index 0000000..2258fb8 --- /dev/null +++ b/internal/cmd/profile_review_test.go @@ -0,0 +1,166 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "path/filepath" + "strings" + "testing" + "testing/synctest" + "time" + + "github.com/yashiels/linkedin-cli/internal/api" + "github.com/yashiels/linkedin-cli/internal/auth" + "github.com/yashiels/linkedin-cli/internal/safety" + "github.com/yashiels/linkedin-cli/internal/types" +) + +type profileReviewReply struct { + status int + body string + err error + readErr bool +} + +type profileReviewTransport func(*http.Request) (*http.Response, error) + +func (f profileReviewTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +type profileReviewBrokenBody struct{} + +func (profileReviewBrokenBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } +func (profileReviewBrokenBody) Close() error { return nil } + +func runProfileReview(t *testing.T, replies []profileReviewReply, mini json.RawMessage, wantCount, wantStatus int, wantReason types.DiagnosticReason, wantName string) { + t.Helper() + synctest.Test(t, func(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Setenv("HOME", root) + t.Setenv("LNK_CONFIG", filepath.Join(root, "config.toml")) + t.Setenv("LNK_LI_AT", "") + t.Setenv("LNK_CSRF_TOKEN", "") + var paths []string + var times []time.Time + client := api.New(auth.Credentials{LiAt: "synthetic-profile", CSRFToken: "synthetic-csrf"}, api.WithHTTPClient(&http.Client{Transport: profileReviewTransport(func(req *http.Request) (*http.Response, error) { + paths = append(paths, req.URL.Path) + times = append(times, time.Now()) + i := len(paths) - 1 + if i >= len(replies) { + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`))}, nil + } + reply := replies[i] + if reply.err != nil { + return nil, reply.err + } + var body io.ReadCloser = io.NopCloser(strings.NewReader(reply.body)) + if reply.readErr { + body = profileReviewBrokenBody{} + } + return &http.Response{StatusCode: reply.status, Header: make(http.Header), Body: body}, nil + })})) + profile, err := fetchProfile(client, "fixture", "urn:li:fsd_profile:fixture", mini) + if len(paths) != wantCount { + t.Errorf("exchanges=%d want=%d paths=%v error=%v", len(paths), wantCount, paths, err) + } + if wantReason != types.ReasonUnspecified { + var coded *types.LnkError + if !errors.As(err, &coded) || coded.Status != wantStatus || coded.Reason != wantReason { + t.Errorf("error=%+v want status=%d reason=%d", err, wantStatus, wantReason) + } + } else if err != nil || profile == nil || profile.FirstName != wantName { + t.Errorf("profile=%+v error=%v want name=%q", profile, err, wantName) + } + policy, policyErr := safety.Default(safety.Config{}) + if policyErr != nil { + t.Fatal(policyErr) + } + state, stateErr := policy.Status(context.Background()) + if stateErr != nil || state.Admissions != len(paths) { + t.Errorf("admissions=%d exchanges=%d error=%v", state.Admissions, len(paths), stateErr) + } + for i := 1; i < len(times); i++ { + if times[i].Sub(times[i-1]) < 5*time.Second { + t.Errorf("unpaced exchange %d: %v", i, times[i].Sub(times[i-1])) + } + } + }) +} + +func TestProfileHTTPFailuresStopExchanges(t *testing.T) { + empty := profileReviewReply{status: 200, body: `{}`} + basic := profileReviewReply{status: 200, body: `{"firstName":"Fixture"}`} + gone := profileReviewReply{status: 410, body: `{}`} + stages := []struct { + name string + before []profileReviewReply + }{ + {"dash", nil}, {"basic", []profileReviewReply{empty}}, + {"graphql_urn", []profileReviewReply{empty, gone}}, + {"graphql_identity", []profileReviewReply{empty, gone, empty}}, + {"view", []profileReviewReply{empty, basic}}, + {"network", []profileReviewReply{empty, basic, empty}}, + } + for _, stage := range stages { + for _, status := range []int{400, 404, 410, 500, 502} { + if stage.name == "basic" && status == 410 { + continue + } + t.Run(fmt.Sprintf("%s/%d", stage.name, status), func(t *testing.T) { + replies := append(append([]profileReviewReply{}, stage.before...), profileReviewReply{status: status, body: `{}`}) + runProfileReview(t, replies, nil, len(replies), status, types.ReasonHTTPStatus, "") + }) + } + } +} + +func TestProfileCompatibilityAndEmptyData(t *testing.T) { + empty := profileReviewReply{status: 200, body: `{}`} + basic := profileReviewReply{status: 200, body: `{"firstName":"Fixture"}`} + gone := profileReviewReply{status: 410, body: `{}`} + graph := profileReviewReply{status: 200, body: `{"data":{"profile":{"elements":[{"firstName":"Fixture"}]}}}`} + for _, tc := range []struct { + name string + replies []profileReviewReply + mini json.RawMessage + }{ + {"dash", []profileReviewReply{{status: 200, body: `{"elements":[{"firstName":"Fixture"}]}`}}, nil}, + {"legacy", []profileReviewReply{empty, basic, empty, empty}, nil}, + {"gone_graphql", []profileReviewReply{empty, gone, graph, empty, empty}, nil}, + {"empty_graphql_urn", []profileReviewReply{empty, gone, empty, graph, empty, empty}, nil}, + {"empty_seeded", []profileReviewReply{empty, gone, empty, empty, empty, empty}, json.RawMessage(`{"firstName":"Fixture"}`)}, + } { + t.Run(tc.name, func(t *testing.T) { + runProfileReview(t, tc.replies, tc.mini, len(tc.replies), 0, types.ReasonUnspecified, "Fixture") + }) + } +} + +func TestProfileLaterHardStops(t *testing.T) { + for _, tc := range []struct { + name string + reply profileReviewReply + status int + reason types.DiagnosticReason + }{ + {"auth", profileReviewReply{status: 401, body: `{}`}, 401, types.ReasonRefusal}, + {"forbidden", profileReviewReply{status: 403, body: `{}`}, 403, types.ReasonRefusal}, + {"restricted", profileReviewReply{status: 999, body: `{}`}, 999, types.ReasonRefusal}, + {"body_size", profileReviewReply{status: 200, body: strings.Repeat("x", safety.MaxBody+1)}, 200, types.ReasonResponseSize}, + {"rate", profileReviewReply{status: 429, body: `{}`}, 429, types.ReasonRefusal}, + {"transport", profileReviewReply{err: io.ErrUnexpectedEOF}, 0, types.ReasonTransport}, + {"body_read", profileReviewReply{status: 200, readErr: true}, 200, types.ReasonResponseRead}, + {"json", profileReviewReply{status: 200, body: `invalid`}, 200, types.ReasonJSON}, + {"graphql", profileReviewReply{status: 200, body: `{"errors":[{"message":"fixture"}]}`}, 200, types.ReasonGraphQL}, + } { + t.Run(tc.name, func(t *testing.T) { + runProfileReview(t, []profileReviewReply{{status: 200, body: `{}`}, {status: 410, body: `{}`}, tc.reply}, nil, 3, tc.status, tc.reason, "") + }) + } +} diff --git a/internal/cmd/review_test.go b/internal/cmd/review_test.go index 03feb00..9a1c08b 100644 --- a/internal/cmd/review_test.go +++ b/internal/cmd/review_test.go @@ -12,7 +12,7 @@ func TestTypedHTTPStatusPreservesProfileFallbackContract(t *testing.T) { reason types.DiagnosticReason stop bool }{ - {1, types.ReasonHTTPStatus, false}, {1, types.ReasonJSON, true}, {1, types.ReasonGraphQL, true}, {3, types.ReasonRefusal, true}, {4, types.ReasonTransport, true}, {5, types.ReasonRefusal, true}, + {1, types.ReasonHTTPStatus, true}, {1, types.ReasonJSON, true}, {1, types.ReasonGraphQL, true}, {3, types.ReasonRefusal, true}, {4, types.ReasonTransport, true}, {5, types.ReasonRefusal, true}, } { err := types.WithRequestDetails(types.NewError(tc.code, "untrusted"), "GET", "read", 410, tc.reason) if got := isRequestFailure(err); got != tc.stop {