From 65926bd62e0c4b246e585854ac65acf91fea6371 Mon Sep 17 00:00:00 2001 From: kongesque Date: Sat, 17 Oct 2026 02:02:27 +0800 Subject: [PATCH 1/2] fix: handle unavailable profiles in chat search --- pkg/line/contacts_test.go | 22 ++++++++++++++++++++-- pkg/line/methods.go | 20 ++++++++++++++------ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/pkg/line/contacts_test.go b/pkg/line/contacts_test.go index 6833846..81d23bf 100644 --- a/pkg/line/contacts_test.go +++ b/pkg/line/contacts_test.go @@ -39,8 +39,8 @@ func TestGetContactsV2DistinguishesEmptyFromMalformedResponses(t *testing.T) { {"missing map", `{"code":0,"data":{}}`, false}, {"null map", `{"code":0,"data":{"contacts":null}}`, false}, {"null entry", `{"code":0,"data":{"contacts":{"private-mid":null}}}`, false}, - {"missing contact", `{"code":0,"data":{"contacts":{"private-mid":{}}}}`, false}, - {"null contact", `{"code":0,"data":{"contacts":{"private-mid":{"contact":null}}}}`, false}, + {"missing contact", `{"code":0,"data":{"contacts":{"private-mid":{}}}}`, true}, + {"null contact", `{"code":0,"data":{"contacts":{"private-mid":{"contact":null}}}}`, true}, {"wrong contact type", `{"code":0,"data":{"contacts":{"private-mid":{"contact":"private-profile"}}}}`, false}, } { t.Run(tc.name, func(t *testing.T) { @@ -57,3 +57,21 @@ func TestGetContactsV2DistinguishesEmptyFromMalformedResponses(t *testing.T) { }) } } + +func TestGetContactsV2UnavailableProfilesDoNotDiscardResolvedContacts(t *testing.T) { + client := newReactionTestClientWithResponse(t, + "/api/talk/thrift/Talk/TalkService/getContactsV2", + `{"code":0,"data":{"contacts":{"u-available":{"contact":{"mid":"u-available","displayName":"Alice"}},"u-null":{"contact":null},"u-missing":{}}}}`, nil) + response, err := client.GetContactsV2([]string{"u-available", "u-null", "u-missing"}) + if err != nil { + t.Fatal(err) + } + if len(response.Contacts) != 1 || response.Contacts["u-available"].Contact.EffectiveDisplayName() != "Alice" { + t.Fatal("an unavailable profile discarded a resolved contact") + } + for _, mid := range []string{"u-null", "u-missing"} { + if _, ok := response.Contacts[mid]; ok { + t.Fatal("unavailable profile became a resolved contact") + } + } +} diff --git a/pkg/line/methods.go b/pkg/line/methods.go index 5618961..5ff0796 100644 --- a/pkg/line/methods.go +++ b/pkg/line/methods.go @@ -495,7 +495,7 @@ func (c *Client) GetContactsV2Context(ctx context.Context, mids []string) (*Cont Message string `json:"message"` Data struct { Contacts map[string]*struct { - Contact *Contact `json:"contact"` + Contact json.RawMessage `json:"contact"` } `json:"contacts"` } `json:"data"` } @@ -511,12 +511,20 @@ func (c *Client) GetContactsV2Context(ctx context.Context, mids []string) (*Cont } result := &ContactsResponse{Contacts: make(map[string]ContactWrapper, len(wrapper.Data.Contacts))} for mid, entry := range wrapper.Data.Contacts { - // An omitted map entry is unavailable. A present entry must contain a - // profile; null/missing wrappers must not become resolved empty contacts. - if entry == nil || entry.Contact == nil { - return nil, errors.New("getContactsV2 returned an invalid contact entry") + // LINE can return a missing or explicit null profile for a chat whose + // contact is unavailable. Treat either like an omitted map entry, not an + // empty profile. A null wrapper is still a malformed response. + if entry == nil { + return nil, errors.New("getContactsV2 returned a null contact wrapper") } - result.Contacts[mid] = ContactWrapper{Contact: *entry.Contact} + if len(entry.Contact) == 0 || string(entry.Contact) == "null" { + continue + } + var contact Contact + if err := json.Unmarshal(entry.Contact, &contact); err != nil { + return nil, errors.New("getContactsV2 returned an invalid contact profile") + } + result.Contacts[mid] = ContactWrapper{Contact: contact} } return result, nil } From bd2fc9ca2c6b5a7b7677620e160faf9bd7c62d6c Mon Sep 17 00:00:00 2001 From: kongesque Date: Sat, 17 Oct 2026 22:25:04 +0800 Subject: [PATCH 2/2] fix: honor negotiated group encryption version --- docs/CLI.md | 18 ++++- docs/ERRORS.md | 16 ++++ docs/TESTING.md | 13 ++++ docs/protocol/endpoints/history.md | 83 +++++++++++++++++++++ internal/cli/diagnostics_test.go | 93 ++++++++++++++++++++++++ internal/cli/errors.go | 3 + internal/cli/messages.go | 12 +++ internal/fault/error.go | 20 +++-- internal/messaging/client.go | 42 +++++++---- internal/messaging/diagnostics.go | 42 +++++++++++ internal/messaging/files_test.go | 5 ++ internal/messaging/group_version_test.go | 84 +++++++++++++++++++++ internal/messaging/keys.go | 15 +++- internal/messaging/test_helpers_test.go | 6 ++ pkg/e2ee/manager.go | 28 +++++-- pkg/e2ee/manager_test.go | 59 +++++++++++++++ pkg/line/client.go | 4 +- pkg/line/diagnostics.go | 69 ++++++++++++++++++ pkg/line/diagnostics_test.go | 87 ++++++++++++++++++++++ pkg/line/errors.go | 7 +- pkg/line/history.go | 14 +++- pkg/line/methods.go | 5 +- 22 files changed, 684 insertions(+), 41 deletions(-) create mode 100644 internal/cli/diagnostics_test.go create mode 100644 internal/messaging/diagnostics.go create mode 100644 internal/messaging/group_version_test.go create mode 100644 pkg/line/diagnostics.go create mode 100644 pkg/line/diagnostics_test.go diff --git a/docs/CLI.md b/docs/CLI.md index f68742d..b20282f 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -247,7 +247,23 @@ line messages CHAT_ID --json By default, `messages` fetches 1–100 recent messages. Human output sorts by created time, oldest first; legacy `--json` retains the server's array order. `--show-ids` includes the IDs needed for replies, reactions, downloads, and -unsend. To continue into older available history, use the opt-in page contract: +unsend. To inspect which IDs LINE returns before decryption, without printing +message content: + +```sh +line --error-format=json messages "Chat name" --diagnostics --json --limit 50 +``` + +This returns `schema_version: 1`, `chat_id`, `method: "getRecentMessagesV2"`, +`requested_count`, `returned_count`, `null_count`, `invalid_id_count`, and +`message_ids` in server order. IDs remain strings; malformed IDs are counted +instead of printed. It performs one logical recent-history read (authentication +recovery may retry), without decryption or key lookups. An absent ID means it +was not in this response, not that delivery failed or all history was searched. +`--diagnostics` requires `--json` and cannot combine with `--details`, +`--resolve-names`, `--page`, or `--cursor`. Normal history output is unchanged. + +To continue into older available history, use the opt-in page contract: ```sh line messages CHAT_ID --page --json --limit 50 diff --git a/docs/ERRORS.md b/docs/ERRORS.md index d781e02..248b1b2 100644 --- a/docs/ERRORS.md +++ b/docs/ERRORS.md @@ -53,6 +53,22 @@ and sequence are omitted when unavailable. The persisted request sequence is within LINE's signed 32-bit range and remains a JSON number. This report is not an operation ledger and does not supply a deduplication or replay API. +Request failures may additionally include `error.diagnostic`. Its `kind` +distinguishes `transport` (HTTP exchange failed), `response_read` (body could +not be read completely), `gateway` (HTTP/application error), +`response_envelope` (invalid JSON or missing/invalid success code), +`message_decode` (message data could not be decoded), and `missing_receipt` +(send returned no message or no ID). Gateway diagnostics include `http_status` +and, when available, numeric `gateway_code`. Only the known exception names +`TalkException` and `TokenAuthException` and their numeric `exception_code` are +exposed. Server reason strings, body contents, and transport error text are +never included. Other failures may omit diagnostics. + +These details do not change outcome classification or authorize retries. A +gateway failure or a parsing failure can still follow a delivered message. +For missing-history investigations, `messages CHAT --diagnostics --json` +reports returned IDs before decryption without message text or encrypted data. + | Code | Meaning | | --- | --- | | `invalid_arguments` | Invalid or missing options/input; validation stopped the command. | diff --git a/docs/TESTING.md b/docs/TESTING.md index 8be0e2e..0bf6292 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -54,6 +54,19 @@ authentication rejection followed by failed recovery, lost/missing receipts, preparation side effects, and plaintext-file upload failures without replay. All use synthetic APIs/transports; no live LINE mutation is authorized by them. +Request diagnostic tests distinguish transport, response-read, gateway, +envelope, message-decoding, and missing-receipt failures; verify redaction and +wrapped cancellation; and exercise the protocol-to-session-to-CLI error path +with a persisted sequence and exactly one send. History diagnostic tests retain +large returned IDs even for undecryptable messages, count null/malformed IDs, +exclude message content, and reject incompatible flags before session access. + +Group encryption tests cover negotiated V1/V2 and the absent-version default +for text, mentions, replies, and files; refreshed negotiation; unsupported +versions; and cipher failures without sending or downgrading to plaintext. +The E2EE manager suite includes a real synthetic-key V1 roundtrip and checks +that V2 encryption errors cannot fall back to mislabeled V1 chunks. + History tests cover lossless IDs/delivered times, chat-bound cursors, inclusive and exclusive anchors, short/empty/malformed/nonadvancing pages, bounded lookup, authentication recovery, wrong-chat replies, ownership, and older targets across diff --git a/docs/protocol/endpoints/history.md b/docs/protocol/endpoints/history.md index bfa7c1e..87154c2 100644 --- a/docs/protocol/endpoints/history.md +++ b/docs/protocol/endpoints/history.md @@ -55,3 +55,86 @@ Supplementary evidence: the pinned [linejs fetcher](https://github.com/evex-dev/linejs/blob/4fc512ab8c19a2490ca73bb71777beb775ca3ae3/packages/linejs/client/features/chat/fetcher.ts) uses each page's last delivered time and lossless message ID as its next anchor. Its binary Thrift transport is not evidence for the Chrome JSON argument shape. + +## Send failure and history diagnostics (issue #20) + +### Evidence + +Inspected the official public LINE Chrome extension **3.7.2** on 2026-10-17, +matching the CLI version constants. The package was downloaded from Google's +extension update service for `ophjlpahpchlmihnnnihgmmeilfjmjjc` and verified as +SHA-256 `3722ab02edb0aba49c229399ee8df5b4b5a38c60b76efadadfbae395015eb859`. +LY Corporation retains copyright in the extension. Its source is not committed. +No Chrome login, session replacement, or browser traffic capture was performed. + +This investigation distinguishes transport/response failures from service +exceptions, checks whether recent-history records disappear during decoding, +and corrects group sends to honor the negotiated encryption version. Mutation +retries remain disabled. + +Locators refer to `static/js/main.js` in that exact package: + +| Locator | Observed behavior | +| --- | --- | +| `mU.E2EE_RETRY_ENCRYPT` | Talk error enum value **82**. This is distinct from sender-key update (83), receiver-key update (84), and group-key recreation (99). | +| Send-error switch using `mU.E2EE_RETRY_ENCRYPT` | When E2EE is enabled and content is encryptable, Chrome resets chat negotiation and retries its send flow. This CLI deliberately does not copy mutation retries. | +| `LA.negotiate`, `renewLatestGroupKey`, `unwrapGroupKey` | Group negotiation reads `getLastE2EEGroupSharedKey(1, chat)` and retains the returned `allowedTypes` and `specVersion`. | +| `encryptGroupMessage`, `encryptWithE2eeChannel`, `EL` | Chrome uses negotiated encryption version, constructs chunks, removes outer text/location, and sets string `contentMetadata.e2eeVersion`. | +| `MP`, `Rj` | `sendMessage` takes the request sequence and the prepared message. | + +### Authorized live observations + +Two single-attempt sends were performed in the user-approved two-member test +group: ordinary text and an explicit mention. Both returned HTTP **400**, +gateway code **10051**, `TalkException` code **82**. Their outcomes remain +`unknown`; neither was retried. The planned reply to the baseline was skipped +because the baseline had no confirmed receipt. + +Recent-history diagnostics returned three records, with no null records or +invalid IDs. Detailed history contained the same three IDs. Neither test text +appeared, and none of the returned records carried a reply relationship. This +establishes no decode-stage omission in that sample, not exhaustive history or +non-delivery of the attempted sends. + +A separate read-only check found saved E2EE enabled, saved keys present, and a +current group shared-key response available (no explicit disabled capability +or missing-key response). Those facts do not establish that the encryption +envelope, key freshness, or negotiated version used at send time was correct. +The original issue's visibly delivered reply remains unexplained. + +### Root cause and correction + +A read-only negotiation plus locally intercepted send showed `specVersion: 1`, +but the CLI generated V2 chunks and set `e2eeVersion: "2"`. The group key's +negotiated version was discarded by the messaging layer. This explains the +reproduced code-82 failures for both ordinary text and mentions. + +Group sends now select V1 or V2 explicitly from the latest key response and +label the envelope with the same version. An absent version defaults to V2, +matching Chrome's `Nd` default; unsupported versions fail before sending. +The former V2-error fallback to V1 was removed because it returned V1 chunks +under a V2 label. Cipher failures remain failures, with no plaintext downgrade +or automatic mutation retry. The same selection applies to attachment payloads. + +The corrected local interception produced the V1 five-chunk framing (8-byte +salt, 16-byte MAC, two 4-byte key IDs), labeled version 1 with no outer plaintext. +Chrome locators `mL` (V1) and `vL` (V2) corroborate these framing differences. + +### Live verification after the fix + +One baseline, one mention, and one reply were sent using the previously approved +content, only in the authorized two-member test group. All three returned +confirmed encrypted receipts without group-key registration. Subsequent recent +history returned seven records with no null or invalid IDs. Each new receipt +appeared in both pre-decryption diagnostics and detailed history, decrypted +successfully, and matched the approved text. The decoded mention targeted the +expected other member; the reply relationship matched the confirmed baseline. +Each verification send was attempted once, without automatic retries. + +This confirms the corrected group text/mention/reply flow. It does not establish +what happened to the original issue's historical reply, or live attachment/V2 +interoperability; those paths have synthetic regression coverage. + +No private IDs, message bodies, keys, encrypted chunks, raw responses, or +captures are retained here. See [CLI diagnostics](../../CLI.md) and +[structured error details](../../ERRORS.md) for the new safe output contract. diff --git a/internal/cli/diagnostics_test.go b/internal/cli/diagnostics_test.go new file mode 100644 index 0000000..1a72706 --- /dev/null +++ b/internal/cli/diagnostics_test.go @@ -0,0 +1,93 @@ +package cli + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/kongesque/line-cli/internal/fault" + "github.com/kongesque/line-cli/internal/messaging" + "github.com/kongesque/line-cli/internal/session" + "github.com/kongesque/line-cli/pkg/line" +) + +type diagnosticTransport func(*http.Request) (*http.Response, error) + +func (f diagnosticTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +type diagnosticSendAPI struct { + messageAPI + client *line.Client +} + +func (f *diagnosticSendAPI) SendMessageContext(ctx context.Context, seq int64, msg *line.Message) (*line.Message, error) { + return f.client.SendMessageContext(ctx, seq, msg) +} + +func TestSendDiagnosticSurvivesSessionAndMachineWrapping(t *testing.T) { + for _, tc := range []struct{ body, kind string }{ + {`{"code":10051,"data":{"name":"TalkException","code":82,"reason":"private-reason"}}`, "gateway"}, + {`{"code":0,"data":{"id":123,"text":"private-text"}}`, "message_decode"}, + {`{"code":0,"data":null}`, "missing_receipt"}, + } { + a, out, _ := testApp(nil) + calls := 0 + client := line.NewClient("synthetic") + client.HTTPClient = &http.Client{Transport: diagnosticTransport(func(*http.Request) (*http.Response, error) { + calls++ + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(tc.body))}, nil + })} + f := &diagnosticSendAPI{client: client} + store := &testStore{state: &session.State{MID: "u-self", AccessToken: "synthetic", NoE2EE: true}} + a.Manager.Store = store + a.Manager.NewClient = func(string) session.API { return f } + err := a.Run([]string{"--error-format=json", "send", "id:c-group", "--text", "synthetic", "--json"}) + d := machineDetail(t, err) + if d.Code != "remote_failed" || d.Stage != "send" || d.Diagnostic == nil || d.Diagnostic.Kind != tc.kind || d.Mutation == nil || d.Mutation.Outcome != fault.Unknown || !d.Mutation.Attempted || calls != 1 || out.Len() != 0 { + t.Fatalf("incorrect send result: %+v; calls %d", d, calls) + } + if d.Mutation.RequestSequence == nil || *d.Mutation.RequestSequence != store.state.LastReqSeq { + t.Fatal("lost persisted sequence") + } + if tc.kind == "gateway" && (d.Diagnostic.ExceptionCode == nil || *d.Diagnostic.ExceptionCode != 82) { + t.Fatal("lost E2EE_RETRY_ENCRYPT diagnostic") + } + encoded, _ := json.Marshal(d) + if strings.Contains(string(encoded), "private-") { + t.Fatal("machine channel leaked data") + } + } +} + +func TestHistoryDiagnosticsIDsBeforeDecryption(t *testing.T) { + a, out, _ := testApp(nil) + f := &messageAPI{history: []*line.Message{ + {ID: "9007199254740993", Text: "private-text", Chunks: []string{"private-chunk"}, ContentMetadata: map[string]string{"e2eeVersion": "2"}}, + nil, {ID: "private-invalid-id"}, {ID: "9007199254740994", RelatedMessageID: "123"}, + }} + a.Manager.Store = &testStore{state: &session.State{MID: "u-self", AccessToken: "synthetic", NoE2EE: true}} + a.Manager.NewClient = func(string) session.API { return f } + if err := a.Run([]string{"messages", "id:c-group", "--diagnostics", "--json", "--limit", "50"}); err != nil { + t.Fatal(err) + } + var d messaging.HistoryDiagnostics + if json.Unmarshal(out.Bytes(), &d) != nil || d.SchemaVersion != 1 || d.Method != "getRecentMessagesV2" || d.RequestedCount != 50 || d.ReturnedCount != 4 || d.NullCount != 1 || d.InvalidIDCount != 1 || len(d.MessageIDs) != 2 || d.MessageIDs[0] != "9007199254740993" || d.MessageIDs[1] != "9007199254740994" { + t.Fatalf("diagnostics: %s", out.String()) + } + if strings.Contains(out.String(), "private-") || strings.Contains(out.String(), "chunks") || strings.Contains(out.String(), "text") { + t.Fatal("history diagnostics leaked message data") + } +} + +func TestHistoryDiagnosticFlagsValidateBeforeSession(t *testing.T) { + for _, extra := range [][]string{{}, {"--json", "--details"}, {"--json", "--page"}, {"--json", "--cursor", "bad"}, {"--json", "--resolve-names"}} { + a, _, _ := testApp(nil) + a.Lock = func() (func(), error) { t.Fatal("invalid flags accessed session"); return nil, nil } + if err := a.Run(append([]string{"messages", "id:c-group", "--diagnostics"}, extra...)); err == nil { + t.Fatalf("accepted invalid flags: %v", extra) + } + } +} diff --git a/internal/cli/errors.go b/internal/cli/errors.go index 8f06134..e8f17bf 100644 --- a/internal/cli/errors.go +++ b/internal/cli/errors.go @@ -174,6 +174,9 @@ func DescribeError(err error, stage string, op *fault.Mutation) *fault.Error { } detail := fault.New(code, stage, message, err) detail.Mutation = op + if d := line.RequestDiagnostics(err); d != nil { + detail.Diagnostic = &fault.Diagnostic{Kind: d.Kind, HTTPStatus: d.HTTPStatus, GatewayCode: d.GatewayCode, Exception: d.Exception, ExceptionCode: d.ExceptionCode} + } if op != nil { switch op.Outcome { case fault.Unknown: diff --git a/internal/cli/messages.go b/internal/cli/messages.go index 5ccaf8c..4b00148 100644 --- a/internal/cli/messages.go +++ b/internal/cli/messages.go @@ -31,6 +31,7 @@ func (a *App) messageCommand(command string, args []string) error { limit := 20 var pageMode bool var details, resolveNames bool + var diagnostics bool var cursor string var text string var stdin bool @@ -43,6 +44,7 @@ func (a *App) messageCommand(command string, args []string) error { var attachment *messaging.Attachment showIDs := fs.Bool("show-ids", false, "show message IDs in human output") if command == "messages" { + fs.BoolVar(&diagnostics, "diagnostics", false, "report recent-history IDs and counts before decryption, without content (requires --json; excludes --details/--page)") fs.BoolVar(&details, "details", false, "include safe decoded content in a versioned envelope (requires --json)") fs.BoolVar(&resolveNames, "resolve-names", false, "resolve sender names in batches (requires --details --json)") fs.IntVar(&limit, "limit", 20, "maximum messages to fetch (1–100)") @@ -81,6 +83,9 @@ func (a *App) messageCommand(command string, args []string) error { if details && !*jsonOutput { return errors.New("--details requires --json") } + if diagnostics && (!*jsonOutput || details || pageMode || resolveNames) { + return errors.New("--diagnostics requires --json and cannot be combined with --details, --page, or --resolve-names") + } if resolveNames && !details { return errors.New("--resolve-names requires --details --json") } @@ -291,6 +296,13 @@ func (a *App) messageCommand(command string, args []string) error { return a.confirmedOutput(op, err) } var messages []messaging.Message + if diagnostics { + result, err := client.HistoryDiagnosticsContext(a.loginContext(), chat, limit) + if err != nil { + return err + } + return a.json(result) + } var page *messaging.HistoryPage if pageMode { page, err = client.HistoryPageContext(a.loginContext(), chat, limit, cursor) diff --git a/internal/fault/error.go b/internal/fault/error.go index 44ba171..bd3ddaa 100644 --- a/internal/fault/error.go +++ b/internal/fault/error.go @@ -31,12 +31,22 @@ type Mutation struct { Steps []Step `json:"steps,omitempty"` } +// Diagnostic is the allowlisted request failure detail in the machine channel. +type Diagnostic struct { + Kind string `json:"kind"` + HTTPStatus int `json:"http_status,omitempty"` + GatewayCode *int `json:"gateway_code,omitempty"` + Exception string `json:"exception,omitempty"` + ExceptionCode *int `json:"exception_code,omitempty"` +} + type Error struct { - Code string `json:"code"` - Stage string `json:"stage"` - Message string `json:"message"` - Mutation *Mutation `json:"mutation,omitempty"` - cause error + Code string `json:"code"` + Stage string `json:"stage"` + Message string `json:"message"` + Mutation *Mutation `json:"mutation,omitempty"` + Diagnostic *Diagnostic `json:"diagnostic,omitempty"` + cause error } func New(code, stage, message string, cause error) *Error { diff --git a/internal/messaging/client.go b/internal/messaging/client.go index 68c0965..1c43866 100644 --- a/internal/messaging/client.go +++ b/internal/messaging/client.go @@ -63,9 +63,9 @@ type Crypto interface { DecryptMessageV2(*line.Message) (string, error) DecryptGroupMessage(*line.Message, string) (string, int, error) EncryptMessageV2(string, string, int, string, int, int, int, string) ([]string, error) - EncryptGroupMessage(string, string, string) ([]string, error) + EncryptGroupMessageVersion(string, string, int, string) ([]string, error) EncryptMessageV2Raw(string, string, int, string, int, int, int, []byte) ([]string, error) - EncryptGroupMessageRaw(string, string, int, []byte) ([]string, error) + EncryptGroupMessageRawVersion(string, string, int, int, []byte) ([]string, error) GenerateGroupKey() (int, error) WrapGroupKeyForMember(string, int) (string, error) } @@ -75,7 +75,7 @@ type Client struct { state *session.State crypto Crypto keyError error - groupKeys map[string]bool + groupKeys map[string]int // chat:key ID -> negotiated version; key ID 0 is the latest send lookup } func New(manager *session.Manager) (*Client, error) { @@ -90,7 +90,7 @@ func newClient(manager *session.Manager, factory func() (Crypto, error)) (*Clien if s.Invalidated { return nil, errors.New("LINE session was logged out; run line login") } - c := &Client{Session: manager, state: s, groupKeys: make(map[string]bool)} + c := &Client{Session: manager, state: s, groupKeys: make(map[string]int)} if s.NoE2EE { return c, nil } @@ -131,14 +131,8 @@ func (c *Client) History(chat string, limit int) ([]Message, error) { } func (c *Client) HistoryContext(ctx context.Context, chat string, limit int) ([]Message, error) { - if err := ValidateChatID(chat); err != nil { - return nil, err - } - if limit < 1 || limit > 100 { - return nil, errors.New("limit must be between 1 and 100") - } - var raw []*line.Message - if err := c.Session.DoContext(ctx, func(api session.API) (err error) { raw, err = api.GetRecentMessagesV2Context(ctx, chat, limit); return }); err != nil { + raw, err := c.recentMessages(ctx, chat, limit) + if err != nil { return nil, err } result := make([]Message, 0, len(raw)) @@ -157,6 +151,20 @@ func (c *Client) HistoryContext(ctx context.Context, chat string, limit int) ([] return result, nil } +func (c *Client) recentMessages(ctx context.Context, chat string, limit int) ([]*line.Message, error) { + if err := ValidateChatID(chat); err != nil { + return nil, err + } + if limit < 1 || limit > 100 { + return nil, errors.New("limit must be between 1 and 100") + } + var raw []*line.Message + if err := c.Session.DoContext(ctx, func(api session.API) (err error) { raw, err = api.GetRecentMessagesV2Context(ctx, chat, limit); return }); err != nil { + return nil, err + } + return raw, ctx.Err() +} + // Decode converts a non-nil history or stream message to safe CLI output. // The caller holds the session lock; key lookups never register group keys. func (c *Client) Decode(chat string, msg *line.Message) Message { @@ -375,6 +383,7 @@ func (c *Client) send(ctx context.Context, chat, text, replyTo string, file *Att } } var chunks []string + encryptionVersion := 2 if !plain && kind == 0 { key, keyErr := c.negotiate(ctx, chat) if errors.Is(keyErr, errNoLetterSealing) { @@ -409,13 +418,14 @@ func (c *Client) send(ctx context.Context, chat, text, replyTo string, file *Att if errors.Is(err, errNoLetterSealing) { plain, err = true, nil } else if err == nil { + encryptionVersion = c.groupKeys[chat+":0"] if file == nil { - chunks, err = c.crypto.EncryptGroupMessage(chat, c.state.MID, text) + chunks, err = c.crypto.EncryptGroupMessageVersion(chat, c.state.MID, encryptionVersion, text) } else { var payload []byte payload, err = c.uploadAttachment(ctx, file, image, metadata, op) if err == nil { - chunks, err = c.crypto.EncryptGroupMessageRaw(chat, c.state.MID, contentType, payload) + chunks, err = c.crypto.EncryptGroupMessageRawVersion(chat, c.state.MID, encryptionVersion, contentType, payload) } } } @@ -453,7 +463,7 @@ func (c *Client) send(ctx context.Context, chat, text, replyTo string, file *Att msg.Text = text } else { msg.Chunks = chunks - msg.ContentMetadata["e2eeVersion"] = "2" + msg.ContentMetadata["e2eeVersion"] = strconv.Itoa(encryptionVersion) } var sent *line.Message stage = "send" @@ -470,7 +480,7 @@ func (c *Client) send(ctx context.Context, chat, text, replyTo string, file *Att if sent == nil || sent.ID == "" { op.Outcome = fault.Unknown op.Steps[len(op.Steps)-1].Outcome = fault.Unknown - return nil, fault.New("remote_failed", "send", "send returned no message ID; delivery may have occurred; inspect history before retrying", nil) + return nil, fault.New("remote_failed", "send", "send returned no message ID; delivery may have occurred; inspect history before retrying", line.ErrMissingMessageReceipt) } op.MessageID = sent.ID if plain && file != nil { diff --git a/internal/messaging/diagnostics.go b/internal/messaging/diagnostics.go new file mode 100644 index 0000000..e55f502 --- /dev/null +++ b/internal/messaging/diagnostics.go @@ -0,0 +1,42 @@ +package messaging + +import ( + "context" + "strconv" +) + +// HistoryDiagnostics describes the recent-history response before decryption. +// IDs are the only message fields exposed; malformed IDs are counted, not echoed. +type HistoryDiagnostics struct { + SchemaVersion int `json:"schema_version"` + ChatID string `json:"chat_id"` + Method string `json:"method"` + RequestedCount int `json:"requested_count"` + ReturnedCount int `json:"returned_count"` + NullCount int `json:"null_count"` + InvalidIDCount int `json:"invalid_id_count"` + MessageIDs []string `json:"message_ids"` +} + +// HistoryDiagnosticsContext makes one logical read (auth recovery may retry), +// without decrypting content, looking up keys, or mutating LINE state. +func (c *Client) HistoryDiagnosticsContext(ctx context.Context, chat string, limit int) (*HistoryDiagnostics, error) { + raw, err := c.recentMessages(ctx, chat, limit) + if err != nil { + return nil, err + } + d := &HistoryDiagnostics{SchemaVersion: 1, ChatID: chat, Method: "getRecentMessagesV2", RequestedCount: limit, ReturnedCount: len(raw), MessageIDs: make([]string, 0, len(raw))} + for _, msg := range raw { + if msg == nil { + d.NullCount++ + continue + } + id, err := strconv.ParseUint(msg.ID, 10, 64) + if err != nil || id == 0 || strconv.FormatUint(id, 10) != msg.ID { + d.InvalidIDCount++ + continue + } + d.MessageIDs = append(d.MessageIDs, msg.ID) + } + return d, ctx.Err() +} diff --git a/internal/messaging/files_test.go b/internal/messaging/files_test.go index e6c47a7..445461a 100644 --- a/internal/messaging/files_test.go +++ b/internal/messaging/files_test.go @@ -18,6 +18,11 @@ type fileCrypto struct { contentType int } +func (f *fileCrypto) EncryptGroupMessageRawVersion(chat, from string, version, kind int, payload []byte) ([]string, error) { + f.groupVersion = version + return f.EncryptGroupMessageRaw(chat, from, kind, payload) +} + func (f *fileCrypto) EncryptMessageV2Raw(_ string, _ string, _ int, _ string, _ int, _ int, kind int, payload []byte) ([]string, error) { f.payloadSent = payload f.contentType = kind diff --git a/internal/messaging/group_version_test.go b/internal/messaging/group_version_test.go new file mode 100644 index 0000000..f5c2378 --- /dev/null +++ b/internal/messaging/group_version_test.go @@ -0,0 +1,84 @@ +package messaging + +import ( + "context" + "errors" + "strconv" + "testing" + + "github.com/kongesque/line-cli/internal/session" + "github.com/kongesque/line-cli/pkg/line" +) + +func TestGroupSendHonorsNegotiatedVersion(t *testing.T) { + for _, version := range []int{0, 1, 2} { + for _, mode := range []string{"text", "mention", "reply", "file"} { + t.Run(strconv.Itoa(version)+"/"+mode, func(t *testing.T) { + c, api, crypto, store := setup(t, false) + api.group = &line.E2EEGroupSharedKey{GroupKeyID: 33, Creator: "u-self", CreatorKeyID: 11, ReceiverKeyID: 11, EncryptedSharedKey: "wrapped", SpecVersion: version} + api.history = []*line.Message{{ID: "123", From: "u-self", To: "c-group", ToType: 2}} + var result *SendResult + var err error + switch mode { + case "text": + result, err = c.Send("c-group", "synthetic") + case "mention": + result, err = c.SendMentionsContext(context.Background(), "c-group", "@Peer", "", []Mention{{Start: 0, End: 5, MID: mentionPeer}}) + case "reply": + result, err = c.SendReply("c-group", "synthetic", "123") + case "file": + c.crypto = &fileCrypto{fakeCrypto: crypto} + files := &fileAPI{fakeAPI: api} + c.Session.NewClient = func(string) session.API { return files } + result, err = c.SendFile("c-group", Attachment{Name: "synthetic.txt", Data: []byte("synthetic")}, "") + } + want := version + if want == 0 { + want = 2 + } + if err != nil || result == nil || !result.Encrypted || api.sends != 1 || api.registrations != 0 || crypto.groupVersion != want || api.sent.ContentMetadata["e2eeVersion"] != strconv.Itoa(want) || api.sent.Text != "" || len(api.sent.Chunks) != 5 || api.sequence != store.state.LastReqSeq { + t.Fatalf("group send did not preserve negotiation/encryption: version=%d, err=%v", crypto.groupVersion, err) + } + if mode == "mention" && api.sent.ContentMetadata["MENTION"] == "" { + t.Fatal("lost mention metadata") + } + if mode == "reply" && api.sent.RelatedMessageID != "123" { + t.Fatal("lost reply relation") + } + }) + } + } +} + +func TestGroupSendRejectsUnsupportedVersionBeforeMutation(t *testing.T) { + for _, version := range []int{-1, 3, 99} { + c, api, crypto, store := setup(t, false) + api.group = &line.E2EEGroupSharedKey{GroupKeyID: 33, Creator: "u-self", CreatorKeyID: 11, ReceiverKeyID: 11, EncryptedSharedKey: "wrapped", SpecVersion: version} + before := store.state.LastReqSeq + if _, err := c.Send("c-group", "synthetic"); err == nil { + t.Fatal("accepted unsupported version") + } + if api.sends != 0 || api.registrations != 0 || len(crypto.unwrapped) != 0 || store.state.LastReqSeq != before { + t.Fatal("mutated before validating negotiation") + } + } +} + +func TestGroupVersionRefreshAndEncryptionFailureNeverDowngrade(t *testing.T) { + c, api, crypto, _ := setup(t, false) + api.group = &line.E2EEGroupSharedKey{GroupKeyID: 33, Creator: "u-self", CreatorKeyID: 11, ReceiverKeyID: 11, EncryptedSharedKey: "wrapped", SpecVersion: 1} + if _, err := c.Send("c-group", "synthetic"); err != nil { + t.Fatal(err) + } + api.group.SpecVersion = 2 + if _, err := c.Send("c-group", "synthetic"); err != nil { + t.Fatal(err) + } + if crypto.groupVersion != 2 || api.sent.ContentMetadata["e2eeVersion"] != "2" { + t.Fatal("cached stale send version") + } + crypto.encryptErr = errors.New("synthetic cipher failure") + if _, err := c.Send("c-group", "synthetic"); err == nil || api.sends != 2 { + t.Fatal("encryption failure caused send or plaintext downgrade") + } +} diff --git a/internal/messaging/keys.go b/internal/messaging/keys.go index 71e64b0..ff44213 100644 --- a/internal/messaging/keys.go +++ b/internal/messaging/keys.go @@ -66,7 +66,7 @@ func (c *Client) peerByID(ctx context.Context, mid string, id int) error { // fresh key explicitly as part of preparation for a requested send. func (c *Client) groupKey(ctx context.Context, chat string, id int) error { cacheKey := fmt.Sprintf("%s:%d", chat, id) - if id > 0 && c.groupKeys[cacheKey] { + if _, cached := c.groupKeys[cacheKey]; id > 0 && cached { return nil } var key *line.E2EEGroupSharedKey @@ -91,6 +91,14 @@ func (c *Client) groupKey(ctx context.Context, chat string, id int) error { if key == nil || key.GroupKeyID <= 0 || key.CreatorKeyID <= 0 || key.ReceiverKeyID <= 0 || key.EncryptedSharedKey == "" || (id > 0 && key.GroupKeyID != id) { return errors.New("LINE returned an incomplete or mismatched group key") } + version := key.SpecVersion + if version == 0 { + // Chrome 3.7.2 defaults absent negotiation versions to Nd (2). + version = 2 + } + if id == 0 && version != 1 && version != 2 { + return errors.New("unsupported negotiated group encryption version; nothing was sent") + } if err := c.peerByID(ctx, key.Creator, key.CreatorKeyID); err != nil { return err } @@ -100,7 +108,10 @@ func (c *Client) groupKey(ctx context.Context, chat string, id int) error { } return errors.New("could not unwrap the group key") } - c.groupKeys[fmt.Sprintf("%s:%d", chat, key.GroupKeyID)] = true + c.groupKeys[fmt.Sprintf("%s:%d", chat, key.GroupKeyID)] = version + if id == 0 { + c.groupKeys[cacheKey] = version + } return nil } diff --git a/internal/messaging/test_helpers_test.go b/internal/messaging/test_helpers_test.go index ba313a4..e0a9130 100644 --- a/internal/messaging/test_helpers_test.go +++ b/internal/messaging/test_helpers_test.go @@ -110,6 +110,7 @@ type fakeCrypto struct { registered []int unwrapped []int decryptCalls int + groupVersion int } func (f *fakeCrypto) LoadMyKeyFromExportedMap(keys map[string]string) error { @@ -150,6 +151,11 @@ func (f *fakeCrypto) EncryptGroupMessage(string, string, string) ([]string, erro return encryptedChunks(11, 33), f.encryptErr } +func (f *fakeCrypto) EncryptGroupMessageVersion(chat, from string, version int, text string) ([]string, error) { + f.groupVersion = version + return f.EncryptGroupMessage(chat, from, text) +} + func (f *fakeCrypto) GenerateGroupKey() (int, error) { return 33, nil } func (f *fakeCrypto) WrapGroupKeyForMember(string, int) (string, error) { return "wrapped", nil } diff --git a/pkg/e2ee/manager.go b/pkg/e2ee/manager.go index 69c20b9..616fa21 100644 --- a/pkg/e2ee/manager.go +++ b/pkg/e2ee/manager.go @@ -622,16 +622,29 @@ func (m *Manager) ensureGroupChannel(chatMid string, groupKeyID, unwrappedKeyID, } func (m *Manager) EncryptGroupMessage(chatMid, fromMid string, plaintext string) ([]string, error) { + return m.EncryptGroupMessageVersion(chatMid, fromMid, 2, plaintext) +} + +// EncryptGroupMessageVersion uses the group's negotiated Letter Sealing version. +func (m *Manager) EncryptGroupMessageVersion(chatMid, fromMid string, version int, plaintext string) ([]string, error) { payload, err := json.Marshal(map[string]string{"text": plaintext}) if err != nil { return nil, fmt.Errorf("failed to marshal payload: %w", err) } - return m.EncryptGroupMessageRaw(chatMid, fromMid, 0, payload) + return m.EncryptGroupMessageRawVersion(chatMid, fromMid, version, 0, payload) } -// group messages are encrypted differently compared to 1:1 messages. -// Falls back to V1 if V2 fails (some clients like iOS still use V1). +// EncryptGroupMessageRaw encrypts a group payload using V2. func (m *Manager) EncryptGroupMessageRaw(chatMid, fromMid string, contentType int, payload []byte) ([]string, error) { + return m.EncryptGroupMessageRawVersion(chatMid, fromMid, 2, contentType, payload) +} + +// EncryptGroupMessageRawVersion selects the negotiated cipher explicitly. A V2 +// failure must not return V1 chunks that the caller would label as V2. +func (m *Manager) EncryptGroupMessageRawVersion(chatMid, fromMid string, version, contentType int, payload []byte) ([]string, error) { + if version != 1 && version != 2 { + return nil, errors.New("unsupported group encryption version") + } m.mu.Lock() groupKeyID, ok := m.latestGroupKey[chatMid] var unwrappedKeyID int @@ -652,13 +665,12 @@ func (m *Manager) EncryptGroupMessageRaw(chatMid, fromMid string, contentType in return nil, err } + if version == 1 { + return m.encryptV1Chunks(chanID, myKeyID, groupKeyID, payload) + } ctB64, err := m.runner.ChannelEncryptV2(chanID, chatMid, fromMid, myKeyID, groupKeyID, contentType, seq, string(payload)) if err != nil { - if isFatalLTSMError(err) { - return nil, err - } - // V2 failed, fall back to V1 (some clients like iOS still use V1) - return m.encryptV1Chunks(chanID, myKeyID, groupKeyID, payload) + return nil, err } ctBytes, err := base64.StdEncoding.DecodeString(ctB64) if err != nil { diff --git a/pkg/e2ee/manager_test.go b/pkg/e2ee/manager_test.go index 9cf6ad7..cd3dc77 100644 --- a/pkg/e2ee/manager_test.go +++ b/pkg/e2ee/manager_test.go @@ -1,6 +1,7 @@ package e2ee import ( + "encoding/base64" "errors" "fmt" "strings" @@ -25,6 +26,64 @@ func TestIsFatalLTSMError(t *testing.T) { } } +func TestGroupEncryptionV1RoundTrip(t *testing.T) { + m, err := NewManager() + if err != nil { + t.Fatal(err) + } + sender, err := m.runner.KeyGenerate() + if err != nil { + t.Fatal(err) + } + public, err := m.runner.KeyGetPublic(sender) + if err != nil { + t.Fatal(err) + } + group, err := m.runner.KeyGenerate() + if err != nil { + t.Fatal(err) + } + const chat = "c00000000000000000000000000000000" + const from = "u11111111111111111111111111111111" + m.myKeyID, m.myRawKeyID = sender, 11 + m.peerPublic[11] = public + m.groupKeys[chat] = map[int]int{33: group} + m.latestGroupKey[chat] = 33 + chunks, err := m.EncryptGroupMessageVersion(chat, from, 1, "synthetic") + if err != nil { + t.Fatal(err) + } + if len(chunks) != 5 { + t.Fatal("invalid chunks") + } + salt, err := base64.StdEncoding.DecodeString(chunks[0]) + if err != nil { + t.Fatal(err) + } + mac, err := base64.StdEncoding.DecodeString(chunks[2]) + if err != nil { + t.Fatal(err) + } + if len(salt) != 8 || len(mac) != 16 { + t.Fatal("cipher framing is not V1") + } + payload, key, err := m.DecryptGroupMessage(&line.Message{From: from, To: chat, ToType: 2, Chunks: chunks, ContentMetadata: map[string]string{"e2eeVersion": "1"}}, chat) + if err != nil || key != 33 || payload != `{"text":"synthetic"}` { + t.Fatalf("V1 roundtrip failed: %v", err) + } + for _, version := range []int{0, 3, -1} { + if _, err := m.EncryptGroupMessageVersion(chat, from, version, "synthetic"); err == nil { + t.Fatal("unsupported version accepted") + } + } + // Force a V2 runner failure after channel preparation. Preserve that error; + // the former fallback replaced it with a "V1 encrypt failed" error. + m.groupSessionChannels[chat][33][11] = -1 + if chunks, err := m.EncryptGroupMessageVersion(chat, from, 2, "synthetic"); err == nil || err.Error() != "unknown channel: -1" || chunks != nil { + t.Fatalf("V2 failure fell back or returned chunks: %v", err) + } +} + func TestUnwrapGroupSharedKeyReturnsMissingOwnPrivateKey(t *testing.T) { manager := &Manager{ peerPublic: map[int]string{1234: "creator-public-key"}, diff --git a/pkg/line/client.go b/pkg/line/client.go index 77b7397..fa0e884 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -407,13 +407,13 @@ func (c *Client) callRPCWithBaseURLContext(ctx context.Context, baseURL, service } resp, err := c.HTTPClient.Do(req) if err != nil { - return nil, fmt.Errorf("request failed: %w", err) + return nil, requestFailure("transport", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("read LINE response: %w", err) + return nil, requestFailure("response_read", err) } if err := responseError(resp.StatusCode, respBody); err != nil && (resp.StatusCode != 200 || IsAuthError(err)) { return nil, err diff --git a/pkg/line/diagnostics.go b/pkg/line/diagnostics.go new file mode 100644 index 0000000..fc2b60d --- /dev/null +++ b/pkg/line/diagnostics.go @@ -0,0 +1,69 @@ +package line + +import ( + "encoding/json" + "errors" +) + +// RequestDiagnostic contains only fixed categories and numeric protocol codes. +// It deliberately excludes server messages, bodies, headers, and field values. +type RequestDiagnostic struct { + Kind string `json:"kind"` + HTTPStatus int `json:"http_status,omitempty"` + GatewayCode *int `json:"gateway_code,omitempty"` + Exception string `json:"exception,omitempty"` + ExceptionCode *int `json:"exception_code,omitempty"` +} + +type diagnosticError struct { + kind string + cause error +} + +func (e *diagnosticError) Error() string { return "LINE request failed (" + e.kind + ")" } +func (e *diagnosticError) Unwrap() error { return e.cause } + +func requestFailure(kind string, cause error) error { + return &diagnosticError{kind: kind, cause: cause} +} + +// ErrMissingMessageReceipt means success could not be established. It is not +// evidence that the message was rejected, and must never trigger a retry. +var ErrMissingMessageReceipt = errors.New("LINE returned no message receipt") + +// RequestDiagnostics inspects typed errors only, never arbitrary error text. +// Causes remain wrapped so authentication, cancellation, and outcome handling +// keep their existing behavior. +func RequestDiagnostics(err error) *RequestDiagnostic { + if errors.Is(err, ErrMissingMessageReceipt) { + return &RequestDiagnostic{Kind: "missing_receipt"} + } + var diagnostic *diagnosticError + if errors.As(err, &diagnostic) { + return &RequestDiagnostic{Kind: diagnostic.kind} + } + var response *ResponseError + if !errors.As(err, &response) { + return nil + } + d := &RequestDiagnostic{Kind: "gateway", HTTPStatus: response.Status} + var envelope struct { + Code *int `json:"code"` + Data json.RawMessage `json:"data"` + } + if json.Unmarshal([]byte(response.body), &envelope) == nil { + d.GatewayCode = envelope.Code + var exception struct { + Name string `json:"name"` + Code *int `json:"code"` + } + if json.Unmarshal(envelope.Data, &exception) != nil { + return d + } + switch exception.Name { + case "TalkException", "TokenAuthException": + d.Exception, d.ExceptionCode = exception.Name, exception.Code + } + } + return d +} diff --git a/pkg/line/diagnostics_test.go b/pkg/line/diagnostics_test.go new file mode 100644 index 0000000..5cd0787 --- /dev/null +++ b/pkg/line/diagnostics_test.go @@ -0,0 +1,87 @@ +package line + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" +) + +type failingResponseBody struct{} + +func (failingResponseBody) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } +func (failingResponseBody) Close() error { return nil } + +func TestSendRequestDiagnostics(t *testing.T) { + for _, tc := range []struct { + name, body, kind string + status int + cause error + }{ + {name: "transport", kind: "transport", cause: errors.New("private-transport")}, + {name: "cancelled", kind: "transport", cause: context.Canceled}, + {name: "response_read", kind: "response_read", status: 200}, + {name: "http", body: "private-body", kind: "gateway", status: 502}, + {name: "gateway", body: `{"code":10051,"data":{"name":"TalkException","code":5,"reason":"private-body"}}`, kind: "gateway", status: 200}, + {name: "invalid_json", body: "private-body", kind: "response_envelope", status: 200}, + {name: "missing_code", body: `{"data":{"id":"123"}}`, kind: "response_envelope", status: 200}, + {name: "decode", body: `{"code":0,"data":{"id":123,"text":"private-body"}}`, kind: "message_decode", status: 200}, + {name: "null_receipt", body: `{"code":0,"data":null}`, kind: "missing_receipt", status: 200}, + {name: "empty_receipt", body: `{"code":0,"data":{}}`, kind: "missing_receipt", status: 200}, + {name: "success", body: `{"code":0,"data":{"id":"9007199254740993"}}`, status: 200}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + c := NewClient("synthetic") + c.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + calls++ + if tc.cause != nil { + return nil, tc.cause + } + response := qrTestResponse(tc.status, tc.body) + if tc.name == "response_read" { + response.Body = failingResponseBody{} + } + return response, nil + })} + sent, err := c.SendMessageContext(context.Background(), 1, &Message{To: "c-test", Text: "synthetic"}) + if calls != 1 { + t.Fatalf("send calls: %d", calls) + } + if tc.kind == "" { + if err != nil || sent == nil || sent.ID != "9007199254740993" { + t.Fatal("lost successful receipt") + } + return + } + d := RequestDiagnostics(err) + if err == nil || d == nil || d.Kind != tc.kind { + t.Fatalf("diagnostic: %+v; error: %v", d, err) + } + if tc.cause != nil && !errors.Is(err, tc.cause) { + t.Fatal("lost original cause") + } + encoded, _ := json.Marshal(d) + if strings.Contains(string(encoded)+err.Error(), "private-") { + t.Fatal("diagnostics leaked data") + } + if tc.name == "gateway" && (d.HTTPStatus != 200 || d.GatewayCode == nil || *d.GatewayCode != 10051 || d.Exception != "TalkException" || d.ExceptionCode == nil || *d.ExceptionCode != 5) { + t.Fatalf("missing safe codes: %+v", d) + } + }) + } +} + +func TestRequestDiagnosticsOnlyExposeKnownExceptionNames(t *testing.T) { + err := responseError(400, []byte(`{"code":10051,"data":{"name":"private-name","code":123}}`)) + d := RequestDiagnostics(err) + if d.Exception != "" || d.ExceptionCode != nil || d.GatewayCode == nil || *d.GatewayCode != 10051 { + t.Fatalf("unexpected diagnostic: %+v", d) + } + if RequestDiagnostics(errors.New("HTTP 400 private-body")) != nil { + t.Fatal("classified arbitrary error text") + } +} diff --git a/pkg/line/errors.go b/pkg/line/errors.go index 8be46d5..ccbb543 100644 --- a/pkg/line/errors.go +++ b/pkg/line/errors.go @@ -372,8 +372,11 @@ func mutationResponse(body []byte) error { var envelope struct { Code *int `json:"code"` } - if json.Unmarshal(body, &envelope) != nil || envelope.Code == nil { - return errors.New("LINE returned an invalid mutation response") + if err := json.Unmarshal(body, &envelope); err != nil { + return requestFailure("response_envelope", err) + } + if envelope.Code == nil { + return requestFailure("response_envelope", errors.New("missing response code")) } return responseError(200, body) } diff --git a/pkg/line/history.go b/pkg/line/history.go index 4604982..46eeed0 100644 --- a/pkg/line/history.go +++ b/pkg/line/history.go @@ -34,16 +34,22 @@ func decodeHistoryResponse(resp []byte, count int) ([]*Message, error) { Code *int `json:"code"` Data json.RawMessage `json:"data"` } - if json.Unmarshal(resp, &wrapper) != nil || wrapper.Code == nil || *wrapper.Code != 0 { - return nil, errors.New("history request failed") + if err := json.Unmarshal(resp, &wrapper); err != nil { + return nil, requestFailure("response_envelope", err) + } + if wrapper.Code == nil { + return nil, requestFailure("response_envelope", errors.New("missing response code")) + } + if *wrapper.Code != 0 { + return nil, responseError(200, resp) } // A missing/null payload must not masquerade as the end of history. var messages []*Message if len(wrapper.Data) == 0 || wrapper.Data[0] != '[' || json.Unmarshal(wrapper.Data, &messages) != nil { - return nil, errors.New("invalid history response") + return nil, requestFailure("message_decode", errors.New("invalid history response")) } if len(messages) > count { - return nil, errors.New("history response exceeds requested count") + return nil, requestFailure("message_decode", errors.New("history response exceeds requested count")) } return messages, nil } diff --git a/pkg/line/methods.go b/pkg/line/methods.go index 5ff0796..1f6681a 100644 --- a/pkg/line/methods.go +++ b/pkg/line/methods.go @@ -412,7 +412,10 @@ func (c *Client) SendMessageContext(ctx context.Context, reqSeq int64, msg *Mess Data *Message `json:"data"` } if err := json.Unmarshal(resp, &wrapper); err != nil { - return nil, err + return nil, requestFailure("message_decode", err) + } + if wrapper.Data == nil || wrapper.Data.ID == "" { + return nil, ErrMissingMessageReceipt } return wrapper.Data, nil }