diff --git a/.github/coverage-baseline.tsv b/.github/coverage-baseline.tsv new file mode 100644 index 00000000..695cb04f --- /dev/null +++ b/.github/coverage-baseline.tsv @@ -0,0 +1,11 @@ +# Max of historical development baseline and measured upstream baseline at 913362278cb8d7b39b90e04be3d54b43e3aba843. +# package covered total non_generated_covered non_generated_total +github.com/beeper/line/cmd/matrix-line 0 3 0 3 +github.com/beeper/line/pkg 235 736 235 736 +github.com/beeper/line/pkg/connector 1537 4433 1537 4433 +github.com/beeper/line/pkg/connector/handlers 368 985 368 985 +github.com/beeper/line/pkg/e2ee 223 608 223 608 +github.com/beeper/line/pkg/line 419 1567 419 1567 +github.com/beeper/line/pkg/line/password 0 26 0 26 +github.com/beeper/line/pkg/line/secret 0 7 0 7 +github.com/beeper/line/pkg/ltsm 171289 404716 545 1061 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3a575616..8ce04801 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -78,6 +78,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Login to registry + if: github.event_name == 'push' && github.repository == 'beeper/line' uses: docker/login-action@v3 with: registry: ${{ secrets.CI_REGISTRY }} @@ -88,13 +89,14 @@ jobs: uses: docker/build-push-action@v5 with: context: . - cache-from: ${{ env.CI_REGISTRY_IMAGE }}:latest + cache-from: type=gha,scope=line-linux-amd64 + cache-to: type=gha,mode=max,scope=line-linux-amd64 pull: true file: Dockerfile tags: | - ${{ env.CI_REGISTRY_IMAGE }}:${{ github.sha }} - ${{ env.CI_REGISTRY_IMAGE }}:${{ github.sha }}-amd64 - push: true + ${{ github.event_name == 'push' && github.repository == 'beeper/line' && format('{0}:{1}', env.CI_REGISTRY_IMAGE, github.sha) || format('line-ci:{0}', github.sha) }} + ${{ github.event_name == 'push' && github.repository == 'beeper/line' && format('{0}:{1}-amd64', env.CI_REGISTRY_IMAGE, github.sha) || format('line-ci:{0}-amd64', github.sha) }} + push: ${{ github.event_name == 'push' && github.repository == 'beeper/line' }} deploy-docker: runs-on: ubuntu-latest @@ -102,7 +104,7 @@ jobs: - lint - build - build-docker - if: github.ref == 'refs/heads/main' + if: github.ref == 'refs/heads/main' && github.repository == 'beeper/line' steps: - name: Login to Beeper Docker registry uses: docker/login-action@v3 @@ -125,3 +127,60 @@ jobs: BEEPER_DEV_ADMIN_NIGHTLY_PASS: "${{ secrets.BEEPER_DEV_ADMIN_NIGHTLY_PASS }}" BEEPER_STAGING_ADMIN_NIGHTLY_PASS: "${{ secrets.BEEPER_STAGING_ADMIN_NIGHTLY_PASS }}" BEEPER_PROD_ADMIN_NIGHTLY_PASS: "${{ secrets.BEEPER_PROD_ADMIN_NIGHTLY_PASS }}" + + test: + name: Test (${{ matrix.mode }}) + permissions: + contents: read + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + mode: [normal, race, coverage] + env: + GOMAXPROCS: "4" + TEST_PACKAGE_PARALLELISM: "2" + TEST_TIMEOUT: 10m + TEST_JSON: "1" + TEST_OUTPUT_DIR: ${{ github.workspace }}/.opencode-work/test-ci-${{ matrix.mode }} + GOPATH: ${{ github.workspace }}/.opencode-work/gopath + GOCACHE: ${{ github.workspace }}/.opencode-work/go-build-cache + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + id: go + with: + go-version: "1.26" + cache: false + - name: Install libolm + run: sudo apt-get update && sudo apt-get install -y libolm-dev + - name: Identify native test ABI + id: native + run: echo "abi=$(gcc -dumpfullversion)-$(pkg-config --modversion olm)" >> "$GITHUB_OUTPUT" + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ${{ env.GOPATH }}/pkg/mod + key: test-modules-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('go.sum') }} + - name: Cache compiled tests by instrumentation mode + uses: actions/cache@v4 + with: + path: ${{ env.GOCACHE }} + key: test-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.go.outputs.go-version }}-${{ steps.native.outputs.abi }}-${{ matrix.mode }}-${{ hashFiles('go.sum') }}-${{ hashFiles('**/*.go', 'pkg/ltsm/data/*') }} + restore-keys: | + test-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.go.outputs.go-version }}-${{ steps.native.outputs.abi }}-${{ matrix.mode }}-${{ hashFiles('go.sum') }}- + - name: Verify LINE login aliases for release + if: matrix.mode == 'normal' + run: TEST_RUN='^TestRegisteredLineLoginCommandDispatch$' bash scripts/test.sh fast ./pkg/connector + - name: Run validation + run: bash scripts/test.sh "${{ matrix.mode }}" + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.mode }} + path: | + ${{ env.TEST_OUTPUT_DIR }}/coverage.out + if-no-files-found: warn + retention-days: 7 diff --git a/.gitignore b/.gitignore index f8945039..bef8bec4 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ # internal tools /tools + +# OpenCode-only validation artifacts and continuity records +/.opencode-work/ +/CONTINUITY.md diff --git a/README.md b/README.md index 732232fe..3c0f14f5 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,9 @@ Based on the [mautrix-twilio](https://github.com/mautrix/twilio) bridge > need to log out of the bridge first as the bridge will try to automatically > log back in and invalidate the LINE Chrome session again. +For direct-chat identifiers, synchronization triggers, outbound-message diagnostics, +and QR login troubleshooting, see [the debugging guide](readme/DEBUGGING.md). + ## Features - [x] Messages diff --git a/build.sh b/build.sh index 62df5ef3..8143c5fd 100755 --- a/build.sh +++ b/build.sh @@ -1,4 +1,9 @@ #!/bin/sh MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }' | head -n1) -GO_LDFLAGS="-s -w -X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date -Iseconds`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" -go build -ldflags="$GO_LDFLAGS" ./cmd/matrix-line "$@" +if [ -n "${SOURCE_DATE_EPOCH:-}" ]; then + BUILD_TIME=$(date -u -Iseconds -d "@$SOURCE_DATE_EPOCH") +else + BUILD_TIME=$(date -Iseconds) +fi +GO_LDFLAGS="-s -w -X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=$BUILD_TIME' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" +go build -buildvcs=false -ldflags="$GO_LDFLAGS" "$@" ./cmd/matrix-line diff --git a/docs/FEATURE_PARITY.md b/docs/FEATURE_PARITY.md new file mode 100644 index 00000000..6793fd0e --- /dev/null +++ b/docs/FEATURE_PARITY.md @@ -0,0 +1,43 @@ +# Feature parity with the previous development checkpoint + +Comparison baseline: checkpoint `910b4b3dceceb1dcd0e0b34e805ffc83d13d594f` +and known working tree `4eec1754b7916cbeed622529e5c9fb9ca129f6d2`, compared +with upstream-first release source `e50154bda1f0ac2632574048760d1bee18e5331a`. +The new source is a separate upstream integration history, so ancestry alone is +not evidence of parity. Feature and test paths were reviewed directly. + +| Historical feature | Source | Current implementation | Status / action | Coverage | +| --- | --- | --- | --- | --- | +| Friendly `login qr`, `login email`, `login help` | `4e9b184` | `pkg/connector/login_command.go`; registered in `LineConnector.Init` | Missing in rebuild because custom handler registration was omitted; restored | `pkg/connector/login_command_test.go` uses real framework processor | +| QR auth lifecycle and flow ID | upstream PR #271; `09a8685`, `6e333e5`, `4eec175` | `connector.go`, `login_qr.go`, `pkg/line/qr.go` | Present; alias dispatch is separate | `login_qr_test.go`, `qr_failure_test.go`, `qr_error_test.go` | +| Sanitized provider auth errors and cooldown | `6e333e5`, `596334a`, `4eec175`, `c6cf461`, `c1d2fad`, `7c4303c` | `loginerrors.go`, `login_cooldown.go`, `pkg/line/auth_error.go`, `pkg/line/errors.go` | Present and extended; do not roll back | `login_cooldown_test.go`, `login_error_logging_test.go`, `auth_error_test.go` | +| Public LINE ID resolution before portal creation | `695d6d9` | `userinfo.go`, `resolve_identifier.go` | Present | `resolve_identifier_test.go`, `identity_audit_test.go` | +| MID normalization and opaque case-sensitive IDs | `7615af2`, `05a1607`, `bde3468` | `pkg/line/user_mid.go`, `resolve_identifier.go`, `userinfo.go` | Present, with deliberate preservation of gateway identity case | `user_mid_test.go`, `opaque_mid_test.go`, `resolve_identifier_test.go` | +| Contact lookup validation / empty query safety | `160ac20`, `46a2e79` | `userinfo.go` | Present; validates returned contacts and avoids empty-query enumeration | `resolve_identifier_test.go` | +| Optional sanitized lookup-shape diagnostics | `65a2c02` | No current diagnostics code/config | Removed/superseded from rebuild. Not restored: diagnostics were opt-in but the old response parser/key allowlist is not present for current protocol shapes; requires a fresh privacy review and tests before reintroduction | Historical tests existed; no current coverage | +| Portal creation, canonical reuse, reopen and cancellation | `695d6d9`, `15ff9e4`, `971a828` | `resolve_identifier.go`, bridgev2 portal flow | Present | `resolve_identifier_test.go`, `identity_audit_test.go` | +| Outbound delivery, cancellation, provider error status | `c4ffe53`, `0597689`, `72c7a51` | `send_message.go`, `senderrors.go`, connector status mapping | Present | `send_message_test.go`, `outbound_pipeline_test.go` | +| Inbound handling and sync diagnostics | `022678d`, `d57862a` | `handle_message.go`, `sync.go` and bridge structured logs | Present; current upstream path retained | `handle_message_test.go`, `sync_test.go` | +| Media auth, image metadata, HEIC, audio, stickers, link previews | `0b79d0f`, `17ae6dc`, `e2670d4`, `6f7f92b`, `055d62e`, upstream PR #286 | `media.go`, `pkg/line/publicmedia.go`, handlers | Present in current rebuild | media and handler tests including `media_test.go`, `image_media_test.go`, `text_test.go` | +| Group E2EE spec version / crypto runtime behavior | `c5a432d`, `9be42f2`, `8811f31`, `b68db47` | `e2ee_keys.go`, `pkg/e2ee`, `pkg/ltsm` | Present/current implementation retained | `e2ee_keys_test.go`, `manager_test.go` | +| Test optimization and coverage gates | test optimization PR #1; `a8d80f2`, `1b0db13` | `scripts/test.sh`, `scripts/check_test_coverage.py`, coverage workflow | Present | normal/race/coverage/release modes and coverage gate | +| Build/release reproducibility and native packaging | `ffb62b5`, `e50154b` | `build.sh`, release scripts/docs | Present; exact release artifacts preserved | release validation workflow | + +## Intentional differences + +- The mautrix-go framework still accepts fully qualified login flow IDs; the + LINE aliases wrap, rather than replace, its standard `login` handler and keep + the framework's permissions, login limits, relogin, and command-state logic. +- QR login remains controlled by `network.qr_login`. The friendly help omits QR + when that option is disabled. +- Historical lookup diagnostics are not blindly restored. Current identity + validation and safe errors remain; any new diagnostics need current-schema + allowlisting and a privacy-focused review. +- No production deployment or database/session changes are part of this work. + +## Remaining gaps + +- The historical lookup-diagnostics feature needs a fresh implementation if + operational evidence shows it is still needed. +- Live Matrix/LINE behavior must be validated after an operator deploys the + prepared release candidate; this source change does not deploy it. diff --git a/docs/PR297_BEFORE_AFTER.md b/docs/PR297_BEFORE_AFTER.md new file mode 100644 index 00000000..817eb5f9 --- /dev/null +++ b/docs/PR297_BEFORE_AFTER.md @@ -0,0 +1,57 @@ +# PR #297: before and after + +Review alongside the [replay matrix](PR297_TEST_MATRIX.md) and +[blast-radius assessment](PR297_BLAST_RADIUS.md). + +## Comparison boundary + +“Before” below means upstream `45a48b9b3b57330d99d326d443f9a9fc8725a48f`, +the last upstream commit integrated into this branch. “After” is PR source +`81f502150b88e681d2703239df608b1b38ccc2d6` plus documentation-only updates. +It is **not** a claim that today's upstream lacks every improvement below. +On 2026-11-02, upstream was `10e0a5fdd18440d4b5c23b5dd0f9cc75cc7dd3fe`; +GitHub reports a conflict. A read-only `git merge-tree` check located the +content conflict in `pkg/connector/client.go`. Upstream #294 (transient token +recovery), #298 (persistent E2EE reconnect prompt), #299 (avatar concurrency), +#295 (send/media error mapping), #302 (media size handling), and #303 (stickers) +must be preserved and revalidated during a future integration/split. This +documentation update neither resolves that conflict nor changes runtime code. + +## Behavioral matrix + +Test names below link to tests in this branch. These are local fixture/mock +tests, not evidence of successful live authentication or message delivery. + +| Component / feature | Before PR #297 | After PR #297 | Observable behavior | Test / verification reference | Risk / compatibility impact | +| --- | --- | --- | --- | --- | --- | +| `login`, `login help` | Framework lists full flow IDs; `help` is treated as a flow ID | LINE handler provides friendly method usage | Help without starting LINE auth | [login command test](../pkg/connector/login_command_test.go), `TestRegisteredLineLoginCommandDispatch` | Changes default command UX; framework permissions retained | +| `login qr`, `login email`, full IDs, QR disabled | Short methods invalid; full IDs already supported; QR already configurable | Case-insensitive aliases select existing flows; full IDs retained; disabled QR omitted/rejected | One selected flow or useful unavailable-method guidance | Same test; [handler](../pkg/connector/login_command.go), registered in `LineConnector.Init` | Flow selection only; not a replacement QR protocol; no new config key | +| QR creation, scan/PIN verification, token issue | QR implementation already exists; overlapping Start/Wait calls can mutate/consume state; deadline errors can become cancellation | Overlap rejected, deadline/cancel retained; token checked before session finalization | Stages complete in order; malformed/incomplete results fail rather than silently persisting | [QR lifecycle tests](../pkg/connector/login_qr_test.go): `TestQRConcurrentWaitIsRejected`, `TestQRApprovalPINAndTokenValidation`, `TestQRExpiryAndCancellation`, `TestQRStartPreservesDeadline`; [wire contract](../pkg/line/qr_contract_test.go) | High-risk login lifecycle path; server compatibility needs live confirmation | +| QR rejection / auth stages / session restoration | QR failures expose limited status/code detail; generic session invalidation can imply another client | Structured QR-create/verify/token and email/session stages; neutral invalidation wording; passwordless reconnect still requires approval | Distinguish provider rejection, temporary verification, network failure and session establishment | [QR error tests](../pkg/line/qr_error_test.go), `TestQRAuthErrorStage`; [logging tests](../pkg/connector/login_error_logging_test.go), `TestSessionEstablishmentErrorKeepsStageAndSafeMessage`; `TestQRPasswordlessReconnectNeedsApproval` | Does not bypass mobile approval, fix restrictions, or guarantee session restoration | +| Provider messages | Email reason/error text can be propagated too broadly; QR detail too sparse | Classified errors with bounded selectively redacted safe messages; unsafe structured/URL/control/secret content suppressed | Useful safe wording where allowed; generic category fallback otherwise | [sanitizer](../pkg/line/auth_error.go), [tests](../pkg/line/auth_error_test.go), `TestSanitizeProviderMessage`; [rejection tests](../pkg/connector/qr_failure_test.go) | Redaction is conservative and may suppress useful text; not a guarantee that all upstream raw errors/logs are sanitized | +| HTTP 429 / RETRY_LATER / verification unavailable | No shared connector login cooldown | Defaults: 15 / 5 / 10 minutes respectively; Retry-After can extend wait | Immediate re-attempt blocked locally; cooldown is not an automatic delayed retry | [cooldown tests](../pkg/connector/login_cooldown_test.go): `TestQRStartupRateLimitMapsAndStartsCooldown`, `TestVerificationTemporarilyUnavailableBlocksRetry`, `TestLoginRateLimitClassification`; [HTTP tests](../pkg/line/qr_error_test.go): `TestParseRetryAfter`, `TestHTTPErrorPreservesRetryAfter` | High account-safety impact; bridge wait does not establish LINE's actual restriction duration | +| Login serialization / stored-credential recovery | Interactive and stored-credential paths lack shared claims/cooldown enforcement | Claims and waits shared by Matrix user ID; recovery checks them before credential request; release on cancel/error | Concurrent login rejected; stored credential path does not bypass active wait | `TestLoginClaimSerializesSameAccount`, `TestStoredCredentialRecoveryRespectsActiveCooldown`, `TestStoredCredentialRecoveryHonorsHTTPRetryAfter`, `TestCanceledEmailVerificationReleasesLoginClaim` | In-memory, lost at restart; two Matrix users for one LINE account are not globally coordinated; multiple logins for one Matrix user share the gate | +| `start-chat ` / lookup-only | Treats input as MID; creates portal record before lookup and ignores chat-info error | Public ID resolves via `findContactByUserid`; validates before portal persistence; lookup-only does not create chat | Correct recipient or clear failure instead of retrying a fabricated portal | [resolver tests](../pkg/connector/resolve_identifier_test.go): `TestResolveIdentifierPublicMIDAndSearch`, `TestResolveIdentifierFailuresDoNotPersistPortals`; [resolver](../pkg/connector/resolve_identifier.go) | High identity/routing impact; depends on LINE ID-lookup permission; no display-name choice for start-chat | +| Internal MIDs / opaque gateway IDs | No shared validated normalization for returned IDs | Legacy 33-character hexadecimal MID canonicalized; 44-character `U…` gateway ID preserves case | Public ID and internal ID reach the same validated identity | [MID tests](../pkg/line/user_mid_test.go), [opaque tests](../pkg/connector/opaque_mid_test.go), `TestResolveIdentifierPreservesOpaqueMIDCase`; [identity audit](../pkg/connector/identity_audit_test.go) | Not every value beginning with `M`/`U` is supported; unknown shapes rejected, not guessed | +| Portal reuse / reopen / lookup validation | Portal key can be the unresolved public string; lookup errors discarded | Canonical recipient + login receiver key; framework reuses/reopens room; mismatched/ambiguous responses rejected | Repeat start-chat reuses DM; failed lookup does not create new row | `TestStartChatCreatesReusesAndRetriesRoom`, `TestResolveIdentifierConcurrentCanonicalPortalReuse`, `TestResolveIdentifierDeadlineAndMismatchedMID`, `TestSearchUsersRejectsUnrequestedContactMID` | No migration of old incorrectly keyed portals; malformed provider data may now fail closed | +| Search / sync requests | Exact result may still fall back to contact enumeration; search errors hidden | Exact result returns immediately; empty search no requests; errors returned; public ID resolved afresh, contact data cached by MID | Less unnecessary contact lookup for exact ID; no added bulk-sync policy | `TestSearchUsersEmptyQueryDoesNotEnumerateContacts`, resolver tests; [userinfo](../pkg/connector/userinfo.go), [sync diff](../pkg/connector/sync.go) | Fallback search still lists contacts/batches (cap 20 from upstream #296); connect/fullSync reconciliation still exists | +| Outbound cancellation / error / acknowledgement | Matrix request context not explicitly passed to send RPC; missing message ID can look successful; provider error codes lost | Context-aware send; require nonempty LINE ack ID; preserve provider error structure for existing recovery | Failed/empty ack not reported as success; cancellation reaches request | [outbound tests](../pkg/connector/outbound_pipeline_test.go): `TestOutboundOwnerAndCanonicalRecipient`, `TestOutboundRequestCancellation`; [ack tests](../pkg/line/send_message_response_test.go) | High-risk delivery path; ack is server acceptance, not read/delivery proof. Existing reqSeq/retry machinery retained; no new exactly-once guarantee | +| Encrypted inbound media / direct-media handler | Trailing MAC stripped without verification | Verify HMAC before AES-CTR plaintext; video uses chunk-hash MAC input; wrong key/mode/corruption rejected | Corrupt/unsupported encrypted content fails rather than displaying unauthenticated bytes | [media tests](../pkg/connector/media_hmac_test.go), `TestMediaHMAC`; [handler routing](../pkg/connector/handlers/handler.go) | Security behavior intentionally stricter; affects images, files, audio, video, thumbnails; requires real client-format tests | +| Inbound image metadata | Always image.jpg / image/jpeg without new dimensions info | Detect MIME/extension/byte size; dimensions where decoder supports them; bounded AVIF/HEIC/HEIF brands; JPEG fallback | Correct Matrix metadata when recognized; dimensions may be absent | [metadata tests](../pkg/connector/handlers/image_media_test.go), `TestLineImageMediaInfo`, `TestLineImageBrands`; [image handler](../pkg/connector/handlers/image.go) | Metadata detection is not universal format decoding/transcoding support | +| Link preview media | Base already uploads preview images to Matrix | Adds injection-based coverage; stops logging failed preview URL/raw error | Existing upload behavior preserved; less sensitive log context | [text tests](../pkg/connector/handlers/text_test.go), `TestConvertTextUploadsOnlyImageLinkPreviews` | Do not credit this PR with upstream's earlier preview upload feature; HEIC/audio/sticker improvements already in integrated upstream retained | +| Test performance | Expensive redundant crypto/cache fixtures and timing sleeps | Real native heap pressure fixture; bounded cache rotations; deterministic concurrency; vector output in temp dirs | Faster isolated fixtures; stress/recovery assertions retained | [runner fixtures](../pkg/runner_secret_test.go), [memory tests](../pkg/ltsm/memory_test.go), [album tests](../pkg/connector/handlers/post_notification_test.go) | Exported native allocation wrapper added for fixtures; generated crypto not edited | +| Validation / coverage / CI cache | No new test-mode scripts/package floors | Fast/normal/race/coverage/release modes; per-package gates; CI normal/race/coverage; instrumentation-keyed cache; fork Docker push/deploy guards | Local replay plus bounded CI jobs without publishing fork images to Beeper | [script](../scripts/test.sh), [checker](../scripts/check_test_coverage.py), [workflow](../.github/workflows/deploy.yml) | Cache path shared but keys vary by mode/toolchain/ABI; release is a local mode, not a fourth CI matrix entry. No GitHub checks currently reported | +| Reproducible builds | Wall-clock timestamp; build flags after package; automatic VCS stamp | SOURCE_DATE_EPOCH supported; flags before package; explicit commit/tag and `-buildvcs=false` | Two builds in same controlled environment reproduce | [build script](../build.sh); checkpoint evidence in replay matrix | Not a universal cross-path/toolchain reproducibility claim; tag presence changes bytes | + +## Attribution and scope + +This consolidates [#271](https://github.com/beeper/line/pull/271): all nine +commits are represented by patch ID in this history. It adapts compatible work +from [fork PR #1](https://github.com/meramsey/line/pull/1) (two identical patches, +later adaptations of tooling; old benchmark report intentionally excluded). +Media authentication and image metadata retain attribution to Adrien Vermersch's +[#196](https://github.com/beeper/line/pull/196) and +[#189](https://github.com/beeper/line/pull/189). +Neither existing PR is closed/retargeted. They overlap; do not merge independently +without comparison. Proposed smaller review units and dependencies are in the +[blast-radius document](PR297_BLAST_RADIUS.md#suggested-smaller-review-units). diff --git a/docs/PR297_BLAST_RADIUS.md b/docs/PR297_BLAST_RADIUS.md new file mode 100644 index 00000000..ef781805 --- /dev/null +++ b/docs/PR297_BLAST_RADIUS.md @@ -0,0 +1,99 @@ +# PR #297: blast radius and review boundaries + +This is a broad runtime + test/CI PR, not a low-risk documentation-only feature. +The **current update** adds documentation only. Refer to the +[behavior comparison](PR297_BEFORE_AFTER.md) and [test evidence/replay](PR297_TEST_MATRIX.md). + +## Impact by subsystem + +| Subsystem / class | Runtime and users affected | Configuration / persistence | Requests / error policy | Potential failures and validation priority | +| --- | --- | --- | --- | --- | +| Login command UX (user-visible) | Matrix users with login permission invoking `login`; provisioning still uses existing flow IDs | Existing `network.qr_login`; no new required option. Framework permissions remain | Help/invalid/disabled methods do not start provider auth; aliases delegate to same flow | Handler registration/permissions regression; test actual Processor dispatch, disabled QR and full IDs before live auth | +| QR/email auth and stored-credential recovery (high, account safety) | New QR/email sign-ins, re-authentication, token fallback, session establishment | Claims/cooldowns are memory-only, keyed by `User.MXID`; existing stored credentials, certificate, token/key metadata and finalization reused | Overlap blocked, wait enforced before new auth request including stored credentials. Default rate/temporary waits plus longer Retry-After. Not an automatic retry scheduler | Can delay legitimate reconnect or suppress useful provider wording; different Matrix users for same LINE account not coordinated; one Matrix user's several logins share gate. Test cancel/error claim release and stages; use disposable account. Restart clears local cooldown, not LINE's restriction; never recommend restart as bypass | +| Identity lookup, search, portals (high, routing) | Public ID start-chat, lookup-only, MID input, contact search/list | Validated canonical MID + receiver forms portal key; existing portal DB APIs reused; no rewrite of old bad public-ID portals | Public IDs resolved afresh; contact cache keyed by MID. Exact result returns without contact enumeration; fallback search still batches contacts, max 20 results from #296. Empty search no lookup. Errors now visible rather than empty successful result | Unsupported real IDs/provider shapes rejected; old malformed portals can coexist until operator-reviewed cleanup; wrong-recipient routing is critical. Verify known contact, mismatched IDs and portal reuse/reopen before sending | +| Outbound send/error/ack path (high, reliability) | Matrix-to-LINE text/media send paths using sendMessage | Existing sequence allocation/retry/DB ack mechanisms retained; no new persistent dedup store | Pass caller context; preserve provider errors for existing recovery; reject empty ack ID | Cancellation/timeout may occur after LINE accepted message. Re-sending can duplicate; one mock request is not a global exactly-once guarantee. Test failures/ack absence and verify with recipient before live resend | +| Inbound media / metadata (high, integrity/compatibility) | Encrypted image/file/audio/video/thumbnail paths, including shared handler/direct-media decryption; inbound image metadata | No new option; no key/device reset or crypto-store change | Authenticate before plaintext; video-specific MAC input. No new 60-retry OBS policy. MIME/size/dimensions based on received image bytes | Previously tolerated unauthenticated bytes now rejected; real unsupported MAC formats could fail. Synthetic crypto is not full server compatibility. Test each supported type with benign small fixtures. MIME recognition doesn't imply HEIC/AVIF decoder/transcoder coverage | +| Preview/log/sync changes (internal reliability/privacy) | Preview failures, login/send diagnosis, conversation sync logs | Existing structured logging; no new debug config or raw HTTP logging | Preview upload already upstream; safer failure logs and test seam here. Sync policy unchanged: connect/fullSync/backfill reconciliation remains, only trigger labels added | Existing logs still carry sensitive IDs/URLs/error context; sanitize. No claim of eliminating bulk sync or all raw error exposure | +| Native test support / fixtures (mostly test, small runtime API surface) | `pkg/ltsm/memory.go` exposes existing malloc/free wrappers for bounded heap fixtures; other changes in tests | No generated WASM crypto edits, no default runtime policy change | No LINE traffic in test suite | Memory wrappers must only be used correctly; tests retain real exhaustion/recovery, isolation and stress loops. Not a speculative protocol change | +| Test/CI/build infrastructure (not bridge request behavior) | Developers, fork/upstream Actions, build consumers | Per-package floor file, Python checker, scripts; no production config required | Local fast/normal/race/coverage/release; CI normal/race/coverage and caches; fork Docker pushes and deployment restricted | CI resource/cache/coverage regressions; source checking is not live compatibility. Build uses fixed epoch optionally and explicit tag/SHA; same source with different tag state can hash differently | + +## Database and stored-state review + +The diff against the integrated upstream base changes **no SQL/migration/schema +files**, no `go.mod`/`go.sum` framework version, and no fields in +`UserLoginMetadata` or its JSON tags. There is no PR-specific schema migration. +Normal framework DB version checks still run at startup; deployments from other +versions must follow their framework's migration rules. + +Persistent effects still exist: successful login finalization stores tokens, +certificates and E2EE metadata using existing mechanisms; successful canonical +resolution/creation writes ghosts, portals and Matrix room/membership state. +Failed lookups now avoid creating portal rows. No backfill/rekey/migration of +previous wrongly keyed portals is included. Cooldown/active-claim maps are not +persisted. Existing Matrix crypto state/encryption devices are not reset, +disabled or repaired by this PR. The known puppet sender/device ownership +warning remains unresolved. + +## What is untouched / inherited, not a new fix + +- Homeserver configuration, Synapse, appservice registration format and database + schema are not changed by the PR. +- Existing upstream media-size handling at the integrated base, HEIC/audio/ + sticker improvements, bridgev2 room creation and send retry/sequence mechanisms + are not replaced wholesale. Later main changes need separate reconciliation. +- Group-message key-spec logic has additional tests here, not a new algorithm. +- No new auto-login probing, restriction bypass, contact mutation policy, + unconditional room enumeration policy or Matrix encryption weakening. + +## Upgrade / rollback guidance for a reviewer + +Prefer an isolated bridge with a disposable LINE account. Save exact binary, +config and consistent DB backups before installing; keep a test window and +record source/SHA. Run startup + help/lookup before login/send/media tests. +Avoid running both bridge and Chrome-extension session on the same account. + +Binary rollback is straightforward mechanically (restore preserved binary with +original owner/mode, restart **only the test bridge**) because no PR-specific +schema migration is introduced. It is not a full state rollback: server-accepted +messages, remote session invalidation, newly created portals, room membership and +new login metadata cannot be undone by replacing the executable. Preserve state +for diagnosis; do not delete credentials/DB rows, restore a running DB blindly, +retry an ambiguous send, reset crypto devices, or disable encryption as recovery. +If unexpected identity/portal routing or request bursts appear, stop the test +and report a sanitized timestamp/category rather than probing more accounts. + +## Separate CI dependency issue found during documentation review + +The normal test job's explicit alias step runs `scripts/test.sh fast`, which +invokes `staticcheck`. That job installs Go and libolm but does not install +`staticcheck`; the separate lint job's installation does not share its runner. +On a runner without that tool the alias step will fail before tests. Local +validation had the tool on PATH; GitHub reports no checks here, so this is a +workflow dependency gap found by source inspection, not an observed CI failure. +It needs a focused follow-up (install the tool in that job or use an appropriate +test-only invocation while retaining lint elsewhere). No workflow/application +change was folded into this documentation commit. + +## Suggested smaller review units + +The reviewer explicitly asked for smaller chunks. A sensible follow-up split is: + +1. **Identity/DM resolution:** resolver + context-aware lookup RPCs + MID and + portal tests; preserve #296 search cap and newer avatar changes. +2. **Authentication/account safety:** QR lifecycle + typed/sanitized errors + + HTTP Retry-After + shared claims/cooldowns + reconnect tests. These are + coupled and should be validated together with upstream #294/#298. +3. **Messaging/media:** context-aware sends and ack validation, and separately + authenticated inbound media/image metadata with #189/#196 attribution. + Identity-aware send tests currently reuse resolver fixtures; extract shared + fixture support or state an explicit test dependency rather than duplicate it. +4. **Test performance/CI/build:** deterministic fixture and native heap support, + then scripts/coverage/cache/fork safeguards and reproducible build metadata; + coverage baselines must be measured for each resulting runtime branch. + +These are proposed boundaries, **not already-created independent PRs**. #297 +consolidates overlapping #271 and fork #1 work; those PRs remain open and their +branches untouched. Do not merge overlapping versions independently. Current +main `10e0a5f` conflicts in `client.go`; resolve/review on separate focused +branches with fresh tests before treating any split as merge-ready. No runtime +conflict resolution or history rewrite was done for this documentation request. diff --git a/docs/PR297_TEST_MATRIX.md b/docs/PR297_TEST_MATRIX.md new file mode 100644 index 00000000..35632a32 --- /dev/null +++ b/docs/PR297_TEST_MATRIX.md @@ -0,0 +1,139 @@ +# PR #297: reproducible test matrix and evidence + +See [before/after](PR297_BEFORE_AFTER.md) for the comparison boundary and +[blast radius](PR297_BLAST_RADIUS.md) for risks/rollback. This is a replay guide, +not a claim that mocked tests validate LINE's production behavior. + +## Evidence and statuses + +- **AUTOMATED PASS**: local fixture/mock execution, no live LINE services. +- **LIVE VERIFIED**: a specific observed live result with attributable source. +- **MANUAL NOT RUN**: no attributable per-scenario live evidence. +- **NOT TESTABLE WITHOUT PROVIDER CONDITIONS**: do not provoke a restriction. +- **KNOWN LIMITATION**: no claim of a fix/guarantee. + +### What was actually run + +| Evidence | Source / environment | Result and boundary | +| --- | --- | --- | +| Historical full normal/race/coverage/release | Integrated runtime commit `07badf92460e5ae744b90d1c0c0a46cc30396218`; Go 1.26.10, Linux amd64, CGO/libolm 3.2.16 | Each suite: 512 pass test/subtest events, 1 skip, 0 failures. Release repeats all three plus format/vet/staticcheck/module verification/build/coverage-checker tests. All package floors passed. Source diff from this commit through pre-documentation head `81f502150b88e681d2703239df608b1b38ccc2d6` is only `docs/UPSTREAM_INTEGRATION_STATUS.md`; runtime/tests/CI are identical. This is reused evidence, not a newly run exact-head full suite. | +| Fresh targeted replay, 2026-11-02 | `81f502150b88e681d2703239df608b1b38ccc2d6`, before this documentation-only change; same local Go/libolm setup | 146 pass test/subtest events, 0 skips/failures. `fast` also ran gofmt/vet/staticcheck on the three selected packages. Exact command below. | +| Historical skip | `pkg/runner_vectors_replay_test.go`: `TestReplayLTSMVectors` | Explicit existing skip: old JSON IPC replay needs rewriting for the typed/wazero runner. Supplying vectors does not enable this test. This is not a blanket skip of crypto/stress tests. | +| Checkpoint build | `f36c646b468719877c9fb4329f2cab18b652fbca`, v1.2.0+dev.f36c646b | Two same-environment, fixed-epoch builds were identical. Tagged artifact SHA256 `dabf2c816b752341328411ab02fa73d756c20b82097abab38a46e38916649c81`; not a build of today's upstream merge. | +| Historical production startup | Same `f36c646b` version; working binary SHA256 `a8483a0342fc9b0715ec087a6c773a0b40a8b63c122895cc714940c9bee7f4b8` | **LIVE VERIFIED** startup/service active, DB schemas current, Matrix appservice connectivity. This binary predates the checkpoint tag: same source/time/toolchain but empty embedded tag, hence different checksum. Not evidence for every login/media scenario or the PR's later upstream integration. | +| Author's live report | [PR comment](https://github.com/beeper/line/pull/297#issuecomment-6546539362) reports use over multiple days | Useful operational feedback, but no per-scenario/source inventory. Version attribution for individual scenarios is uncertain; do not label them LIVE VERIFIED. | +| GitHub CI | No checks reported when inspected on 2026-11-02 | Not a green CI claim; PR conflicts with newer main. No tests in this guide validate the unresolved combined tree. | + +### Automated replay (no LINE credentials needed) + +Use Go 1.26+, libolm development headers, Python 3, `goimports`, and `staticcheck` +on PATH. Run in a checkout of **this PR branch**, not the production data folder. +Record `git rev-parse HEAD`, `go version`, and `pkg-config --modversion olm`. +Package/event counts include nested subtests, not 512 independent user journeys. + +```sh +TEST_JSON=1 TEST_RUN='Test(RegisteredLineLoginCommandDispatch|ResolveIdentifier|StartChat|OpaqueMID|LookupToOutbound|QR|LoginCooldown|LoginClaim|LoginRateLimit|EmailLoginRate|StoredCredentialRecovery|VerificationTemporarily|CanceledEmail|SubmitUserInputLogs|SessionEstablishment|ReconnectError|SanitizeProvider|ParseRetryAfter|HTTPErrorPreserves|Outbound|SendMessage|MediaHMAC|LineImage|ConvertTextUploads)' \ + bash scripts/test.sh fast ./pkg/connector ./pkg/line ./pkg/connector/handlers + +# Full validation, optional for reviewers but required after integrating conflicts: +bash scripts/test.sh normal +bash scripts/test.sh race +bash scripts/test.sh coverage +bash scripts/test.sh release +``` + +`release` reruns normal/race/coverage and builds a local binary; it never deploys. +Use `TEST_PACKAGE_PARALLELISM=2`, `GOMAXPROCS=4` (defaults) for bounded load. +Coverage floors stay enabled. CI matrix runs normal/race/coverage, not `release`. + +## Automated implementation-to-test map + +Paths are relative to repository root. All tests below use synthetic fixtures, +mock transports/Matrix intents and (where needed) temporary SQLite databases; +none logs in to a real LINE account. HMAC/crypto tests execute local crypto. + +| Source path / behavior | Test file and function | Key assertion / method | +| --- | --- | --- | +| `pkg/connector/login_command.go`, `connector.go`: registration and selection | [login_command_test.go](../pkg/connector/login_command_test.go): `TestRegisteredLineLoginCommandDispatch` | Real mautrix-go Processor initialized by bridge; help, aliases/case, full IDs, unknown/disabled flow; mock `CreateLogin` stops before auth. Checks framework login-permission requirement retained, not a comprehensive permissions suite. | +| `pkg/connector/login_qr.go`: lifecycle | [login_qr_test.go](../pkg/connector/login_qr_test.go): `TestQRConcurrentWaitIsRejected`, `TestQRApprovalPINAndTokenValidation`, `TestQRExpiryAndCancellation`, `TestQRStartPreservesDeadline`, `TestQRPasswordlessReconnectNeedsApproval` | Mock scan/PIN/token responses, reject incomplete finalization, concurrent wait and expired/canceled attempts; fresh approval required for passwordless reconnect. | +| `pkg/line/qr.go`, `auth_error.go`: request/stage/provider errors | [qr_contract_test.go](../pkg/line/qr_contract_test.go): `TestQRRequestContractAndMIDRepresentations`; [qr_error_test.go](../pkg/line/qr_error_test.go): `TestQRErrorDetailsAndRedaction`, `TestQRAuthErrorStage`, `TestQRRPCErrorAuthError`, `TestParseRetryAfter`, `TestHTTPErrorPreservesRetryAfter` | HTTP fixtures verify wire fields, numeric codes, type-specific classification, stage, safe fields, seconds/date retry guidance. | +| `pkg/connector/loginerrors.go`, `connector.go`: surfaced safe errors | [login_error_logging_test.go](../pkg/connector/login_error_logging_test.go): `TestSubmitUserInputLogsStructuredLoginErrorWithoutCredentials`, `TestSessionEstablishmentErrorKeepsStageAndSafeMessage`; [auth_error_test.go](../pkg/line/auth_error_test.go): `TestSanitizeProviderMessage`; [qr_failure_test.go](../pkg/connector/qr_failure_test.go): `TestQRVerificationTemporarilyUnavailable` | Capture structured logs/errors; no fixture secrets; safe provider text retained, unsafe substrings suppressed/redacted. | +| `pkg/connector/login_cooldown.go`, `client.go`: retry safety | [login_cooldown_test.go](../pkg/connector/login_cooldown_test.go): `TestQRRateLimitIsReportedAndBlocksImmediateRetry`, `TestLoginClaimSerializesSameAccount`, `TestLoginCooldownUsesRetryAfter`, `TestStoredCredentialRecoveryRespectsActiveCooldown`, `TestStoredCredentialRecoveryStartsCooldownAfterProviderLimit`, `TestStoredCredentialRecoveryHonorsHTTPRetryAfter` | Count fake requests, no extra provider call during wait; timers/claims scoped by Matrix user; stored credentials cannot bypass active cooldown. | +| `pkg/connector/resolve_identifier.go`, `userinfo.go`: lookup/portal | [resolve_identifier_test.go](../pkg/connector/resolve_identifier_test.go): `TestResolveIdentifierPublicMIDAndSearch`, `TestResolveIdentifierFailuresDoNotPersistPortals`, `TestResolveIdentifierConcurrentCanonicalPortalReuse`, `TestStartChatCreatesReusesAndRetriesRoom` | Temp DB + framework provisioning path; validated public-ID/MID key, no failed-lookup persistence; create, reuse, creation retry and reopen after leaving. | +| Same plus `pkg/line/user_mid.go`: strict identity | [opaque_mid_test.go](../pkg/connector/opaque_mid_test.go), [user_mid_test.go](../pkg/line/user_mid_test.go), [identity_audit_test.go](../pkg/connector/identity_audit_test.go): `TestLookupToOutboundIdentityRepresentations`; resolver `TestResolveIdentifierDeadlineAndMismatchedMID`, `TestSearchUsersRejectsUnrequestedContactMID`, `TestSearchUsersEmptyQueryDoesNotEnumerateContacts` | Case preserved for gateway IDs, canonical legacy IDs, reject mismatched/unrequested/ambiguous result, no empty-query enumeration, same outbound recipient. | +| `pkg/connector/send_message.go`, `pkg/line/methods.go`: send/ack | [outbound_pipeline_test.go](../pkg/connector/outbound_pipeline_test.go): `TestOutboundOwnerAndCanonicalRecipient`, `TestOutboundRequestCancellation`; [send_message_response_test.go](../pkg/line/send_message_response_test.go): `TestSendMessageRejectsMissingAcknowledgement`, `TestSendMessagePreservesAuthenticationError` | Correct sender/recipient/login, one mocked normal send, no false success on reject/empty ack; blocked HTTP observes context cancellation. Not exactly-once under lost acknowledgements. | +| `pkg/connector/media.go`, handlers: integrity | [media_hmac_test.go](../pkg/connector/media_hmac_test.go): `TestMediaHMAC`; [handler_test.go](../pkg/connector/handlers/handler_test.go) | Real local encryption/MAC fixtures for image/file/audio/video/thumbnail; tampered ciphertext/MAC and wrong MAC mode rejected; handler key/decrypt routing. | +| `pkg/connector/handlers/image_media.go`, `image.go`, `text.go`: metadata/previews | [image_media_test.go](../pkg/connector/handlers/image_media_test.go): `TestLineImageMediaInfo`, `TestLineImageBrands`, `TestLineImageExtensions`; [text_test.go](../pkg/connector/handlers/text_test.go): `TestConvertTextUploadsOnlyImageLinkPreviews` | Generated image/config fixtures and bounded brand parsing; mock download/upload verifies preview MXC, no raw CDN image reference; failures retain text. | +| Test/runtime support, CI/build scripts | [runner_secret_test.go](../pkg/runner_secret_test.go), [memory_test.go](../pkg/ltsm/memory_test.go), [coverage checker tests](../scripts/test_check_test_coverage.py) | Native heap abort/recovery remains exercised; deterministic concurrency fixtures; checker rejects coverage regression. Repro build is historical measured evidence above, not a claim about cross-host builds. | + +## Safe live replay order + +Use a **disposable/secondary LINE account** and a consenting test contact. Record +the exact binary version/hash and source; use a separate bridge/test data set. +Back up binary, config and database using your normal consistent backup process. +Do not attach a second Chrome-extension client: it can invalidate the bridge's +session. The branch currently conflicts with main; test the isolated branch, +not an unreviewed conflict resolution. Do not overwrite an important deployment +just to replay this matrix. + +Commands below are in the bot management room. In other rooms prefix with your +configured prefix (for example `!line`). `` means an exact public ID +shared by the test contact, **not** their display name. `` is a verified +internal user identifier, not a guessed `M_id` string. With several logins, +select the intended login using framework help before any create/send action. + +| ID / feature | Preconditions | Exact command or action | Expected result / prior behavior | Status | Risk | Cleanup / recovery | +| --- | --- | --- | --- | --- | --- | --- | +| S01 startup/session | Isolated deployment with preserved existing session | Start test bridge normally; inspect health and management-room `list-logins` | DB/appservice initialize; existing login stays usable where provider permits. Not a new login request | LIVE VERIFIED for historical `f36c646b` startup only; MANUAL NOT RUN on current PR integration | Medium | Stop test bridge and restore backup on fatal startup; never delete sessions to fix a symptom | +| L01 help/unknown | No login required; command permission | `login`, then `login help`, then `login invalid` | Friendly options/guidance, no provider auth; before: full IDs / invalid help | AUTOMATED PASS; MANUAL NOT RUN with exact-head live evidence | Low | None | +| L02 disabled QR | Separate test config with existing `network.qr_login: false` | `login help`, then `login qr` | No QR advertised; useful unavailable error and no QR request | AUTOMATED PASS; MANUAL NOT RUN | Low/medium (config restart) | Restore original option; restart only test bridge if changed | +| L03 email/full ID selection | Only if testing a new disposable session is necessary | `login email`, then `cancel`; alternatively `login dev.highest.matrix.line.email_login` then `cancel` | Credential prompt, no password in command history; full IDs remain valid | AUTOMATED PASS; MANUAL NOT RUN | Medium | Cancel; do not submit primary credentials or duplicate login attempts | +| Q01 one QR lifecycle | QR enabled; login actually needed, mobile available | **Choose one**: `login qr` or `login dev.highest.matrix.line.qr_login`; scan once; approve/PIN only in expected UI | QR → approval/PIN if requested → validated session; before had concurrency/deadline error problems, not missing QR protocol | AUTOMATED PASS; MANUAL NOT RUN per-scenario live evidence | High | `cancel` if abandoned; stop on rejection and observe provider guidance; do not loop | +| Q02 overlap/cancel/expiry | Prefer automated test; optional disposable pending QR | Abandon one pending attempt with `cancel`; for expiry let it expire naturally | No persisted incomplete login; claim released; errors distinguish deadline/cancel | AUTOMATED PASS; MANUAL NOT RUN | High | Cancel only pending login; do not repeatedly generate QR codes for stress testing | +| A01 restriction/provider messages | Only a naturally occurring 429, RETRY_LATER, or verification-unavailable error | Record displayed category/stage and UTC, not QR/token/body; do not induce rate limit | Useful safe message + wait; defaults 15/5/10 min, Retry-After can extend. Duration is local policy, not LINE restriction duration | AUTOMATED PASS; NOT TESTABLE WITHOUT PROVIDER CONDITIONS | High | Honor wait; no bypass/restart to clear local gate; use mocked tests for immediate-retry assertions | +| I01 resolve public ID | Logged-in secondary account; known consenting contact | `search `; optionally `resolve-identifier ` as listed in framework help | Validated exact contact; lookup-only does not create a DM; no false “shadow ban” inference from failure | AUTOMATED PASS; MANUAL NOT RUN | Medium | Stop on mismatch; do not contact unverified results | +| I02 create/reuse/reopen | I01 succeeds, intended account confirmed | `start-chat `; repeat once; optionally `start-chat ` for same contact; leave test room then `start-chat ` | Same canonical recipient/login; reuse/rejoin existing room where possible, not duplicate portal; before public string could create wrong portal key | AUTOMATED PASS; MANUAL NOT RUN | High (rooms/membership persist) | Stop if wrong recipient/duplicate; do not delete DB rows manually; keep IDs private for operator diagnosis | +| I03 malformed/ambiguous IDs | Automated only for synthetic server results | Run resolver/MID tests above; do not deliberately query strangers | Reject mismatched/ambiguous/unrequested MID before portal persistence; opaque case unchanged | AUTOMATED PASS; NOT TESTABLE WITHOUT PROVIDER CONDITIONS for malformed server data | High | No live fault injection needed | +| M01 outbound | Known correct test portal; cooperating contact | Send one neutral unique text from Matrix, inspect LINE receipt | Server ack maps to event; one observed message; before empty ack could look successful | AUTOMATED PASS; MANUAL NOT RUN | High | Do not resend on timeout until recipient checks; deletion not required | +| M02 inbound/attribution | M01 verified | Contact sends one neutral text from LINE to Matrix | Correct room/sender, one observed event; inbound text routing largely pre-existing | MANUAL NOT RUN; automated identity tests cover only part of this | Medium | Stop on unexpected portal/sender; preserve sanitized timestamps only | +| M03 E2EE warning | Existing encrypted test room | Observe inbound puppet event for “sender ... does not match ... owner of the device” | Known unresolved warning; no fix claimed | KNOWN LIMITATION | High if mistaken for delivery/integrity guarantee | Do not reset crypto/devices or disable encryption to hide warning | +| F01 media | Correct test portal; non-private fixtures | Contact sends one small JPEG, PNG, supported video, audio and file; inspect dimensions/MIME/download in Matrix | Valid media displays; MIME/name/size and dimensions where supported; authenticated bytes only | AUTOMATED PASS for synthetic integrity/metadata; MANUAL NOT RUN across live formats | High | Stop on HMAC failure; do not disable verification; keep original fixture privately | +| F02 corruption / unusual formats | Local fixtures only | Run `TestMediaHMAC` and `TestLineImageBrands`; optional benign WebP/HEIC/AVIF from disposable contact | Tampered fixture rejected; unsupported dimensions may be absent; not universal codec support | AUTOMATED PASS; MANUAL NOT RUN for live format combinations | Medium/high | No tampering with production traffic; no repeated OBS retry workaround | +| F03 link preview | Benign non-private URL, previews enabled | Send one text with a harmless public link in test chat | Image preview uploads to Matrix; failure still delivers text (upload behavior already in base) | AUTOMATED PASS; MANUAL NOT RUN | Medium (downloads) | Disable only test preview if needed; don't publish URLs/raw errors | +| R01 send cancellation | Automated mock preferred | Run `TestOutboundRequestCancellation`; do not disconnect a production bridge mid-send | Mock request canceled promptly; live uncertain delivery remains possible | AUTOMATED PASS; MANUAL NOT RUN for real interruptions | High | No blind resends; verify recipient before retry | +| R02 reconnect/session | Only on isolated bridge with known session | Optional orderly test-bridge restart once; verify `list-logins` and one controlled message | Stored state retained where possible; passwordless failed recovery asks fresh approval rather than inventing credentials | AUTOMATED PASS for selected recovery cases; MANUAL NOT RUN across network/session matrix | High | Restore backup if necessary; do not invalidate mobile account or trigger repeated auth | +| O01 sync/log review | UTC window around above tests | Inspect structured fields locally, see safe projection below | No new bulk-sync policy; existing connect/fullSync sync still runs; exact lookup avoids contact enumeration | MANUAL NOT RUN for exact-head live network frequency; code-diff evidence | Medium/privacy | Stop on unintended portals or request bursts; don't publish raw logs | + +### Safe diagnostic collection + +Prefer existing structured logging; no new config key or unrestricted HTTP-body +logging is needed. Logs can contain MIDs, Matrix IDs, message IDs, URLs and error +text even when the new auth messages are sanitized. Inspect a narrow UTC test +window locally. For JSON logs, a public-shareable **numeric/boolean-only** +projection can be made without dumping raw production data: + +```sh +# Set an ISO UTC START/END and your actual log path locally. +jq -c --arg start "$START" --arg end "$END" ' + select(.time >= $start and .time <= $end) | + {time, http_status, line_response_code, line_error_code, + retry_recommended, temporary, cooldown_triggered, success} | + with_entries(select(.key == "time" or + (.value | type) == "number" or (.value | type) == "boolean")) +' /path/to/logs/bridge.log +``` + +Verify your logger's time field/format first (it may use `timestamp` instead of +`time`). Privately correlate stage/category and identifiers if necessary, then +manually redact before sharing. Never publish QR/session tokens, credentials, +cookies, encryption keys, private text, complete logs, or raw provider responses. + +## What has NOT been established + +No complete live multi-account/login/client matrix; no proof of every LINE +restriction category or its duration; no guarantee against account restrictions; +no exhaustive network-interruption/reconnect testing; no exactly-once delivery +under lost acknowledgements; no all-format/all-size media, group/LS-on/LS-off, +direct-media or all-Matrix-client coverage. No new-current-upstream merged-tree +validation, GitHub CI success, E2EE device-ownership warning fix, or historical +per-scenario live inventory. Optional lookup-shape diagnostics remain deferred. diff --git a/docs/UPSTREAM_INTEGRATION_STATUS.md b/docs/UPSTREAM_INTEGRATION_STATUS.md new file mode 100644 index 00000000..c3fccc1f --- /dev/null +++ b/docs/UPSTREAM_INTEGRATION_STATUS.md @@ -0,0 +1,79 @@ +# Upstream integration status + +## Reviewer update (2026-11-02) + +PR [#297](https://github.com/beeper/line/pull/297) is published. It now has a +[before/after comparison](PR297_BEFORE_AFTER.md), a +[reproducible test/evidence matrix](PR297_TEST_MATRIX.md), and a +[blast-radius/split assessment](PR297_BLAST_RADIUS.md). These supersede the +publication/CI status recorded below. Upstream main advanced to `10e0a5f`; +the PR is conflicting in `pkg/connector/client.go` and has no reported GitHub +checks. No runtime conflict resolution was performed in this documentation update. + +The sections below retain the historical integration and validation record; +the earlier CLI authentication blocker was subsequently resolved using the +normal user configuration. They are not a claim of current-main compatibility. + +## Verified sources + +- Current upstream baseline fetched from `origin/main`: `45a48b9b3b57330d99d326d443f9a9fc8725a48f`. +- Immutable working checkpoint: `f36c646b468719877c9fb4329f2cab18b652fbca`, branch + `checkpoint/line-feature-complete-20261029`, tag + `checkpoint/line-feature-complete-20261029-f36c646b`. +- Upstream integration branch `upstream/line-stabilization-20261029` merges the + checkpoint with the current upstream baseline. It retains upstream #296's + bounded/cancellable search and avatar request improvements alongside the + checkpoint's exact ID validation, contact-result checks, and portal reuse. +- Working release v1.2.0+dev.f36c646b is still the byte-identical binary running + on `element-chat`; no service was changed in this integration task. + +## Historical PR relationships + +- `beeper/line#271` is still open (head `meramsey/line:integration/linux-build-20261020`, + SHA `587164f7ced99bc5765ac7df4eafc11314e6d602`). Its nine commits are present + in the checkpoint by patch equivalence. This integration is intended to + supersede #271 because it contains those features plus follow-up authentication, + identity, contact validation, media, CI, and release corrections. #271 remains + open and untouched; do not merge both versions without comparing their diffs. +- `meramsey/line#1` remains open against the fork's integration branch (6 commits, + head `a7b5764a6a4c0d45d10814fd101c21eca70199b9`). Two patches are identical by + patch ID; bounded test modes, measurements, coverage gates, and CI caching were + adapted in later integration commits. Historical benchmark numbers in its old + performance report are intentionally not copied as they are environment-specific. +- Upstream commits already incorporated include latest #296 (`45a48b9`), which + bounds search results and honors cancellation. The integration retains this + behavior rather than reverting it. + +## Feature map + +| Area | Implementation | Regression coverage | Status | +| --- | --- | --- | --- | +| Friendly login aliases and QR selection | `pkg/connector/login_command.go`, `connector.go` | `login_command_test.go` through framework Processor | In new PR | +| QR lifecycle, auth stages, sanitized provider errors, 429/Retry-After and per-login cooldown | `login_qr.go`, `loginerrors.go`, `login_cooldown.go`, `pkg/line/{qr,auth_error,errors}.go` | QR, error, and cooldown suites | In new PR | +| Public ID, canonical/opaque MIDs, validation, portal reuse/reopen | `resolve_identifier.go`, `userinfo.go`, `pkg/line/user_mid.go` | resolver, opaque MID, and portal tests | In new PR; preserves upstream #296 limits/cancellation | +| Send cancellation/acknowledgements, inbound media auth and metadata | `send_message.go`, `media.go`, handlers | outbound, media, image metadata tests | In new PR | +| CI caches, test modes, coverage gate, reproducible build | `.github/workflows/deploy.yml`, `scripts/`, `build.sh` | release validation | In new PR | +| Optional lookup-shape diagnostics | No current implementation | No current test | Intentionally deferred pending a privacy/schema review; current safe validation and errors remain | + +## Validation + +On the integration branch (source merge `07badf92460e5ae744b90d1c0c0a46cc30396218`, +plus this status-documentation commit): + +- Normal: 512 passing test/subtest events, one skip, zero failures. +- Race: 512 passing test/subtest events, one skip, zero failures. +- Coverage: 512 passing test/subtest events, one skip, zero failures; all package floors passed. +- Release: all checks passed, including module verification, static analysis, build, and the three suites above. +- Targeted login, search/identity, portal, QR, auth error, and cooldown tests passed. + +## Publication state and remaining work + +- Checkpoint branch/tag are published to the `meramsey/line` fork. +- The upstream integration branch is prepared for a replacement PR targeting + `beeper/line:main`. GitHub CLI has no authenticated session in this environment, + so PR creation requires interactive authentication. The pushed branch's + compare URL is `https://github.com/beeper/line/compare/main...meramsey:line:upstream/line-stabilization-20261029`. +- No feature claim should imply upstream coverage until the replacement PR is + reviewed/merged. Do not close PR #271 or modify PR #1 automatically. +- Live QR/provider behavior and inbound puppet E2EE sender/device warnings remain + operator-verification items. Matrix encryption was not altered. diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 2d8e5dfd..897228dc 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -21,7 +21,7 @@ import ( ) var ( - errLineSessionInvalidated = errors.New("LINE session invalidated by another client") + errLineSessionInvalidated = errors.New("LINE session invalidated") errLineClientSuperseded = errors.New("LINE client was superseded") errLineQRLoginRequired = errors.New("LINE requires a new QR scan. Reconnect in Beeper to continue") errLinePINNotVerified = errors.New("PIN verification failed") @@ -826,6 +826,7 @@ func (lc *LineClient) startSession(ctx context.Context) bool { // deliver messages. Otherwise an existing LINE group whose Matrix room // doesn't exist yet may be created by the first message, which makes the // sender's existing membership look like a fresh join. + lc.UserLogin.Bridge.Log.Info().Str("sync_trigger", "connect").Msg("Starting LINE conversation synchronization") lc.syncChatsNow(ctx) if ctx.Err() != nil { return false @@ -852,13 +853,22 @@ func verifyReloginProfile(ctx context.Context, accessToken string) (*line.Profil } } -func (lc *LineClient) tryLogin(ctx context.Context) error { +func (lc *LineClient) tryLogin(ctx context.Context) (resultErr error) { var email, password, certificate string + var loginProcess *LineEmailLogin if meta, ok := lc.UserLogin.Metadata.(*UserLoginMetadata); ok { email = meta.Email password = meta.Password certificate = meta.Certificate } + if lc.UserLogin.Bridge != nil { + if network, ok := lc.UserLogin.Bridge.Network.(*LineConnector); ok { + loginProcess = &LineEmailLogin{User: lc.UserLogin.User, cooldown: &network.loginCooldown} + if err := loginProcess.loginCooldownError(); err != nil { + return err + } + } + } if email == "" || password == "" { if lc.UserLogin.Bridge != nil { @@ -868,9 +878,19 @@ func (lc *LineClient) tryLogin(ctx context.Context) error { } return fmt.Errorf("no stored credentials available for re-login") } + if loginProcess != nil { + if err := loginProcess.claimLogin(); err != nil { + return err + } + defer loginProcess.releaseLogin() + defer func() { + if resultErr != nil { + loginProcess.noteLoginRateLimit(resultErr) + } + }() + } lc.UserLogin.Bridge.Log.Info(). - Str("email", email). Bool("has_certificate", certificate != ""). Msg("Attempting to login with email/password...") res, err := loginWithCredentialsContext(ctx, email, password, certificate) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 9eade7ff..739a5383 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net" "net/http" "strings" "sync" @@ -32,6 +33,7 @@ type LineConnector struct { br *bridgev2.Bridge loginFinalizeMu sync.Mutex directMedia atomic.Bool + loginCooldown loginCooldown } var _ bridgev2.NetworkConnector = (*LineConnector)(nil) @@ -42,6 +44,7 @@ func (lc *LineConnector) Init(bridge *bridgev2.Bridge) { // user doesn't already have one. bridge.Config.BridgeStatusNotices = "none" lc.br = bridge + lc.registerLoginCommand() } func (lc *LineConnector) Start(ctx context.Context) error { @@ -184,12 +187,12 @@ func (lc *LineConnector) GetLoginFlows() []bridgev2.LoginFlow { func (lc *LineConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) { if flowID == LoginFlowIDQR && lc.Config.QRLogin { - return &LineQRLogin{login: &LineEmailLogin{User: user, finalizeMu: &lc.loginFinalizeMu}}, nil + return &LineQRLogin{login: &LineEmailLogin{User: user, finalizeMu: &lc.loginFinalizeMu, cooldown: &lc.loginCooldown, authFlow: "qr"}}, nil } if flowID != LoginFlowIDEmail { return nil, bridgev2.ErrInvalidLoginFlowID } - return &LineEmailLogin{User: user, finalizeMu: &lc.loginFinalizeMu, qrEnabled: lc.Config.QRLogin}, nil + return &LineEmailLogin{User: user, finalizeMu: &lc.loginFinalizeMu, qrEnabled: lc.Config.QRLogin, cooldown: &lc.loginCooldown, authFlow: "email"}, nil } type LineEmailLogin struct { @@ -205,6 +208,8 @@ type LineEmailLogin struct { ExistingLogin *bridgev2.UserLogin qrEnabled bool qrLogin *LineQRLogin + cooldown *loginCooldown + authFlow string pollResult chan *line.LoginResult pollErr chan error @@ -213,15 +218,50 @@ type LineEmailLogin struct { attemptCtx context.Context attemptCancel context.CancelFunc canceled bool + loginClaimed bool mu sync.Mutex finalizeMu *sync.Mutex } +func (ll *LineEmailLogin) claimLogin() error { + if ll.cooldown == nil || ll.User == nil { + return nil + } + ll.mu.Lock() + alreadyClaimed := ll.loginClaimed + ll.mu.Unlock() + if alreadyClaimed || !ll.cooldown.acquire(string(ll.User.MXID)) { + return ErrLoginInProgress + } + ll.mu.Lock() + if ll.loginClaimed { + ll.mu.Unlock() + ll.cooldown.release(string(ll.User.MXID)) + return ErrLoginInProgress + } + ll.loginClaimed = true + ll.mu.Unlock() + return nil +} + +func (ll *LineEmailLogin) releaseLogin() { + ll.mu.Lock() + claimed := ll.loginClaimed + ll.loginClaimed = false + ll.mu.Unlock() + if claimed && ll.cooldown != nil && ll.User != nil { + ll.cooldown.release(string(ll.User.MXID)) + } +} + var _ bridgev2.LoginProcessUserInput = (*LineEmailLogin)(nil) var _ bridgev2.LoginProcessDisplayAndWait = (*LineEmailLogin)(nil) var _ bridgev2.LoginProcessWithOverride = (*LineEmailLogin)(nil) func (ll *LineEmailLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { + if err := ll.loginCooldownError(); err != nil { + return nil, err + } return &bridgev2.LoginStep{ Type: bridgev2.LoginStepTypeUserInput, StepID: "dev.highest.matrix.line.enter_creds", @@ -257,6 +297,7 @@ func (ll *LineEmailLogin) StartWithOverride(ctx context.Context, override *bridg if ll.Email == "" || ll.Password == "" { if ll.qrEnabled { ll.Email, ll.Password = "", "" + ll.authFlow = "qr" ll.mu.Lock() ll.qrLogin = &LineQRLogin{login: ll} qrLogin := ll.qrLogin @@ -275,18 +316,29 @@ func (ll *LineEmailLogin) StartWithOverride(ctx context.Context, override *bridg } } + if err := ll.loginCooldownError(); err != nil { + return nil, err + } + if err := ll.claimLogin(); err != nil { + return nil, err + } override.BridgeState.Send(status.BridgeState{StateEvent: status.StateConnecting}) - res, err := ll.loginCredentials(ctx, ll.Certificate) if err != nil { + ll.releaseLogin() ll.logLoginFailure(err, "reconnect") + ll.noteLoginRateLimit(err) reason := loginErrorReason(err) if reason == "" { reason = genericLoginFailureReason } return ll.loginErrorStep(reason), nil } - return ll.handleLoginResponse(ctx, res) + step, err := ll.handleLoginResponse(ctx, res) + if err != nil { + ll.releaseLogin() + } + return step, err } func (ll *LineEmailLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { @@ -304,9 +356,17 @@ func (ll *LineEmailLogin) SubmitUserInput(ctx context.Context, input map[string] return ll.loginErrorStep("Email and password are required"), nil } + if err := ll.loginCooldownError(); err != nil { + return nil, err + } + if err := ll.claimLogin(); err != nil { + return nil, err + } res, err := ll.loginCredentials(ctx, "") if err != nil { + ll.releaseLogin() ll.logLoginFailure(err, "credentials") + ll.noteLoginRateLimit(err) reason := loginErrorReason(err) if reason == "" { reason = genericLoginFailureReason @@ -314,7 +374,11 @@ func (ll *LineEmailLogin) SubmitUserInput(ctx context.Context, input map[string] return ll.loginErrorStep(reason), nil } - return ll.handleLoginResponse(ctx, res) + step, err := ll.handleLoginResponse(ctx, res) + if err != nil { + ll.releaseLogin() + } + return step, err } func (ll *LineEmailLogin) loginErrorStep(message string) *bridgev2.LoginStep { @@ -436,16 +500,6 @@ func loginErrorSummary(err error, details loginErrorDetails) string { return "" } -func loginLogField(value string) string { - value = strings.TrimSpace(value) - const maxRunes = 512 - runes := []rune(value) - if len(runes) > maxRunes { - return string(runes[:maxRunes]) + "…" - } - return value -} - func (ll *LineEmailLogin) logLoginFailure(err error, flow string) { if err == nil || ll.User == nil { return @@ -465,33 +519,117 @@ func (ll *LineEmailLogin) logLoginFailure(err error, flow string) { event.Int("line_response_code", details.ResponseCode) } if details.ResponseMessage != "" { - event.Str("line_response_message", loginLogField(details.ResponseMessage)) + event.Str("line_response_message", "redacted") } if details.ErrorName != "" { - event.Str("line_error_name", loginLogField(details.ErrorName)) + switch details.ErrorName { + case "TalkException", "LoginException", "SecondaryQrCodeException", "SecondaryQrCodeLoginException": + event.Str("line_error_name", details.ErrorName) + default: + event.Str("line_error_name", "other") + } } if details.HasErrorCode { event.Int("line_error_code", details.ErrorCode) } if details.ErrorMessage != "" { - event.Str("line_error_message", loginLogField(details.ErrorMessage)) + event.Str("line_error_message", "redacted") } if details.ErrorReason != "" { - event.Str("line_error_reason", loginLogField(details.ErrorReason)) + event.Str("line_error_reason", line.SanitizeProviderAlert(details.ErrorReason)) + } + var authErr *line.AuthError + if errors.As(wrapLineLoginError(err), &authErr) { + event.Str("auth_error_flow", authErr.Flow). + Str("auth_error_stage", authErr.Stage.String()). + Str("auth_error_category", authErr.Category.String()). + Bool("retry_recommended", authErr.RetryRecommended). + Bool("temporary", authErr.Temporary). + Bool("cooldown_triggered", authErr.CooldownTriggered) + if authErr.ProviderMessage != "" { + event.Str("provider_message", authErr.ProviderMessage) + } + event.Str("provider_message_status", authErr.ProviderMessageStatus) + if authErr.RetryAfter != nil { + event.Dur("retry_after", *authErr.RetryAfter) + } } event.Msg("LINE login attempt failed") } +// logQRFailure records the sanitized details of a rejected QR login request. +// The mobile app shows no detail for these rejections, so the bridge log is the +// only structured record of what LINE rejected. If the error carries a structured +// AuthError, its safe classification fields are also logged. +func (ll *LineEmailLogin) logQRFailure(err error, flow string) { + if err == nil || ll.User == nil { + return + } + event := ll.User.Log.Warn().Str("login_flow", flow) + + detail, ok := line.QRLoginFailureDetail(err) + if ok { + if detail.Method != "" { + event.Str("qr_method", detail.Method) + } + event.Int("http_status", detail.HTTPStatus) + if detail.ResponseCode != 0 { + event.Int("line_response_code", detail.ResponseCode) + } + if detail.ExceptionType != "" { + event.Str("line_error_name", detail.ExceptionType) + } + if detail.ExceptionCode != 0 { + event.Int("line_error_code", detail.ExceptionCode) + } + if detail.ExceptionLabel != "" { + event.Str("line_error_label", detail.ExceptionLabel) + } + if detail.Reason != "" { + event.Str("line_error_reason", detail.Reason) + } + if detail.Alert != "" { + event.Str("line_error_alert", detail.Alert) + } + } + + var qrErr *line.QRRPCError + if errors.As(err, &qrErr) && qrErr.AuthError() != nil { + ae := qrErr.AuthError() + event.Str("auth_error_flow", ae.Flow). + Str("auth_error_stage", ae.Stage.String()). + Str("auth_error_category", ae.Category.String()) + if ae.ProviderReason != "" { + event.Str("provider_reason", ae.ProviderReason) + } + if ae.ProviderMessage != "" { + event.Str("provider_message", ae.ProviderMessage) + } + event.Str("provider_message_status", ae.ProviderMessageStatus). + Str("provider_alert_status", string(ae.ProviderAlertStatus)). + Bool("retry_recommended", ae.RetryRecommended). + Bool("temporary", ae.Temporary). + Bool("user_action_required", ae.UserActionRequired). + Bool("bridge_failure", ae.BridgeFailure). + Bool("cooldown_triggered", ae.CooldownTriggered) + if ae.RetryAfter != nil { + event.Dur("retry_after", *ae.RetryAfter) + } + } + + event.Msg("LINE QR login attempt failed") +} + func loginErrorReason(err error) string { details := parseLoginErrorDetails(err) - reason := details.ErrorReason - if reason == "" { - reason = details.ErrorMessage + var networkErr net.Error + if errors.Is(err, context.DeadlineExceeded) || errors.As(err, &networkErr) { + return line.UserMessageForCategory(line.AuthErrorNetworkUnavailable) } - if isBlockedUserLoginError(reason) { - return loginTooManyAttemptsReason + if details.HTTPStatus >= http.StatusInternalServerError { + return line.UserMessageForCategory(line.AuthErrorServiceUnavailable) } - return reason + return safeEmailLoginReason(providerLoginMessage(details)) } func isBlockedUserLoginError(message string) bool { @@ -558,11 +696,15 @@ func (ll *LineEmailLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) return ll.finishLogin(ctx, res) } res.Attempt.Close() + ll.releaseLogin() return nil, ErrLoginVerificationFailed case err := <-errCh: ll.logLoginFailure(err, "verification_poll") - return nil, wrapLineLoginError(err) + ll.noteLoginRateLimit(err) + ll.releaseLogin() + return nil, wrapLineLoginErrorAt(err, line.AuthStageEmailVerification) case <-done: + ll.Cancel() return nil, context.Canceled case <-ctx.Done(): ll.Cancel() @@ -571,12 +713,21 @@ func (ll *LineEmailLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) } if awaitingPIN { + if err := ll.loginCooldownError(); err != nil { + return nil, err + } res, err := ll.loginCredentials(ctx, ll.Certificate) if err != nil { ll.logLoginFailure(err, "pin_continuation") - return nil, wrapLineLoginError(err) + ll.noteLoginRateLimit(err) + ll.releaseLogin() + return nil, wrapLineLoginErrorAt(err, line.AuthStageEmailVerification) + } + step, err := ll.handleLoginResponse(ctx, res) + if err != nil { + ll.releaseLogin() } - return ll.handleLoginResponse(ctx, res) + return step, err } return nil, fmt.Errorf("no pending login continuation") @@ -683,6 +834,7 @@ func (ll *LineEmailLogin) handleLoginResponse(ctx context.Context, res *line.Log } func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult) (*bridgev2.LoginStep, error) { + defer ll.releaseLogin() if res != nil { defer res.Attempt.Close() } @@ -725,7 +877,7 @@ func (ll *LineEmailLogin) finishLogin(ctx context.Context, res *line.LoginResult client.LoginAttempt = res.Attempt profile, err := client.GetProfileContext(ctx) if err != nil { - return nil, wrapLineLoginError(fmt.Errorf("failed to verify token: %w", err)) + return nil, wrapSessionEstablishError(ll, fmt.Errorf("failed to verify token: %w", err)) } displayName := profile.DisplayName @@ -997,4 +1149,5 @@ func (ll *LineEmailLogin) Cancel() { if attempt != nil { attempt.Close() } + ll.releaseLogin() } diff --git a/pkg/connector/forced_logout_test.go b/pkg/connector/forced_logout_test.go index efe123a3..adf7782e 100644 --- a/pkg/connector/forced_logout_test.go +++ b/pkg/connector/forced_logout_test.go @@ -130,7 +130,7 @@ func TestInvalidSenderKeyReconnectPersistsFullVerification(t *testing.T) { select { case state := <-matrix.states: wantRestartError := status.BridgeStateErrorCode("line-logged-out") - wantRestartMessage := "LINE logged this Chrome Extension session out because another LINE client connected. Click Reconnect in Beeper to reconnect LINE." + wantRestartMessage := "LINE invalidated this Chrome Extension session. Click Reconnect in Beeper to reconnect LINE." if invalidKey { wantRestartError = "line-e2ee-key-missing" wantRestartMessage = lineMissingE2EEKeyMessage diff --git a/pkg/connector/handle_message.go b/pkg/connector/handle_message.go index 812354b6..a5b76b48 100644 --- a/pkg/connector/handle_message.go +++ b/pkg/connector/handle_message.go @@ -29,11 +29,12 @@ const ( func (lc *LineClient) newMessageHandler() *handlers.Handler { handler := &handlers.Handler{ - Log: lc.UserLogin.Bridge.Log, - HTTPClient: lc.HTTPClient, - RecoverClient: lc.recoverClientAfterAuthError, - NewClient: func() *line.Client { return lc.newClient() }, - DecryptMedia: lc.decryptImageData, + Log: lc.UserLogin.Bridge.Log, + HTTPClient: lc.HTTPClient, + RecoverClient: lc.recoverClientAfterAuthError, + NewClient: func() *line.Client { return lc.newClient() }, + DecryptMedia: lc.decryptImageData, + DecryptVideoMedia: lc.decryptVideoData, } connector, ok := lc.UserLogin.Bridge.Network.(*LineConnector) if ok && connector.directMedia.Load() { diff --git a/pkg/connector/handlers/handler.go b/pkg/connector/handlers/handler.go index 04c4dd99..36b89589 100644 --- a/pkg/connector/handlers/handler.go +++ b/pkg/connector/handlers/handler.go @@ -34,12 +34,16 @@ type Handler struct { // DownloadOBSResource overrides non-talk OBS downloads in tests. DownloadOBSResource func(ctx context.Context, client *line.Client, service, sid, oid string) ([]byte, error) + // DownloadPublicMedia overrides public link-preview downloads in tests. + DownloadPublicMedia func(ctx context.Context, client *line.Client, resource string) ([]byte, error) // DownloadAlbumPreview overrides album thumbnail downloads in tests. DownloadAlbumPreview func(ctx context.Context, client *line.Client, oid, chatID, albumID string) ([]byte, error) // DecryptMedia decrypts E2EE encrypted media data using the given key material. DecryptMedia func(data []byte, keyMaterial string) ([]byte, error) + // DecryptVideoMedia verifies the video-specific chunk-hash HMAC before decryption. + DecryptVideoMedia func(data []byte, keyMaterial string) ([]byte, error) } func (h *Handler) decryptDownloadedMedia(data []byte, decryptedBody string, metadata map[string]string, kind string) ([]byte, error) { @@ -90,13 +94,17 @@ func (h *Handler) decryptMediaWithKeys(data []byte, keys []string, encrypted boo if len(keys) == 0 { return nil, fmt.Errorf("%w: encrypted %s has no usable media key", bridgev2.ErrIgnoringRemoteEvent, strings.ToLower(kind)) } - if h.DecryptMedia == nil { + decrypt := h.DecryptMedia + if kind == "video" { + decrypt = h.DecryptVideoMedia + } + if decrypt == nil { return nil, fmt.Errorf("%w: encrypted %s has no media decryptor", bridgev2.ErrIgnoringRemoteEvent, strings.ToLower(kind)) } decryptErrors := make([]error, 0, len(keys)) for _, key := range keys { - decrypted, err := h.DecryptMedia(data, key) + decrypted, err := decrypt(data, key) if err == nil { return decrypted, nil } diff --git a/pkg/connector/handlers/handler_test.go b/pkg/connector/handlers/handler_test.go index 19057e03..c825b11d 100644 --- a/pkg/connector/handlers/handler_test.go +++ b/pkg/connector/handlers/handler_test.go @@ -88,7 +88,7 @@ func TestDecryptDownloadedMediaFailsClosed(t *testing.T) { ciphertext := []byte("ciphertext") decryptErr := errors.New("invalid media key") var calls int - h := &Handler{DecryptMedia: func([]byte, string) ([]byte, error) { + h := &Handler{DecryptVideoMedia: func([]byte, string) ([]byte, error) { calls++ return nil, decryptErr }} diff --git a/pkg/connector/handlers/image.go b/pkg/connector/handlers/image.go index 7c3ccdbd..ac3c41f7 100644 --- a/pkg/connector/handlers/image.go +++ b/pkg/connector/handlers/image.go @@ -112,8 +112,15 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int } // Upload to Matrix + imageMedia := lineImageMediaInfo(imgData) + if imageMedia.usedMimeFallback { + h.Log.Debug().Int("size_bytes", len(imgData)).Msg("Falling back to JPEG MIME type for LINE image") + } + if imageMedia.decodeErr != nil { + h.Log.Debug().Err(imageMedia.decodeErr).Str("mime_type", imageMedia.mimeType).Msg("Could not decode LINE image dimensions") + } uploadStart := time.Now() - mxc, file, err := intent.UploadMedia(ctx, portal.MXID, imgData, "image.jpg", "image/jpeg") + mxc, file, err := intent.UploadMedia(ctx, portal.MXID, imgData, imageMedia.fileName, imageMedia.mimeType) uploadDuration := time.Since(uploadStart) if err != nil { h.Log.Error(). @@ -145,7 +152,8 @@ func (h *Handler) ConvertImage(ctx context.Context, portal *bridgev2.Portal, int Type: event.EventMessage, Content: &event.MessageEventContent{ MsgType: event.MsgImage, - Body: "image.jpg", + Body: imageMedia.fileName, + Info: imageMedia.info, URL: mxc, File: file, RelatesTo: relatesTo, diff --git a/pkg/connector/handlers/image_media.go b/pkg/connector/handlers/image_media.go new file mode 100644 index 00000000..74bd4975 --- /dev/null +++ b/pkg/connector/handlers/image_media.go @@ -0,0 +1,98 @@ +package handlers + +import ( + "bytes" + "encoding/binary" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "net/http" + "strings" + + _ "golang.org/x/image/webp" + "maunium.net/go/mautrix/event" +) + +// Adapted from beeper/line#189; OBS retry policy remains independent. +type lineImageMedia struct { + fileName string + mimeType string + info *event.FileInfo + usedMimeFallback bool + decodeErr error +} + +func lineImageMediaInfo(data []byte) lineImageMedia { + mimeType, usedFallback := detectLineImageMimeType(data) + info := &event.FileInfo{MimeType: mimeType, Size: len(data)} + var decodeErr error + if config, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil { + info.Width = config.Width + info.Height = config.Height + } else if !usedFallback { + switch mimeType { + case "image/jpeg", "image/png", "image/gif", "image/webp": + decodeErr = err + } + } + return lineImageMedia{ + fileName: "image." + imageExtensionForMIME(mimeType), mimeType: mimeType, + info: info, usedMimeFallback: usedFallback, decodeErr: decodeErr, + } +} + +func detectLineImageMimeType(data []byte) (string, bool) { + mimeType := normalizedImageMIMEType(http.DetectContentType(data)) + if strings.HasPrefix(mimeType, "image/") { + return mimeType, false + } + if len(data) >= 12 && lineImageBrandAt(data, 4) == [4]byte{'f', 't', 'y', 'p'} { + boxSize := binary.BigEndian.Uint32(data[:4]) + if boxSize >= 16 && uint64(boxSize) <= uint64(len(data)) { + detected := "" + for offset, end := 8, int(boxSize); offset+4 <= end; { + switch lineImageBrandAt(data, offset) { + case [4]byte{'a', 'v', 'i', 'f'}, [4]byte{'a', 'v', 'i', 's'}: + return "image/avif", false + case [4]byte{'h', 'e', 'i', 'c'}, [4]byte{'h', 'e', 'i', 'x'}, [4]byte{'h', 'e', 'v', 'c'}, [4]byte{'h', 'e', 'v', 'x'}: + return "image/heic", false + case [4]byte{'h', 'e', 'i', 'f'}, [4]byte{'h', 'e', 'i', 'm'}, [4]byte{'h', 'e', 'i', 's'}, [4]byte{'m', 'i', 'f', '1'}, [4]byte{'m', 's', 'f', '1'}: + detected = "image/heif" + } + if offset == 8 { + offset = 16 + } else { + offset += 4 + } + } + if detected != "" { + return detected, false + } + } + } + return "image/jpeg", true +} + +func lineImageBrandAt(data []byte, offset int) [4]byte { + return [4]byte{data[offset], data[offset+1], data[offset+2], data[offset+3]} +} + +func imageExtensionForMIME(mimeType string) string { + switch normalizedMIMEType := normalizedImageMIMEType(mimeType); normalizedMIMEType { + case "image/jpeg": + return "jpg" + case "image/svg+xml": + return "svg" + case "image/x-icon", "image/vnd.microsoft.icon": + return "ico" + case "image/png", "image/gif", "image/webp", "image/heic", "image/heif", "image/avif", "image/bmp", "image/tiff": + return strings.TrimPrefix(normalizedMIMEType, "image/") + } + return "jpg" +} + +func normalizedImageMIMEType(mimeType string) string { + normalizedMIMEType, _, _ := strings.Cut(mimeType, ";") + return strings.ToLower(strings.TrimSpace(normalizedMIMEType)) +} diff --git a/pkg/connector/handlers/image_media_test.go b/pkg/connector/handlers/image_media_test.go new file mode 100644 index 00000000..cc831ead --- /dev/null +++ b/pkg/connector/handlers/image_media_test.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "bytes" + "encoding/binary" + "image" + "image/jpeg" + "image/png" + "testing" +) + +func TestLineImageMediaInfo(t *testing.T) { + for _, format := range []string{"jpeg", "png"} { + t.Run(format, func(t *testing.T) { + var buf bytes.Buffer + img := image.NewRGBA(image.Rect(0, 0, 23, 17)) + var err error + if format == "jpeg" { + err = jpeg.Encode(&buf, img, nil) + } else { + err = png.Encode(&buf, img) + } + if err != nil { + t.Fatal(err) + } + got := lineImageMediaInfo(buf.Bytes()) + if got.mimeType != "image/"+format || got.info.Width != 23 || got.info.Height != 17 || got.info.Size != buf.Len() || got.info.MimeType != got.mimeType || got.usedMimeFallback || got.decodeErr != nil { + t.Fatalf("incorrect image metadata: %+v, info=%+v", got, got.info) + } + }) + } +} + +func TestLineImageBrands(t *testing.T) { + for _, tt := range []struct{ major, compatible, want string }{ + {"heic", "", "image/heic"}, {"heif", "", "image/heif"}, + {"mif1", "heic", "image/heic"}, {"mif1", "avif", "image/avif"}, + {"avif", "", "image/avif"}, + } { + t.Run(tt.major+tt.compatible, func(t *testing.T) { + data := make([]byte, 20) + binary.BigEndian.PutUint32(data, 20) + copy(data[4:], "ftyp") + copy(data[8:], tt.major) + copy(data[16:], tt.compatible) + got := lineImageMediaInfo(data) + if got.mimeType != tt.want || got.usedMimeFallback || got.decodeErr != nil { + t.Fatalf("unexpected metadata: %+v", got) + } + for _, size := range []uint32{0, 12, 1024, 0xffffffff} { + binary.BigEndian.PutUint32(data, size) + if got := lineImageMediaInfo(data); !got.usedMimeFallback { + t.Fatalf("accepted invalid ftyp size %d", size) + } + } + }) + } +} + +func TestLineImageExtensions(t *testing.T) { + for mimeType, want := range map[string]string{ + "image/jpeg": "jpg", "image/png; charset=binary": "png", "image/gif": "gif", + "image/webp": "webp", "image/heic": "heic", "image/heif": "heif", "image/avif": "avif", + "image/bmp": "bmp", "image/tiff": "tiff", "image/svg+xml": "svg", "image/x-icon": "ico", "unknown": "jpg", + } { + if got := imageExtensionForMIME(mimeType); got != want { + t.Errorf("%s: got %s, want %s", mimeType, got, want) + } + } +} diff --git a/pkg/connector/handlers/image_test.go b/pkg/connector/handlers/image_test.go index 9931c772..e988e81e 100644 --- a/pkg/connector/handlers/image_test.go +++ b/pkg/connector/handlers/image_test.go @@ -23,6 +23,27 @@ func TestLineImageDownloadSourcePrefersPublicResource(t *testing.T) { } } +func TestLineMediaCategoryControlsPlainMediaDownload(t *testing.T) { + for _, tc := range []struct { + name, metadata, wantTID string + }{ + {"original media", `{"category":"original"}`, "original"}, + {"other media", `{"category":"preview"}`, ""}, + {"malformed metadata", "{", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + metadata := map[string]string{"MEDIA_CONTENT_INFO": tc.metadata} + options := lineOBSDownloadOptions(metadata, true) + if options.TID != tc.wantTID { + t.Fatalf("download TID = %q, want %q", options.TID, tc.wantTID) + } + }) + } + if got := lineMediaCategory(nil); got != "" { + t.Fatalf("nil metadata category = %q", got) + } +} + func TestLineImageDownloadSourcePrivateAndPlainFallbacks(t *testing.T) { privateSource := lineImageDownloadSource(line.Message{ ID: "message-id", diff --git a/pkg/connector/handlers/post_notification_test.go b/pkg/connector/handlers/post_notification_test.go index dce063f8..c54eeee1 100644 --- a/pkg/connector/handlers/post_notification_test.go +++ b/pkg/connector/handlers/post_notification_test.go @@ -9,6 +9,7 @@ import ( "sync" "sync/atomic" "testing" + "testing/synctest" "time" "maunium.net/go/mautrix/bridgev2" @@ -177,6 +178,10 @@ func TestConvertPostNotificationUploadsAlbumPreviewImages(t *testing.T) { } func TestConvertPostNotificationProcessesAlbumPreviewsConcurrentlyInOrder(t *testing.T) { + synctest.Test(t, testAlbumPreviewConcurrencyAndOrder) +} + +func testAlbumPreviewConcurrencyAndOrder(t *testing.T) { const previewCount = 6 matrix := &postNotificationTestMatrix{} @@ -248,10 +253,13 @@ func TestConvertPostNotificationProcessesAlbumPreviewsConcurrentlyInOrder(t *tes if len(initialStarted) != albumPreviewWorkerLimit { t.Fatalf("initial workers started %d unique previews, want %d", len(initialStarted), albumPreviewWorkerLimit) } + // Quiescence gives every worker a scheduling opportunity before asserting + // that no fifth download can start while all four slots are occupied. + synctest.Wait() select { case oid := <-started: t.Fatalf("preview %q started before a worker slot was released", oid) - case <-time.After(100 * time.Millisecond): + default: } close(release["oid-4"]) diff --git a/pkg/connector/handlers/text.go b/pkg/connector/handlers/text.go index 31afb1ba..44a067ac 100644 --- a/pkg/connector/handlers/text.go +++ b/pkg/connector/handlers/text.go @@ -41,7 +41,7 @@ func (h *Handler) ConvertText(ctx context.Context, portal *bridgev2.Portal, inte } if info.Image != "" && info.Obs.CDN != "" { if err := h.uploadPreviewImage(ctx, client, portal, intent, info.Obs.CDN+info.Image, preview); err != nil { - h.Log.Warn().Err(err).Str("url", requestURL).Msg("Failed to bridge link preview image") + h.Log.Warn().Msg("Failed to bridge link preview image") } } content.BeeperLinkPreviews = []*event.BeeperLinkPreview{preview} @@ -59,7 +59,13 @@ func (h *Handler) ConvertText(ctx context.Context, portal *bridgev2.Portal, inte } func (h *Handler) uploadPreviewImage(ctx context.Context, client *line.Client, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, imageURL string, preview *event.BeeperLinkPreview) error { - data, err := client.DownloadOBSPublicResource(ctx, imageURL) + var data []byte + var err error + if h.DownloadPublicMedia != nil { + data, err = h.DownloadPublicMedia(ctx, client, imageURL) + } else { + data, err = client.DownloadOBSPublicResource(ctx, imageURL) + } if err != nil { return err } diff --git a/pkg/connector/handlers/text_test.go b/pkg/connector/handlers/text_test.go new file mode 100644 index 00000000..c26174d8 --- /dev/null +++ b/pkg/connector/handlers/text_test.go @@ -0,0 +1,78 @@ +package handlers + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/id" + + "github.com/beeper/line/pkg/line" +) + +type linkPreviewRoundTripper func(*http.Request) (*http.Response, error) + +func (f linkPreviewRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +func TestConvertTextUploadsOnlyImageLinkPreviews(t *testing.T) { + const pageInfo = `{"code":0,"result":{"domain":"example.com","title":"Title","summary":"Summary","image":"/preview.png","obs":{"cdn":"https://cdn.example/"}}}` + png := []byte("\x89PNG\r\n\x1a\npreview") + for _, tc := range []struct { + name string + image []byte + wantURL id.ContentURIString + wantType string + }{ + {"image uploaded", png, "mxc://example/", "image/png"}, + {"non-image omitted", []byte("not an image"), "", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + client := line.NewClient("") + client.HTTPClient.Transport = linkPreviewRoundTripper(func(req *http.Request) (*http.Response, error) { + if !strings.Contains(req.URL.Path, "/pageinfo/get") { + t.Fatalf("unexpected LINE request: %s", req.URL) + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(pageInfo))}, nil + }) + matrix := &postNotificationTestMatrix{} + uploadsBefore := matrix.uploadCount() + var downloadedURL string + h := &Handler{ + Log: zerolog.New(io.Discard), + NewClient: func() *line.Client { return client }, + DownloadPublicMedia: func(_ context.Context, _ *line.Client, resource string) ([]byte, error) { + downloadedURL = resource + return tc.image, nil + }, + } + portal := &bridgev2.Portal{Portal: &database.Portal{MXID: "!preview:example.com"}} + converted, err := h.ConvertText(context.Background(), portal, matrix, "See https://example.com/page", nil) + if err != nil || converted == nil || len(converted.Parts) != 1 { + t.Fatalf("ConvertText = %#v, %v", converted, err) + } + content := converted.Parts[0].Content + if content.Body != "See https://example.com/page" || len(content.BeeperLinkPreviews) != 1 { + t.Fatalf("text content = %#v", content) + } + preview := content.BeeperLinkPreviews[0] + if preview.ImageURL != tc.wantURL || preview.ImageType != tc.wantType { + t.Fatalf("preview image = url %q type %q", preview.ImageURL, preview.ImageType) + } + if downloadedURL != "https://cdn.example//preview.png" { + t.Fatalf("download URL = %q", downloadedURL) + } + wantUploads := uploadsBefore + if tc.wantURL != "" { + wantUploads++ + } + if matrix.uploadCount() != wantUploads { + t.Fatalf("upload count = %d, want %d", matrix.uploadCount(), wantUploads) + } + }) + } +} diff --git a/pkg/connector/identity_audit_test.go b/pkg/connector/identity_audit_test.go new file mode 100644 index 00000000..ec3c66c0 --- /dev/null +++ b/pkg/connector/identity_audit_test.go @@ -0,0 +1,77 @@ +package connector + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/beeper/line/pkg/line" +) + +func TestLookupToOutboundIdentityRepresentations(t *testing.T) { + for _, tc := range []struct{ name, returned, expected string }{ + {"legacy", resolutionMID, resolutionMID}, + {"legacy uppercase", strings.ToUpper(resolutionMID), resolutionMID}, + {"opaque", opaqueMID, opaqueMID}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + calls++ + switch { + case strings.HasSuffix(req.URL.Path, "/findContactByUserid"): + return resolutionResponse(`{"code":0,"data":{"mid":"` + tc.returned + `","displayName":"Fixture"}}`), nil + case strings.HasSuffix(req.URL.Path, "/sendMessage"): + var args []json.RawMessage + if err := json.NewDecoder(req.Body).Decode(&args); err != nil || len(args) != 2 { + t.Fatal("bad send args") + } + var sent line.Message + if err := json.Unmarshal(args[1], &sent); err != nil { + t.Fatal(err) + } + if sent.To != tc.expected || sent.From != resolutionOwnMID { + t.Fatalf("identity changed: %+v", sent) + } + return resolutionResponse(`{"code":0,"data":{"id":"fixture-message"}}`), nil + default: + t.Fatalf("unexpected sync/auth/API operation: %s", req.URL.Path) + return nil, nil + } + }) + ctx := context.Background() + resolved, err := lc.ResolveIdentifier(ctx, "fixture.id", true) + if err != nil { + t.Fatal(err) + } + msg := outboundTestMessage(t, lc) + msg.Portal, err = lc.UserLogin.Bridge.GetPortalByKey(ctx, resolved.Chat.PortalKey) + if err != nil { + t.Fatal(err) + } + response, err := lc.HandleMatrixMessage(ctx, msg) + if err != nil || response == nil { + t.Fatalf("send failed: %v", err) + } + if calls != 2 || !lc.hasAccessToken() || lc.isSessionInvalidated() { + t.Fatal("unexpected calls or invalidated session") + } + }) + } +} + +func TestUnsupportedLookupMIDDoesNotInvalidateLogin(t *testing.T) { + lc := newResolutionClient(t, func(*http.Request) (*http.Response, error) { + return resolutionResponse(`{"code":0,"data":{"mid":"U-unsupported-length"}}`), nil + }) + result, err := lc.ResolveIdentifier(context.Background(), "fixture.id", true) + if err == nil || result != nil { + t.Fatal("unverified recipient accepted") + } + if !lc.hasAccessToken() || lc.isSessionInvalidated() { + t.Fatal("lookup validation invalidated login") + } + assertPortalCount(t, lc, 0) +} diff --git a/pkg/connector/login_command.go b/pkg/connector/login_command.go new file mode 100644 index 00000000..491a293e --- /dev/null +++ b/pkg/connector/login_command.go @@ -0,0 +1,56 @@ +package connector + +import ( + "fmt" + "strings" + + "maunium.net/go/mautrix/bridgev2/commands" +) + +func (lc *LineConnector) registerLoginCommand() { + if processor, ok := lc.br.Commands.(*commands.Processor); ok { + processor.AddHandler(lineLoginCommand()) + } +} + +func lineLoginCommand() *commands.FullHandler { + // Copy the framework handler to retain its permission requirements without + // modifying the shared handler. Only method selection is LINE-specific. + handler := *commands.CommandLogin + handler.Help.Args = "[qr|email|help]" + handler.Func = func(ce *commands.Event) { + flows := ce.Bridge.Network.GetLoginFlows() + var help strings.Builder + help.WriteString("Choose a LINE login method:\n\n") + for _, flow := range flows { + method := flow.ID + switch flow.ID { + case LoginFlowIDQR: + method = "qr" + case LoginFlowIDEmail: + method = "email" + } + _, _ = fmt.Fprintf(&help, "* `$cmdprefix login %s` — %s\n", method, flow.Description) + } + if len(ce.Args) == 0 || strings.EqualFold(ce.Args[0], "help") { + ce.Reply(help.String()) + return + } + method := strings.ToLower(ce.Args[0]) + switch method { + case "qr": + method = LoginFlowIDQR + case "email": + method = LoginFlowIDEmail + } + for _, flow := range flows { + if strings.EqualFold(flow.ID, method) { + ce.Args = append([]string{flow.ID}, ce.Args[1:]...) + commands.CommandLogin.Run(ce) + return + } + } + ce.Reply("Unknown or unavailable LINE login method.\n\n" + help.String()) + } + return &handler +} diff --git a/pkg/connector/login_command_test.go b/pkg/connector/login_command_test.go new file mode 100644 index 00000000..f73bcb27 --- /dev/null +++ b/pkg/connector/login_command_test.go @@ -0,0 +1,114 @@ +package connector + +import ( + "context" + "errors" + "io" + "path/filepath" + "strings" + "testing" + + "github.com/rs/zerolog" + "go.mau.fi/util/dbutil" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/bridgeconfig" + "maunium.net/go/mautrix/bridgev2/commands" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +type loginCommandIntent struct { + bridgev2.MatrixAPI + replies []string +} + +func (i *loginCommandIntent) GetMXID() id.UserID { return "@bot:example.com" } +func (*loginCommandIntent) IsDoublePuppet() bool { return false } +func (i *loginCommandIntent) SendMessage(_ context.Context, _ id.RoomID, _ event.Type, content *event.Content, _ *bridgev2.MatrixSendExtra) (*mautrix.RespSendEvent, error) { + i.replies = append(i.replies, content.AsMessage().Body) + return &mautrix.RespSendEvent{}, nil +} + +type loginCommandMatrix struct { + bridgev2.MatrixConnector + bot *loginCommandIntent +} + +func (*loginCommandMatrix) Init(*bridgev2.Bridge) {} +func (m *loginCommandMatrix) BotIntent() bridgev2.MatrixAPI { return m.bot } +func (*loginCommandMatrix) SendMessageStatus(context.Context, *bridgev2.MessageStatus, *bridgev2.MessageStatusEventInfo) { +} + +type loginCommandNetwork struct { + LineConnector + chosen string +} + +func (n *loginCommandNetwork) CreateLogin(_ context.Context, _ *bridgev2.User, flow string) (bridgev2.LoginProcess, error) { + n.chosen = flow + return nil, errors.New("test authentication dispatch") +} + +// This test goes through mautrix-go's actual command processor and the handler +// installed by LineConnector.Init, not just the handler function in isolation. +func TestRegisteredLineLoginCommandDispatch(t *testing.T) { + ctx := context.Background() + db, err := dbutil.NewWithDialect("file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "bridge.db")), "sqlite3") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + network := &loginCommandNetwork{LineConnector: LineConnector{Config: Config{QRLogin: true}}} + bot := &loginCommandIntent{} + matrix := &loginCommandMatrix{bot: bot} + br := bridgev2.NewBridge("test", db, zerolog.New(io.Discard), &bridgeconfig.BridgeConfig{CommandPrefix: "!line"}, matrix, network, func(br *bridgev2.Bridge) bridgev2.CommandProcessor { + return commands.NewProcessor(br) + }) + if network.br != br { + t.Fatalf("connector Init was not called: connector bridge %p, expected %p", network.br, br) + } + if _, ok := br.Commands.(*commands.Processor); !ok { + t.Fatalf("commands processor has unexpected type %T", br.Commands) + } + if !commands.CommandLogin.RequiresLoginPermission { + t.Fatal("wrapped login command lost framework login permission requirement") + } + if !strings.Contains(lineLoginCommand().Help.Args, "qr|email|help") { + t.Fatal("registered login command help does not list friendly methods") + } + if err = br.DB.Upgrade(ctx); err != nil { + t.Fatal(err) + } + user := &bridgev2.User{User: &database.User{MXID: "@owner:example.com", ManagementRoom: "!management:example.com"}, Bridge: br, Permissions: bridgeconfig.Permissions{Login: true, MaxLogins: 5}} + for _, tc := range []struct { + name, command, args, wantFlow, wantReply string + qrEnabled bool + }{ + {"default help", "login", "", "", "login qr", true}, + {"explicit help", "login", "help", "", "login email", true}, + {"qr alias", "login", "qr", LoginFlowIDQR, "Failed to prepare login process", true}, + {"case-insensitive qr alias", "login", "QR", LoginFlowIDQR, "Failed to prepare login process", true}, + {"email alias", "login", "email", LoginFlowIDEmail, "Failed to prepare login process", true}, + {"case-insensitive email alias", "login", "EMAIL", LoginFlowIDEmail, "Failed to prepare login process", true}, + {"qr flow ID", "login", LoginFlowIDQR, LoginFlowIDQR, "Failed to prepare login process", true}, + {"email flow ID", "login", LoginFlowIDEmail, LoginFlowIDEmail, "Failed to prepare login process", true}, + {"unknown method", "login", "invalid", "", "Unknown or unavailable LINE login method", true}, + {"disabled qr", "login", "qr", "", "Unknown or unavailable LINE login method", false}, + } { + t.Run(tc.name, func(t *testing.T) { + network.chosen = "" + network.Config.QRLogin = tc.qrEnabled + bot.replies = nil + processor := br.Commands.(*commands.Processor) + processor.Handle(ctx, user.ManagementRoom, "$command", user, tc.command+" "+tc.args, "") + if network.chosen != tc.wantFlow { + t.Fatalf("selected flow %q, want %q (replies %q)", network.chosen, tc.wantFlow, bot.replies) + } + if len(bot.replies) == 0 || !strings.Contains(bot.replies[len(bot.replies)-1], tc.wantReply) { + t.Fatalf("replies %q do not contain %q", bot.replies, tc.wantReply) + } + }) + } +} diff --git a/pkg/connector/login_cooldown.go b/pkg/connector/login_cooldown.go new file mode 100644 index 00000000..ce9194c6 --- /dev/null +++ b/pkg/connector/login_cooldown.go @@ -0,0 +1,238 @@ +package connector + +import ( + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/beeper/line/pkg/line" +) + +const ( + // loginRateLimitCooldown is how long the bridge stops starting new login + // attempts for an account after LINE rejected a login as rate limited. + // Repeated attempts keep the account locked, so the bridge waits instead + // of retrying. + loginRateLimitCooldown = 15 * time.Minute + // loginRetryLaterCooldown is the shorter wait used when LINE only asked the + // client to come back later. + loginRetryLaterCooldown = 5 * time.Minute + // loginRetryLaterMessage is shown when LINE asked the client to come back + // later instead of naming a rate limit. + loginRetryLaterMessage = "LINE asked us to try the sign-in again later. Please wait a few minutes before trying again." +) + +// loginRateLimitKind classifies how strongly a login rejection looks like an +// account login rate limit. +type loginRateLimitKind int + +const ( + loginRateLimitNone loginRateLimitKind = iota + loginRateLimitLocked + loginRateLimitRetryLater + loginRateLimitVerificationUnavailable +) + +// loginCooldown tracks when each account may try to sign in again. It is +// in-memory on purpose: the limit is enforced by LINE, so the bridge only +// needs to avoid making it worse. +type loginCooldown struct { + mu sync.Mutex + until map[string]time.Time + active map[string]bool +} + +func (c *loginCooldown) acquire(userID string) bool { + if c == nil || userID == "" { + return false + } + c.mu.Lock() + defer c.mu.Unlock() + if c.active == nil { + c.active = make(map[string]bool) + } + if c.active[userID] { + return false + } + c.active[userID] = true + return true +} + +func (c *loginCooldown) release(userID string) { + if c == nil || userID == "" { + return + } + c.mu.Lock() + delete(c.active, userID) + c.mu.Unlock() +} + +// block stops new login attempts for the account until the given time. +func (c *loginCooldown) block(userID string, until time.Time) { + if c == nil || userID == "" { + return + } + c.mu.Lock() + defer c.mu.Unlock() + now := time.Now() + if c.until == nil { + c.until = make(map[string]time.Time) + } else { + for account, expiry := range c.until { + if !expiry.After(now) { + delete(c.until, account) + } + } + } + if current, ok := c.until[userID]; !ok || until.After(current) { + c.until[userID] = until + } +} + +// remaining returns how long the account still has to wait. +func (c *loginCooldown) remaining(userID string) time.Duration { + if c == nil || userID == "" { + return 0 + } + c.mu.Lock() + defer c.mu.Unlock() + until, ok := c.until[userID] + if !ok { + return 0 + } + remaining := time.Until(until) + if remaining <= 0 { + delete(c.until, userID) + return 0 + } + return remaining +} + +// classifyLoginRateLimit reports whether a login rejection is an account login +// rate limit. LINE does not name the condition in every response, so the +// explicit signals are used: HTTP 429, the fixed "too many login attempts" +// reason, or a request to come back later. If the error carries a structured +// AuthError, its category is authoritative. +func classifyLoginRateLimit(err error) loginRateLimitKind { + if err == nil { + return loginRateLimitNone + } + + // Prefer the structured AuthError classification when available. + var ae *line.AuthError + if errors.As(err, &ae) { + switch ae.Category { + case line.AuthErrorTooManyAttempts: + return loginRateLimitLocked + case line.AuthErrorRetryLater: + return loginRateLimitRetryLater + case line.AuthErrorVerificationTemporarilyUnavailable: + return loginRateLimitVerificationUnavailable + default: + return loginRateLimitNone + } + } + if line.HTTPStatusCode(err) == http.StatusTooManyRequests { + return loginRateLimitLocked + } + + if detail, ok := line.QRLoginFailureDetail(err); ok { + if detail.HTTPStatus == http.StatusTooManyRequests { + return loginRateLimitLocked + } + if isLoginRateLimitText(detail.Reason) || isLoginRateLimitText(detail.Alert) { + return loginRateLimitLocked + } + if detail.ExceptionLabel == "RETRY_LATER" { + return loginRateLimitRetryLater + } + return loginRateLimitNone + } + details := parseLoginErrorDetails(err) + if details.HTTPStatus == http.StatusTooManyRequests { + return loginRateLimitLocked + } + if isLoginRateLimitText(details.ErrorReason) || isLoginRateLimitText(details.ErrorMessage) { + return loginRateLimitLocked + } + if isVerificationTemporarilyUnavailableText(details.ErrorReason) || isVerificationTemporarilyUnavailableText(details.ErrorMessage) { + return loginRateLimitVerificationUnavailable + } + if isRetryLaterText(details.ErrorReason) || isRetryLaterText(details.ErrorMessage) { + return loginRateLimitRetryLater + } + return loginRateLimitNone +} + +func isVerificationTemporarilyUnavailableText(text string) bool { + return line.SanitizeProviderAlert(text) == "verification_temporarily_unavailable" +} + +func isLoginRateLimitText(text string) bool { + category := line.SanitizeProviderAlert(text) + return category == "too_many_login_attempts" || category == "blocked_user" +} + +func isRetryLaterText(text string) bool { + text = strings.TrimSpace(text) + return strings.EqualFold(text, "retry later") || strings.EqualFold(text, "RETRY_LATER") +} + +// noteLoginRateLimit records a rate limit rejection so the next attempt waits +// instead of extending the lock. +func (ll *LineEmailLogin) noteLoginRateLimit(err error) { + if ll.cooldown == nil || ll.User == nil { + return + } + now := time.Now() + var authErr *line.AuthError + _ = errors.As(err, &authErr) + var delay time.Duration + switch classifyLoginRateLimit(err) { + case loginRateLimitLocked: + delay = loginRateLimitCooldown + case loginRateLimitRetryLater: + delay = loginRetryLaterCooldown + case loginRateLimitVerificationUnavailable: + delay = verificationTemporarilyUnavailableCooldown + } + if authErr != nil && authErr.RetryAfter != nil && *authErr.RetryAfter > delay { + delay = *authErr.RetryAfter + } + if retryAfter := line.HTTPRetryAfter(err); retryAfter > delay && (line.HTTPStatusCode(err) == http.StatusTooManyRequests || line.HTTPStatusCode(err) >= http.StatusInternalServerError) { + delay = retryAfter + } + if delay > 0 { + ll.cooldown.block(string(ll.User.MXID), now.Add(delay)) + } +} + +// loginCooldownError asks the user to wait while the account is still inside a +// rate limit window. +func (ll *LineEmailLogin) loginCooldownError() error { + if ll.cooldown == nil || ll.User == nil { + return nil + } + remaining := ll.cooldown.remaining(string(ll.User.MXID)) + if remaining <= 0 { + return nil + } + return ErrLoginRateLimited.WithMessage( + "LINE is limiting how often this account can sign in. Please try again in %s.", + formatLoginWait(remaining), + ) +} + +func formatLoginWait(remaining time.Duration) string { + minutes := int((remaining + time.Minute - 1) / time.Minute) + if minutes < 1 { + minutes = 1 + } + if minutes == 1 { + return "1 minute" + } + return fmt.Sprintf("%d minutes", minutes) +} diff --git a/pkg/connector/login_cooldown_test.go b/pkg/connector/login_cooldown_test.go new file mode 100644 index 00000000..a0b0cd4d --- /dev/null +++ b/pkg/connector/login_cooldown_test.go @@ -0,0 +1,381 @@ +package connector + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + + "github.com/beeper/line/pkg/line" +) + +func runQRLoginToRejection(t *testing.T, body string, status int) (*LineConnector, *bridgev2.User, error) { + t.Helper() + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + method := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:] + switch method { + case "createSession": + return resolutionResponse(`{"code":0,"data":{"authSessionId":"qr-session"}}`), nil + case "createQrCode": + return resolutionResponse(`{"code":0,"data":{"callbackUrl":"line://au/q/test","longPollingMaxCount":1}}`), nil + case "checkQrCodeVerified", "checkPinCodeVerified": + return resolutionResponse(`{"code":0,"data":{}}`), nil + case "verifyCertificate": + resp := resolutionResponse(`{"code":1,"data":{}}`) + resp.StatusCode = http.StatusBadRequest + return resp, nil + case "createPinCode": + return resolutionResponse(`{"code":0,"data":{"pinCode":"123456"}}`), nil + case "qrCodeLoginV2": + resp := resolutionResponse(body) + resp.StatusCode = status + return resp, nil + default: + return nil, fmt.Errorf("unexpected QR RPC: %s", method) + } + }) + lq := &LineQRLogin{login: &LineEmailLogin{User: lc.UserLogin.User, cooldown: &lc.UserLogin.Bridge.Network.(*LineConnector).loginCooldown}} + defer lq.Cancel() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if _, err := lq.Start(ctx); err != nil { + t.Fatal(err) + } + if _, err := lq.Wait(ctx); err != nil { + t.Fatal(err) + } + _, err := lq.Wait(ctx) + return lc.UserLogin.Bridge.Network.(*LineConnector), lc.UserLogin.User, err +} + +func TestQRStartupRateLimitMapsAndStartsCooldown(t *testing.T) { + for _, method := range []string{"createSession", "createQrCode"} { + t.Run(method, func(t *testing.T) { + calls := 0 + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + gotMethod := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:] + if method == "createQrCode" && gotMethod == "createSession" { + return resolutionResponse(`{"code":0,"data":{"authSessionId":"qr-session"}}`), nil + } + calls++ + if gotMethod != method { + t.Fatalf("unexpected QR RPC %s, wanted %s", gotMethod, method) + } + resp := resolutionResponse(`{"code":10051,"data":{"name":"SecondaryQrCodeException","code":5}}`) + resp.StatusCode = http.StatusTooManyRequests + return resp, nil + }) + connector := lc.UserLogin.Bridge.Network.(*LineConnector) + lq := &LineQRLogin{login: &LineEmailLogin{User: lc.UserLogin.User, cooldown: &connector.loginCooldown}} + defer lq.Cancel() + _, err := lq.Start(context.Background()) + var respErr bridgev2.RespError + if !errors.As(err, &respErr) || respErr.StatusCode != http.StatusTooManyRequests { + t.Fatalf("startup failure was not mapped to a rate limit: %v", err) + } + if connector.loginCooldown.remaining(string(lc.UserLogin.User.MXID)) <= 0 { + t.Fatal("startup rate limit did not activate account cooldown") + } + _, again := lq.Start(context.Background()) + if again == nil || calls != 1 { + t.Fatalf("startup cooldown did not prevent another provider call: calls=%d err=%v", calls, again) + } + }) + } +} + +func TestQRRateLimitIsReportedAndBlocksImmediateRetry(t *testing.T) { + for _, tc := range []struct { + name, body, wantMessage string + status int + }{ + { + name: "retry later", + body: `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":5}}`, + wantMessage: loginRetryLaterMessage, + status: http.StatusBadRequest, + }, + { + name: "too many attempts", + body: `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":100,"reason":"Too many login attempts"}}`, + wantMessage: loginTooManyAttemptsReason, + status: http.StatusBadRequest, + }, + { + name: "http 429", + body: `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":100}}`, + wantMessage: loginTooManyAttemptsReason, + status: http.StatusTooManyRequests, + }, + } { + t.Run(tc.name, func(t *testing.T) { + connector, user, err := runQRLoginToRejection(t, tc.body, tc.status) + if err == nil { + t.Fatal("rate limited QR login was accepted") + } + var respErr bridgev2.RespError + if !errors.As(err, &respErr) { + t.Fatalf("QR rejection is not a user-facing error: %v", err) + } + if respErr.Err != tc.wantMessage { + t.Fatalf("message = %q, want %q", respErr.Err, tc.wantMessage) + } + if respErr.StatusCode != http.StatusTooManyRequests { + t.Fatalf("status = %d, want 429", respErr.StatusCode) + } + connector.loginCooldown.block("other-user", time.Now().Add(time.Hour)) + remaining := connector.loginCooldown.remaining(string(user.MXID)) + if remaining <= 0 { + t.Fatal("rate limit did not start a cooldown") + } + lq := &LineQRLogin{login: &LineEmailLogin{User: user, cooldown: &connector.loginCooldown}} + _, err = lq.Start(context.Background()) + if err == nil { + t.Fatal("a new QR login was started inside the rate limit window") + } + var cooldownErr bridgev2.RespError + if !errors.As(err, &cooldownErr) { + t.Fatalf("cooldown error is not user-facing: %v", err) + } + if !strings.Contains(cooldownErr.Err, "try again in") { + t.Fatalf("cooldown message = %q", cooldownErr.Err) + } + }) + } +} + +func TestVerificationTemporarilyUnavailableBlocksRetry(t *testing.T) { + oldLogin := loginWithCredentials + t.Cleanup(func() { loginWithCredentials = oldLogin }) + loginWithCredentials = func(_, _, _ string) (*line.LoginResult, error) { + return nil, errors.New("login failed: API error 400: {\"code\":10051,\"data\":{\"name\":\"TalkException\",\"reason\":\"Verification is temporarily unavailable.\\nPlease try again later.\"}}") + } + logger := zerolog.New(io.Discard) + var cooldown loginCooldown + ll := &LineEmailLogin{ + User: &bridgev2.User{ + User: &database.User{MXID: "@user:example.com"}, + Bridge: &bridgev2.Bridge{Log: logger}, + Log: logger, + }, + cooldown: &cooldown, + } + step, err := ll.SubmitUserInput(context.Background(), map[string]string{ + "email": "user@example.com", + "password": "secret", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if step == nil { + t.Fatal("expected error step, got nil") + } + remaining := cooldown.remaining("@user:example.com") + if remaining <= verificationTemporarilyUnavailableCooldown-time.Minute || remaining > verificationTemporarilyUnavailableCooldown { + t.Fatalf("unexpected cooldown remaining: %s", remaining) + } +} + +func TestQRRejectionWithoutRateLimitDoesNotBlockRetry(t *testing.T) { + connector, user, err := runQRLoginToRejection(t, `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":2}}`, http.StatusBadRequest) + if err == nil { + t.Fatal("QR login was accepted") + } + if remaining := connector.loginCooldown.remaining(string(user.MXID)); remaining != 0 { + t.Fatalf("a verification failure started a rate limit cooldown: %s", remaining) + } + lq := &LineQRLogin{login: &LineEmailLogin{User: user, cooldown: &connector.loginCooldown}} + _, err = lq.Start(context.Background()) + if err != nil && strings.Contains(err.Error(), "limiting how often") { + t.Fatalf("unexpected rate limit error: %v", err) + } +} + +func TestEmailLoginRateLimitBlocksNextAttempt(t *testing.T) { + oldLogin := loginWithCredentials + t.Cleanup(func() { loginWithCredentials = oldLogin }) + loginWithCredentials = func(_, _, _ string) (*line.LoginResult, error) { + return nil, errors.New(`login failed: API error 429: {"code":10051,"data":{"name":"TalkException","reason":"Too many login attempts"}}`) + } + logger := zerolog.New(io.Discard) + var cooldown loginCooldown + ll := &LineEmailLogin{ + User: &bridgev2.User{ + User: &database.User{MXID: "@user:example.com"}, + Bridge: &bridgev2.Bridge{Log: logger}, + Log: logger, + }, + cooldown: &cooldown, + } + step, err := ll.SubmitUserInput(context.Background(), map[string]string{ + "email": "user@example.com", + "password": "secret", + }) + if err != nil { + t.Fatal(err) + } + if step == nil || step.Instructions != loginTooManyAttemptsInstructions { + t.Fatalf("step instructions = %v, want %q", step, loginTooManyAttemptsInstructions) + } + if remaining := cooldown.remaining("@user:example.com"); remaining <= 0 { + t.Fatal("rate limited login did not start a cooldown") + } + if _, err := ll.Start(context.Background()); err == nil { + t.Fatal("a new login was started inside the rate limit window") + } else if !strings.Contains(err.Error(), "try again in") { + t.Fatalf("unexpected cooldown error: %v", err) + } +} + +func TestStoredCredentialRecoveryRespectsActiveCooldown(t *testing.T) { + client := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + t.Fatalf("stored-credential recovery bypassed cooldown: %s", req.URL.Path) + return nil, nil + }) + meta := client.UserLogin.Metadata.(*UserLoginMetadata) + meta.Email, meta.Password = "user@example.com", "password" + connector := client.UserLogin.Bridge.Network.(*LineConnector) + connector.loginCooldown.block(string(client.UserLogin.User.MXID), time.Now().Add(15*time.Minute)) + err := client.tryLogin(context.Background()) + var respErr bridgev2.RespError + if !errors.As(err, &respErr) || respErr.StatusCode != http.StatusTooManyRequests { + t.Fatalf("stored reconnect cooldown = %v", err) + } +} + +func TestStoredCredentialRecoveryStartsCooldownAfterProviderLimit(t *testing.T) { + oldLogin := loginWithCredentials + t.Cleanup(func() { loginWithCredentials = oldLogin }) + loginWithCredentials = func(_, _, _ string) (*line.LoginResult, error) { + return nil, errors.New(`API error 429: {"code":10051,"data":{"name":"TalkException","reason":"Too many login attempts"}}`) + } + client := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + t.Fatalf("mocked login made an unexpected API request: %s", req.URL.Path) + return nil, nil + }) + meta := client.UserLogin.Metadata.(*UserLoginMetadata) + meta.Email, meta.Password = "user@example.com", "password" + connector := client.UserLogin.Bridge.Network.(*LineConnector) + if err := client.tryLogin(context.Background()); err == nil { + t.Fatal("rate-limited stored-credential recovery succeeded") + } + if connector.loginCooldown.remaining(string(client.UserLogin.User.MXID)) <= 0 { + t.Fatal("background reconnect did not activate cooldown") + } +} + +func TestStoredCredentialRecoveryHonorsHTTPRetryAfter(t *testing.T) { + oldLogin := loginWithCredentials + t.Cleanup(func() { loginWithCredentials = oldLogin }) + delay := 20 * time.Minute + loginWithCredentials = func(_, _, _ string) (*line.LoginResult, error) { + return nil, &line.HTTPError{StatusCode: http.StatusTooManyRequests, RetryAfter: delay} + } + client := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + t.Fatalf("mocked login made an unexpected API request: %s", req.URL.Path) + return nil, nil + }) + meta := client.UserLogin.Metadata.(*UserLoginMetadata) + meta.Email, meta.Password = "user@example.com", "password" + connector := client.UserLogin.Bridge.Network.(*LineConnector) + _ = client.tryLogin(context.Background()) + if remaining := connector.loginCooldown.remaining(string(client.UserLogin.User.MXID)); remaining < delay-time.Second { + t.Fatalf("background cooldown=%s, want Retry-After %s", remaining, delay) + } +} + +func TestLoginCooldownExpires(t *testing.T) { + var cooldown loginCooldown + cooldown.block("@user:example.com", time.Now().Add(-time.Minute)) + if remaining := cooldown.remaining("@user:example.com"); remaining != 0 { + t.Fatalf("expired cooldown still blocks login: %s", remaining) + } + cooldown.block("@user:example.com", time.Now().Add(time.Minute)) + if remaining := cooldown.remaining("@user:example.com"); remaining <= 0 { + t.Fatal("active cooldown does not block login") + } + if remaining := cooldown.remaining("@other:example.com"); remaining != 0 { + t.Fatalf("cooldown leaked to another account: %s", remaining) + } +} + +func TestLoginClaimSerializesSameAccount(t *testing.T) { + var cooldown loginCooldown + user := &bridgev2.User{User: &database.User{MXID: "@same:example.com"}} + first := &LineEmailLogin{User: user, cooldown: &cooldown} + second := &LineEmailLogin{User: user, cooldown: &cooldown} + if err := first.claimLogin(); err != nil { + t.Fatal(err) + } + if err := second.claimLogin(); !errors.Is(err, ErrLoginInProgress) { + t.Fatalf("concurrent login claim = %v, want ErrLoginInProgress", err) + } + first.releaseLogin() + if err := second.claimLogin(); err != nil { + t.Fatalf("claim after release = %v", err) + } + second.releaseLogin() +} + +func TestCanceledEmailVerificationReleasesLoginClaim(t *testing.T) { + var cooldown loginCooldown + user := &bridgev2.User{User: &database.User{MXID: "@cancelled:example.com"}} + login := &LineEmailLogin{User: user, cooldown: &cooldown, Verifier: "verification"} + ctx, cancel := context.WithCancel(context.Background()) + login.attemptCtx = ctx + login.pollResult = make(chan *line.LoginResult) + login.pollErr = make(chan error) + if err := login.claimLogin(); err != nil { + t.Fatal(err) + } + cancel() + if _, err := login.Wait(context.Background()); !errors.Is(err, context.Canceled) { + t.Fatalf("Wait after cancellation = %v", err) + } + if !cooldown.acquire(string(user.MXID)) { + t.Fatal("canceled verification retained the per-account login lock") + } + cooldown.release(string(user.MXID)) +} + +func TestLoginCooldownUsesRetryAfter(t *testing.T) { + var cooldown loginCooldown + user := &bridgev2.User{User: &database.User{MXID: "@user:example.com"}} + ll := &LineEmailLogin{User: user, cooldown: &cooldown} + delay := 20 * time.Minute + ll.noteLoginRateLimit(&line.AuthError{Category: line.AuthErrorTooManyAttempts, RetryAfter: &delay}) + if remaining := cooldown.remaining(string(user.MXID)); remaining < delay-time.Second || remaining > delay { + t.Fatalf("cooldown=%s, want provider Retry-After %s", remaining, delay) + } +} + +func TestLoginRateLimitClassification(t *testing.T) { + for _, tc := range []struct { + name string + err error + want loginRateLimitKind + }{ + {"http 429", errors.New("login failed: API error 429: {\"code\":10051}"), loginRateLimitLocked}, + {"too many attempts", errors.New("login failed: API error 400: {\"code\":10051,\"data\":{\"name\":\"TalkException\",\"reason\":\"Too many login attempts\"}}"), loginRateLimitLocked}, + {"blocked user", errors.New("login failed: API error 400: {\"code\":10051,\"data\":{\"name\":\"TalkException\",\"reason\":\"blocked user\"}}"), loginRateLimitLocked}, + {"invalid credentials", errors.New("login failed: API error 400: {\"code\":10051,\"data\":{\"name\":\"TalkException\",\"reason\":\"Account ID or password is invalid\"}}"), loginRateLimitNone}, + {"unrelated", errors.New("network unreachable"), loginRateLimitNone}, + {"nil", nil, loginRateLimitNone}, + {"verification temporarily unavailable via AuthError", wrapLineLoginError(errors.New(`login failed: API error 400: {"code":10051,"data":{"name":"TalkException","reason":"Verification is temporarily unavailable"}}`)), loginRateLimitVerificationUnavailable}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := classifyLoginRateLimit(tc.err); got != tc.want { + t.Fatalf("classification = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/pkg/connector/login_error_logging_test.go b/pkg/connector/login_error_logging_test.go index fa935776..24c2ec9f 100644 --- a/pkg/connector/login_error_logging_test.go +++ b/pkg/connector/login_error_logging_test.go @@ -57,11 +57,11 @@ func TestSubmitUserInputLogsStructuredLoginErrorWithoutCredentials(t *testing.T) "error_summary": "API error 400", "http_status": float64(400), "line_response_code": float64(10051), - "line_response_message": "RESPONSE_ERROR", + "line_response_message": "redacted", "line_error_name": "TalkException", "line_error_code": float64(35), - "line_error_message": "Blocked user", - "line_error_reason": "blocked user", + "line_error_message": "redacted", + "line_error_reason": "blocked_user", } for key, want := range wantFields { if got := event[key]; got != want { @@ -88,6 +88,56 @@ func TestParseLoginErrorDetailsWithoutJSON(t *testing.T) { } } +func TestSessionEstablishmentErrorKeepsStageAndSafeMessage(t *testing.T) { + err := errors.New(`API error 503: {"code":10051,"data":{"name":"LoginException","message":"LINE could not verify this session. Please try again."}}`) + wrapped := wrapSessionEstablishError(&LineEmailLogin{authFlow: "qr"}, err) + var authErr *line.AuthError + if !errors.As(wrapped, &authErr) { + t.Fatalf("session failure lacks AuthError: %v", wrapped) + } + if authErr.Flow != "qr" || authErr.Stage != line.AuthStageSessionEstablish || authErr.Category != line.AuthErrorServiceUnavailable { + t.Fatalf("incorrect session classification: %+v", authErr) + } + var respErr bridgev2.RespError + if !errors.As(wrapped, &respErr) || !strings.Contains(respErr.Err, "LINE could not verify this session") { + t.Fatalf("safe provider message did not reach Matrix: %v", wrapped) + } +} + +func TestLoginTransportErrorsUseNetworkCategory(t *testing.T) { + wrapped := wrapLineLoginError(context.DeadlineExceeded) + var authErr *line.AuthError + if !errors.As(wrapped, &authErr) || authErr.Category != line.AuthErrorNetworkUnavailable || authErr.Stage != line.AuthStageEmailLogin { + t.Fatalf("transport error classification = %#v (%v)", authErr, wrapped) + } + var respErr bridgev2.RespError + if !errors.As(wrapped, &respErr) || !strings.Contains(respErr.Err, "reach LINE") { + t.Fatalf("network error is not actionable: %v", wrapped) + } +} + +func TestReconnectErrorReasonKeepsNetworkAndProviderUnavailableMeaning(t *testing.T) { + if got := loginErrorReason(context.DeadlineExceeded); got != line.UserMessageForCategory(line.AuthErrorNetworkUnavailable) { + t.Fatalf("network reconnect reason = %q", got) + } + err := errors.New(`API error 503: {"code":10051,"data":{"name":"TalkException"}}`) + if got := loginErrorReason(err); got != line.UserMessageForCategory(line.AuthErrorServiceUnavailable) { + t.Fatalf("provider-unavailable reconnect reason = %q", got) + } +} + +func TestUnknownHTTP400LoginRejectionHasSafeFallback(t *testing.T) { + err := wrapLineLoginError(errors.New(`API error 400: {"code":10051,"data":{"name":"LoginException","code":987}}`)) + var authErr *line.AuthError + if !errors.As(err, &authErr) || authErr.Category != line.AuthErrorUnknown { + t.Fatalf("unknown rejection classification = %#v (%v)", authErr, err) + } + var respErr bridgev2.RespError + if !errors.As(err, &respErr) || respErr.Err != genericLoginFailureReason { + t.Fatalf("unknown rejection fallback = %v", err) + } +} + func TestLoginErrorSummaryDoesNotIncludeNonJSONResponseBody(t *testing.T) { err := errors.New("login failed: API error 502: upstream secret response") details := parseLoginErrorDetails(err) @@ -96,6 +146,27 @@ func TestLoginErrorSummaryDoesNotIncludeNonJSONResponseBody(t *testing.T) { } } +func TestLoginErrorReasonDoesNotLeakUnknownProviderText(t *testing.T) { + for _, tc := range []struct { + reason, want string + }{ + {"blocked user", loginTooManyAttemptsReason}, + {"Too many login attempts", loginTooManyAttemptsReason}, + {"Verification is temporarily unavailable", "Verification is temporarily unavailable"}, + {"Account ID or password is invalid", "Account ID or password is invalid"}, + {"Authentication failed", "Authentication failed"}, + {"", ""}, + {"response-token secret", ""}, + {"password: hunter2", "password=[redacted]"}, + } { + t.Run(tc.reason, func(t *testing.T) { + if got := safeEmailLoginReason(tc.reason); got != tc.want { + t.Fatalf("safeEmailLoginReason(%q) = %q, want %q", tc.reason, got, tc.want) + } + }) + } +} + func TestLoginErrorSummaryAllowsKnownContextErrors(t *testing.T) { err := fmt.Errorf("login failed: %w", context.DeadlineExceeded) details := parseLoginErrorDetails(err) diff --git a/pkg/connector/login_qr.go b/pkg/connector/login_qr.go index eb1b1471..b062f2a1 100644 --- a/pkg/connector/login_qr.go +++ b/pkg/connector/login_qr.go @@ -3,6 +3,7 @@ package connector import ( "context" "fmt" + "sync" "time" "maunium.net/go/mautrix/bridgev2" @@ -11,18 +12,23 @@ import ( ) type LineQRLogin struct { - login *LineEmailLogin - client *line.Client - session string - qr *line.QRCodeResponse - poll chan error - pin bool + operationMu sync.Mutex + login *LineEmailLogin + client *line.Client + session string + qr *line.QRCodeResponse + poll chan error + pin bool } var _ bridgev2.LoginProcessDisplayAndWait = (*LineQRLogin)(nil) var _ bridgev2.LoginProcessWithOverride = (*LineQRLogin)(nil) func (lq *LineQRLogin) StartWithOverride(ctx context.Context, override *bridgev2.UserLogin) (*bridgev2.LoginStep, error) { + if !lq.operationMu.TryLock() { + return nil, fmt.Errorf("QR login operation already in progress") + } + defer lq.operationMu.Unlock() meta, ok := override.Metadata.(*UserLoginMetadata) if !ok { return nil, fmt.Errorf("existing LINE login metadata has unexpected type %T", override.Metadata) @@ -32,14 +38,32 @@ func (lq *LineQRLogin) StartWithOverride(ctx context.Context, override *bridgev2 if meta.ForceFullE2EELogin || len(meta.ExportedKeyMap) == 0 { lq.login.Certificate = "" } - return lq.Start(ctx) + return lq.start(ctx) } func (lq *LineQRLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { + if !lq.operationMu.TryLock() { + return nil, fmt.Errorf("QR login operation already in progress") + } + defer lq.operationMu.Unlock() + return lq.start(ctx) +} + +func (lq *LineQRLogin) start(ctx context.Context) (*bridgev2.LoginStep, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := lq.login.loginCooldownError(); err != nil { + return nil, err + } + if err := lq.login.claimLogin(); err != nil { + return nil, err + } ll := lq.login ll.mu.Lock() if ll.canceled || ll.attemptCtx != nil { ll.mu.Unlock() + ll.releaseLogin() return nil, fmt.Errorf("QR login is already started or canceled") } ll.attemptCtx, ll.attemptCancel = context.WithTimeout(context.WithoutCancel(ctx), 10*time.Minute) @@ -49,15 +73,24 @@ func (lq *LineQRLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { defer stop() client := newLineAPIClient("") session, qr, err := client.StartQRLogin(processCtx) - err = wrapLineLoginError(err) ll.mu.Lock() ll.attempt = client.LoginAttempt canceled := ll.canceled || ctx.Err() != nil || processCtx.Err() != nil ll.mu.Unlock() if err != nil || canceled { + // Preserve expiry before Cancel changes the attempt's context error. + if ctx.Err() != nil { + err = ctx.Err() + } else if processCtx.Err() != nil { + err = processCtx.Err() + } else if canceled { + err = context.Canceled + } lq.Cancel() - if canceled { - return nil, context.Canceled + if err != nil { + ll.noteLoginRateLimit(err) + ll.logQRFailure(err, "qr_create") + return nil, wrapQRRPCError(err) } return nil, err } @@ -101,17 +134,25 @@ func (lq *LineQRLogin) startPoll(check func(context.Context, string) error, retr } func (lq *LineQRLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) { + // Overlapping provisioning calls must not consume each other's polling + // results or advance the PIN/token exchange twice. Cancel doesn't take this lock. + if !lq.operationMu.TryLock() { + return nil, fmt.Errorf("QR login operation already in progress") + } + defer lq.operationMu.Unlock() if lq.poll == nil { return nil, fmt.Errorf("QR login has not started") } select { case err := <-lq.poll: if err != nil { + lq.login.noteLoginRateLimit(err) + lq.login.logQRFailure(err, "qr_verify") lq.Cancel() if line.IsQRLoginPollExpired(err) { return nil, fmt.Errorf("%w: %w", ErrLoginVerificationFailed, err) } - return nil, err + return nil, wrapQRRPCError(err) } case <-lq.login.attemptCtx.Done(): lq.Cancel() @@ -123,16 +164,20 @@ func (lq *LineQRLogin) Wait(ctx context.Context) (*bridgev2.LoginStep, error) { if !lq.pin { verified, err := lq.client.VerifyQRCertificate(ctx, lq.session, lq.login.Certificate) if err != nil { + lq.login.noteLoginRateLimit(err) + lq.login.logQRFailure(err, "qr_certificate") lq.Cancel() - return nil, wrapLineLoginError(err) + return nil, wrapQRRPCError(err) } if verified { return lq.finish(ctx) } pin, err := lq.client.CreatePinCode(ctx, lq.session) if err != nil { + lq.login.noteLoginRateLimit(err) + lq.login.logQRFailure(err, "qr_pin") lq.Cancel() - return nil, wrapLineLoginError(err) + return nil, wrapQRRPCError(err) } lq.pin = true lq.startPoll(lq.client.CheckPinCodeVerifiedContext, false) @@ -149,7 +194,9 @@ func (lq *LineQRLogin) finish(ctx context.Context) (*bridgev2.LoginStep, error) defer lq.Cancel() res, err := lq.client.QRCodeLoginV2(ctx, lq.session) if err != nil { - return nil, wrapLineLoginError(err) + lq.login.noteLoginRateLimit(err) + lq.login.logQRFailure(err, "qr_token_issue") + return nil, wrapQRRPCError(err) } return lq.login.finishLogin(ctx, res) } diff --git a/pkg/connector/login_qr_test.go b/pkg/connector/login_qr_test.go new file mode 100644 index 00000000..ec146999 --- /dev/null +++ b/pkg/connector/login_qr_test.go @@ -0,0 +1,203 @@ +package connector + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "sync" + "testing" + "time" + + "maunium.net/go/mautrix/bridgev2" +) + +func TestQRConcurrentWaitIsRejected(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + lq := &LineQRLogin{login: &LineEmailLogin{attemptCtx: ctx, attemptCancel: cancel}, poll: make(chan error, 1)} + results := make(chan error, 2) + for range 2 { + go func() { _, err := lq.Wait(context.Background()); results <- err }() + } + select { + case err := <-results: + if err == nil || !strings.Contains(err.Error(), "already in progress") { + t.Errorf("unexpected concurrent Wait result: %v", err) + } + case <-time.After(time.Second): + t.Error("concurrent Wait calls both blocked instead of rejecting the duplicate") + cancel() + <-results + } + cancel() + select { + case err := <-results: + if !errors.Is(err, context.Canceled) { + t.Errorf("active Wait ignored cancellation: %v", err) + } + case <-time.After(time.Second): + t.Fatal("active Wait did not stop") + } +} + +func TestQRApprovalPINAndTokenValidation(t *testing.T) { + approved, pinApproved := make(chan struct{}), make(chan struct{}) + var mu sync.Mutex + var calls []string + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + method := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:] + mu.Lock() + calls = append(calls, method) + mu.Unlock() + switch method { + case "createSession": + return resolutionResponse(`{"code":0,"data":{"authSessionId":"qr-session"}}`), nil + case "createQrCode": + return resolutionResponse(`{"code":0,"data":{"callbackUrl":"line://au/q/test","longPollingMaxCount":1}}`), nil + case "checkQrCodeVerified", "checkPinCodeVerified": + if req.Header.Get("X-Line-Session-ID") != "qr-session" || req.Header.Get("x-hmac") == "" { + return nil, errors.New("missing QR authentication headers") + } + gate := approved + if method == "checkPinCodeVerified" { + gate = pinApproved + } + select { + case <-gate: + case <-req.Context().Done(): + return nil, req.Context().Err() + } + return resolutionResponse(`{"code":0,"data":{}}`), nil + case "verifyCertificate": + resp := resolutionResponse(`{"code":1,"data":{}}`) + resp.StatusCode = http.StatusBadRequest + return resp, nil + case "createPinCode": + return resolutionResponse(`{"code":0,"data":{"pinCode":"123456"}}`), nil + case "qrCodeLoginV2": + return resolutionResponse(`{"code":0,"data":{"mid":"` + resolutionOwnMID + `","tokenV3IssueResult":{"accessToken":"qr-token","refreshToken":"qr-refresh"},"metaData":{}}}`), nil + case "getProfile": + if req.Header.Get("x-line-access") != "qr-token" { + return nil, errors.New("QR token was not used for verification") + } + return resolutionResponse(`{"code":0,"data":{"mid":"` + resolutionOwnMID + `","displayName":"Owner"}}`), nil + default: + return nil, fmt.Errorf("unexpected QR RPC: %s", method) + } + }) + lq := &LineQRLogin{login: &LineEmailLogin{User: lc.UserLogin.User}} + defer lq.Cancel() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + step, err := lq.Start(ctx) + if err != nil || step.DisplayAndWaitParams.Type != bridgev2.LoginDisplayTypeQR { + t.Fatalf("QR start: %+v %v", step, err) + } + callback, err := url.Parse(step.DisplayAndWaitParams.Data) + if err != nil { + t.Fatal(err) + } + key, err := base64.StdEncoding.DecodeString(callback.Query().Get("secret")) + if err != nil || len(key) != 32 || callback.Query().Get("e2eeVersion") != "1" { + t.Fatal("QR callback missing login key material") + } + close(approved) + step, err = lq.Wait(ctx) + if err != nil || step.DisplayAndWaitParams.Type != bridgev2.LoginDisplayTypeCode || step.DisplayAndWaitParams.Data != "123456" { + t.Fatalf("PIN step: %+v %v", step, err) + } + close(pinApproved) + step, err = lq.Wait(ctx) + if step != nil || !errors.Is(err, ErrLoginNoKeychain) { + t.Fatalf("missing E2EE keychain was accepted: %+v %v", step, err) + } + stored, err := lc.UserLogin.Bridge.DB.UserLogin.GetByID(ctx, lc.UserLogin.ID) + if err != nil || stored.Metadata.(*UserLoginMetadata).AccessToken != "test-token" { + t.Fatalf("incomplete QR login changed stored session: %v", err) + } + mu.Lock() + defer mu.Unlock() + want := "[createSession createQrCode checkQrCodeVerified verifyCertificate createPinCode checkPinCodeVerified qrCodeLoginV2 getProfile]" + if fmt.Sprint(calls) != want { + t.Fatalf("unexpected QR state transitions: %v", calls) + } +} + +func TestQRExpiryAndCancellation(t *testing.T) { + for _, expire := range []bool{false, true} { + t.Run(fmt.Sprint(expire), func(t *testing.T) { + pollStarted, pollStopped := make(chan struct{}), make(chan struct{}) + newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(req.URL.Path, "/createSession"): + return resolutionResponse(`{"code":0,"data":{"authSessionId":"session"}}`), nil + case strings.HasSuffix(req.URL.Path, "/createQrCode"): + return resolutionResponse(`{"code":0,"data":{"callbackUrl":"line://au/q/test","longPollingMaxCount":1}}`), nil + case strings.HasSuffix(req.URL.Path, "/checkQrCodeVerified"): + close(pollStarted) + defer close(pollStopped) + if expire { + return resolutionResponse(`{"code":10052,"data":{"statusCode":410}}`), nil + } + <-req.Context().Done() + return nil, req.Context().Err() + default: + return nil, fmt.Errorf("approval was bypassed: %s", req.URL.Path) + } + }) + lq := &LineQRLogin{login: &LineEmailLogin{}} + defer lq.Cancel() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if _, err := lq.Start(ctx); err != nil { + t.Fatal(err) + } + select { + case <-pollStarted: + case <-ctx.Done(): + t.Fatal("poll did not start") + } + if !expire { + lq.Cancel() + } + step, err := lq.Wait(ctx) + want := error(context.Canceled) + if expire { + want = ErrLoginVerificationFailed + } + if step != nil || !errors.Is(err, want) { + t.Fatalf("unexpected terminal state: %+v %v", step, err) + } + select { + case <-pollStopped: + case <-ctx.Done(): + t.Fatal("poll worker leaked") + } + }) + } +} + +func TestQRStartPreservesDeadline(t *testing.T) { + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + lq := &LineQRLogin{login: &LineEmailLogin{}} + newResolutionClient(t, func(*http.Request) (*http.Response, error) { return nil, ctx.Err() }) + step, err := lq.Start(ctx) + if step != nil || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("start after deadline = %+v, %v; want DeadlineExceeded", step, err) + } +} + +func TestQRPasswordlessReconnectNeedsApproval(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("unexpected passwordless authentication request: %s", req.URL.Path) + }) + lc.UserLogin.Bridge.Network.(*LineConnector).Config.QRLogin = true + if err := lc.tryLogin(context.Background()); !errors.Is(err, errLineQRLoginRequired) { + t.Fatalf("passwordless QR session must request fresh approval: %v", err) + } +} diff --git a/pkg/connector/loginerrors.go b/pkg/connector/loginerrors.go index 2f9bd1fa..bbf2dd2c 100644 --- a/pkg/connector/loginerrors.go +++ b/pkg/connector/loginerrors.go @@ -4,7 +4,10 @@ import ( "context" "errors" "fmt" + "net" "net/http" + "strings" + "time" "maunium.net/go/mautrix" "maunium.net/go/mautrix/bridgev2" @@ -31,7 +34,17 @@ var ( ErrLoginTooManyAttempts = bridgev2.RespError{ ErrCode: "DEV.HIGHEST.LINE.TOO_MANY_ATTEMPTS", Err: loginTooManyAttemptsReason, - StatusCode: http.StatusBadRequest, + StatusCode: http.StatusTooManyRequests, + } + ErrLoginRateLimited = bridgev2.RespError{ + ErrCode: "DEV.HIGHEST.LINE.RATE_LIMITED", + Err: "LINE is limiting how often this account can sign in. Please try again later.", + StatusCode: http.StatusTooManyRequests, + } + ErrLoginInProgress = bridgev2.RespError{ + ErrCode: "DEV.HIGHEST.LINE.LOGIN_IN_PROGRESS", + Err: "A LINE sign-in is already in progress for this account. Continue it or cancel it before starting another.", + StatusCode: http.StatusTooManyRequests, } ErrLoginRejected = bridgev2.RespError{ ErrCode: "DEV.HIGHEST.LINE.LOGIN_REJECTED", @@ -41,30 +54,264 @@ var ( ErrLoginUnknown = bridgev2.RespError(mautrix.MUnknown.WithMessage("Internal error logging in to LINE")) ) -// wrapLineLoginError translates a LINE error into one the client can act on, keeping the -// original in the chain with %w so logs are unaffected. +// verificationTemporarilyUnavailableCooldown is the bridge-side cooldown for +// LINE's "Verification is temporarily unavailable" provider alert. It is a +// bridge policy, not a LINE-provided retry guarantee. +const verificationTemporarilyUnavailableCooldown = 10 * time.Minute + +// intPtr returns a pointer to v when v is non-zero, otherwise nil. +func intPtr(v int) *int { + if v == 0 { + return nil + } + return &v +} + +// safeEmailLoginReason returns a user-facing reason string for known LINE email +// login rejection reasons. It returns the standard rate-limit message for rate +// limits, a fixed message for verification unavailability, and the original +// string only for a small allowlist of safe provider reasons. Unrecognized +// reasons return empty so callers can fall back to generic copy. +func safeEmailLoginReason(reason string) string { + switch strings.ToLower(strings.TrimSpace(reason)) { + case "blocked user", "too many login attempts": + return loginTooManyAttemptsReason + } + safe, status := line.SanitizeProviderMessage(reason) + if status == "recognized" || status == "partially_redacted" || status == "safe_unrecognized" { + return safe + } + return "" +} + +// wrapLineLoginError translates a LINE error into one the client can act on, +// keeping the original in the chain with %w so logs are unaffected. func wrapLineLoginError(err error) error { + return wrapLineLoginErrorAt(err, line.AuthStageEmailLogin) +} + +func wrapLineLoginErrorAt(err error, stage line.AuthErrorStage) error { if err == nil { return nil } - mapped := ErrLoginUnknown details := parseLoginErrorDetails(err) - reason := details.ErrorReason - if reason == "" { - reason = details.ErrorMessage + if details.HTTPStatus == 0 { + details.HTTPStatus = line.HTTPStatusCode(err) + } + reason := providerLoginMessage(details) + safeReason := safeEmailLoginReason(reason) + providerMessage, providerMessageStatus := line.SanitizeProviderMessage(reason) + retryAfter := line.HTTPRetryAfter(err) + var retryAfterPtr *time.Duration + if retryAfter > 0 { + retryAfterPtr = &retryAfter + } + cat := classifyEmailError(details, reason) + var networkErr net.Error + if errors.Is(err, context.DeadlineExceeded) || errors.As(err, &networkErr) { + cat = line.AuthErrorNetworkUnavailable } + + mapped := ErrLoginUnknown switch { case errors.Is(err, line.ErrLoginVerificationExpired): mapped = ErrLoginVerificationFailed - case isBlockedUserLoginError(reason): - mapped = ErrLoginTooManyAttempts - case details.HTTPStatus == http.StatusTooManyRequests: + case cat == line.AuthErrorTooManyAttempts: mapped = ErrLoginTooManyAttempts + case cat == line.AuthErrorRetryLater: + mapped = ErrLoginRateLimited.WithMessage("%s", loginRetryLaterMessage) + case cat == line.AuthErrorVerificationTemporarilyUnavailable: + mapped = ErrLoginRejected.WithMessage("%s", safeReason) + case cat == line.AuthErrorNetworkUnavailable, cat == line.AuthErrorServiceUnavailable: + mapped = ErrLoginRejected.WithMessage("%s", line.UserMessageForCategory(cat)) case details.HTTPStatus == http.StatusUnauthorized, details.HTTPStatus == http.StatusForbidden: mapped = ErrLoginRejected - case reason != "": - // LINE's own reason strings are short and user-facing, so quote them. - mapped = ErrLoginRejected.WithMessage("%s", reason) + case safeReason != "": + mapped = ErrLoginRejected.WithMessage("%s", safeReason) + case details.HTTPStatus >= http.StatusBadRequest && details.HTTPStatus < http.StatusInternalServerError: + mapped = ErrLoginRejected + } + + ae := &line.AuthError{ + Flow: "email", + Stage: stage, + HTTPStatus: details.HTTPStatus, + ResponseCode: intPtr(details.ResponseCode), + ExceptionType: details.ErrorName, + ExceptionCode: intPtr(details.ErrorCode), + Category: cat, + ProviderReason: line.SanitizeProviderAlert(reason), + ProviderAlertStatus: "absent", + ProviderMessage: providerMessage, + ProviderMessageStatus: providerMessageStatus, + RetryAfter: retryAfterPtr, + UserMessage: mapped.Err, + RetryRecommended: isRetryableCategory(cat), + Temporary: isTemporaryCategory(cat), + UserActionRequired: true, + BridgeFailure: false, + CooldownTriggered: cat == line.AuthErrorTooManyAttempts || cat == line.AuthErrorRetryLater || cat == line.AuthErrorVerificationTemporarilyUnavailable, + Err: err, + } + if providerMessageStatus == "recognized" || providerMessageStatus == "partially_redacted" || providerMessageStatus == "safe_unrecognized" { + ae.UserMessage = providerMessage + } + return fmt.Errorf("%w: %w", ae, mapped) +} + +func wrapSessionEstablishError(login *LineEmailLogin, err error) error { + if err == nil { + return nil + } + details := parseLoginErrorDetails(err) + providerMessage, messageStatus := line.SanitizeProviderMessage(providerLoginMessage(details)) + category := line.AuthErrorUnknown + var networkErr net.Error + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || errors.As(err, &networkErr) { + category = line.AuthErrorNetworkUnavailable + } else if details.HTTPStatus >= http.StatusInternalServerError { + category = line.AuthErrorServiceUnavailable + } + flow := "email" + if login != nil && login.authFlow != "" { + flow = login.authFlow + } + userMessage := "LINE accepted sign-in, but the bridge could not verify the new session. Please try again." + if messageStatus == "recognized" || messageStatus == "partially_redacted" || messageStatus == "safe_unrecognized" { + userMessage = providerMessage + } + authErr := &line.AuthError{ + Flow: flow, Stage: line.AuthStageSessionEstablish, HTTPStatus: details.HTTPStatus, + ResponseCode: intPtr(details.ResponseCode), ExceptionType: details.ErrorName, + ExceptionCode: intPtr(details.ErrorCode), Category: category, + ProviderReason: line.SanitizeProviderAlert(providerLoginMessage(details)), + ProviderMessage: providerMessage, ProviderMessageStatus: messageStatus, + UserMessage: userMessage, RetryRecommended: category == line.AuthErrorNetworkUnavailable || category == line.AuthErrorServiceUnavailable, + Temporary: category == line.AuthErrorNetworkUnavailable || category == line.AuthErrorServiceUnavailable, + UserActionRequired: true, Err: err, + } + return fmt.Errorf("%w: %w", authErr, bridgev2.RespError(mautrix.MUnknown.WithMessage("%s", userMessage))) +} + +// classifyEmailError classifies an email/login error into an AuthErrorCategory. +func classifyEmailError(details loginErrorDetails, reason string) line.AuthErrorCategory { + safeCategory := line.SanitizeProviderAlert(reason) + switch { + case safeCategory == "blocked_user": + return line.AuthErrorTooManyAttempts + case safeCategory == "too_many_login_attempts": + return line.AuthErrorTooManyAttempts + case strings.EqualFold(strings.TrimSpace(reason), "retry later"), strings.EqualFold(strings.TrimSpace(reason), "RETRY_LATER"): + return line.AuthErrorRetryLater + case details.HTTPStatus == http.StatusTooManyRequests: + return line.AuthErrorTooManyAttempts + case strings.EqualFold(strings.TrimSpace(reason), "authentication failed"): + return line.AuthErrorInvalidCredentials + case strings.EqualFold(strings.TrimSpace(reason), "account id or password is invalid"): + return line.AuthErrorInvalidCredentials + case safeCategory == "verification_temporarily_unavailable": + return line.AuthErrorVerificationTemporarilyUnavailable + case details.HTTPStatus == http.StatusUnauthorized, details.HTTPStatus == http.StatusForbidden: + return line.AuthErrorInvalidCredentials + case details.HTTPStatus >= http.StatusInternalServerError: + return line.AuthErrorServiceUnavailable + default: + return line.AuthErrorUnknown + } +} + +func providerLoginMessage(details loginErrorDetails) string { + reason := details.ErrorReason + if _, status := line.SanitizeProviderMessage(reason); status == "absent" || status == "suppressed" { + return details.ErrorMessage + } + return reason +} + +// isRetryableCategory returns true if the category suggests a retry may succeed. +func isRetryableCategory(cat line.AuthErrorCategory) bool { + switch cat { + case line.AuthErrorTooManyAttempts, line.AuthErrorRetryLater, + line.AuthErrorVerificationTemporarilyUnavailable, line.AuthErrorServiceUnavailable, + line.AuthErrorNetworkUnavailable: + return true + default: + return false + } +} + +// isTemporaryCategory returns true if the category is likely temporary. +func isTemporaryCategory(cat line.AuthErrorCategory) bool { + switch cat { + case line.AuthErrorTooManyAttempts, line.AuthErrorRetryLater, + line.AuthErrorVerificationTemporarilyUnavailable, line.AuthErrorServiceUnavailable: + return true + default: + return false + } +} + +// qrLoginFailureMessage maps a rejected QR request to fixed user-facing copy. +// The mobile app shows no detail for these rejections, so the bridge is the +// only place the reason can be shown. Provider text is never quoted; only fixed +// schema labels are used. +func qrLoginFailureMessage(detail line.QRFailureDetail) string { + switch detail.ExceptionLabel { + case "NOT_ALLOWED_QR_CODE_LOGIN": + return "LINE doesn't allow QR sign-in for this account. Please sign in with your email and password instead." + case "INVALID_CONTEXT": + return "LINE rejected the QR sign-in because the sign-in context was no longer valid. Please start the sign-in again with a fresh QR code." + case "VERIFICATION_FAILED": + return "LINE didn't confirm the QR sign-in. Please scan the QR code again and try once more." + case "VERIFICATION_NOTICE_FAILED": + return "LINE didn't confirm the QR sign-in request. Please try again." + case "APP_UPGRADE_REQUIRED": + return "LINE requires a newer version of the app. Please update the LINE app and try again." + case "ILLEGAL_ARGUMENT": + return "LINE rejected the QR sign-in request. Please start the sign-in again." + } + return "LINE rejected the QR sign-in. Please try again." +} + +// wrapQRRPCError translates a rejected QR request into a user-facing error. +// The original error stays in the chain with %w so logs are unaffected. +// It now preserves the structured AuthError for caller inspection. +func wrapQRRPCError(err error) error { + if err == nil { + return nil + } + detail, ok := line.QRLoginFailureDetail(err) + if !ok { + return err + } + + // Extract the structured AuthError from the underlying QRRPCError, if + // available, to preserve the provider classification and safe alert status. + var qrErr *line.QRRPCError + if errors.As(err, &qrErr) && qrErr.AuthError() != nil { + ae := qrErr.AuthError() + var mapped bridgev2.RespError + switch ae.Category { + case line.AuthErrorTooManyAttempts: + mapped = ErrLoginTooManyAttempts + case line.AuthErrorRetryLater: + mapped = ErrLoginRateLimited.WithMessage("%s", loginRetryLaterMessage) + case line.AuthErrorVerificationTemporarilyUnavailable: + mapped = ErrLoginRejected.WithMessage("%s", ae.UserMessage) + default: + mapped = ErrLoginRejected.WithMessage("%s", qrLoginFailureMessage(detail)) + } + return fmt.Errorf("%w: %w", ae, mapped) + } + + var mapped bridgev2.RespError + switch classifyLoginRateLimit(err) { + case loginRateLimitLocked: + mapped = ErrLoginTooManyAttempts + case loginRateLimitRetryLater: + mapped = ErrLoginRateLimited.WithMessage("%s", loginRetryLaterMessage) + default: + mapped = ErrLoginRejected.WithMessage("%s", qrLoginFailureMessage(detail)) } return fmt.Errorf("%w: %w", mapped, err) } diff --git a/pkg/connector/media.go b/pkg/connector/media.go index 482236d0..b91fee16 100644 --- a/pkg/connector/media.go +++ b/pkg/connector/media.go @@ -144,6 +144,16 @@ func stripJPEGMetadata(data []byte) []byte { // LINE's E2EE file format: [encrypted_data][32-byte HMAC] // The keyMaterial is derived using HKDF to get encKey (32), macKey (32), and nonce (12 bytes) func (lc *LineClient) decryptImageData(encryptedData []byte, keyMaterialB64 string) ([]byte, error) { + return lc.decryptMediaData(encryptedData, keyMaterialB64, func(ciphertext []byte) []byte { + return ciphertext + }) +} + +func (lc *LineClient) decryptVideoData(encryptedData []byte, keyMaterialB64 string) ([]byte, error) { + return lc.decryptMediaData(encryptedData, keyMaterialB64, generateChunkHashes) +} + +func (lc *LineClient) decryptMediaData(encryptedData []byte, keyMaterialB64 string, macInput func([]byte) []byte) ([]byte, error) { keyMaterial, err := base64.StdEncoding.DecodeString(keyMaterialB64) if err != nil { return nil, fmt.Errorf("failed to decode key material: %w", err) @@ -158,7 +168,7 @@ func (lc *LineClient) decryptImageData(encryptedData []byte, keyMaterialB64 stri } encKey := derived[0:32] - // macKey := derived[32:64] // for HMAC verification + macKey := derived[32:64] nonce := derived[64:76] // Create 16-byte counter: nonce(12 bytes) + zero counter(4 bytes) @@ -169,7 +179,14 @@ func (lc *LineClient) decryptImageData(encryptedData []byte, keyMaterialB64 stri if len(encryptedData) < 32 { return nil, fmt.Errorf("encrypted data too short (< 32 bytes for HMAC)") } - encryptedData = encryptedData[:len(encryptedData)-32] + ciphertext := encryptedData[:len(encryptedData)-32] + receivedMAC := encryptedData[len(encryptedData)-32:] + + h := hmac.New(sha256.New, macKey) + h.Write(macInput(ciphertext)) + if !hmac.Equal(receivedMAC, h.Sum(nil)) { + return nil, fmt.Errorf("media HMAC verification failed") + } block, err := aes.NewCipher(encKey) if err != nil { @@ -178,8 +195,8 @@ func (lc *LineClient) decryptImageData(encryptedData []byte, keyMaterialB64 stri stream := cipher.NewCTR(block, counter) - decrypted := make([]byte, len(encryptedData)) - stream.XORKeyStream(decrypted, encryptedData) + decrypted := make([]byte, len(ciphertext)) + stream.XORKeyStream(decrypted, ciphertext) return decrypted, nil } diff --git a/pkg/connector/media_hmac_test.go b/pkg/connector/media_hmac_test.go new file mode 100644 index 00000000..57fa7425 --- /dev/null +++ b/pkg/connector/media_hmac_test.go @@ -0,0 +1,48 @@ +package connector + +import ( + "bytes" + "testing" +) + +// Adapted from beeper/line#196. +func TestMediaHMAC(t *testing.T) { + lc := &LineClient{} + for _, kind := range []string{"image", "file", "audio", "video", "thumbnail"} { + t.Run(kind, func(t *testing.T) { + plaintext := bytes.Repeat([]byte("authenticated media"), 40000) + encrypt := lc.encryptFileData + if kind == "video" { + encrypt = lc.encryptVideoData + } + ciphertext, key, err := encrypt(plaintext) + if err != nil { + t.Fatal(err) + } + if kind == "thumbnail" { + ciphertext, err = encryptThumbnail(plaintext, key) + if err != nil { + t.Fatal(err) + } + } + decrypt, wrongMode := lc.decryptImageData, lc.decryptVideoData + if kind == "video" { + decrypt, wrongMode = wrongMode, decrypt + } + decrypted, err := decrypt(ciphertext, key) + if err != nil || !bytes.Equal(decrypted, plaintext) { + t.Fatalf("valid media: err=%v, equal=%v", err, bytes.Equal(decrypted, plaintext)) + } + for _, offset := range []int{0, len(ciphertext) - 1} { + tampered := bytes.Clone(ciphertext) + tampered[offset] ^= 1 + if got, err := decrypt(tampered, key); err == nil || got != nil { + t.Fatalf("tampering at %d returned plaintext or no error", offset) + } + } + if got, err := wrongMode(ciphertext, key); err == nil || got != nil { + t.Fatal("accepted incorrect HMAC mode") + } + }) + } +} diff --git a/pkg/connector/opaque_mid_test.go b/pkg/connector/opaque_mid_test.go new file mode 100644 index 00000000..2852dd3d --- /dev/null +++ b/pkg/connector/opaque_mid_test.go @@ -0,0 +1,123 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2/provisionutil" + + "github.com/beeper/line/pkg/line" +) + +// Invented values matching the observed 44-byte U + URL-safe alphabet shape. +// Live evidence verified exact public lookup -> getContactsV2 identity roundtrip; +// these fixtures contain no production identifiers or profile data. +const opaqueMID = "U" + "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-aDEFg" + +func TestOpaqueMIDPublicSearchAndPortalReuse(t *testing.T) { + calls := 0 + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + calls++ + if !strings.HasSuffix(req.URL.Path, "/findContactByUserid") { + t.Fatalf("unexpected fallback or sync: %s", req.URL.Path) + } + return resolutionResponse(`{"code":0,"data":{"mid":"` + opaqueMID + `","displayName":"Fixture"}}`), nil + }) + ctx := context.Background() + var logs bytes.Buffer + lc.UserLogin.Bridge.Log = zerolog.New(&logs) + ctx = lc.UserLogin.Bridge.Log.WithContext(ctx) + search, err := lc.SearchUsers(ctx, "fixture.id") + if err != nil || len(search) != 1 || string(search[0].UserID) != opaqueMID { + t.Fatalf("search=%+v err=%v", search, err) + } + assertPortalCount(t, lc, 0) + first, err := provisionutil.ResolveIdentifier(ctx, lc.UserLogin, opaqueMID, true) + if err != nil { + t.Fatalf("%v: %s", err, logs.String()) + } + second, err := provisionutil.ResolveIdentifier(ctx, lc.UserLogin, "fixture.id", true) + if err != nil || second.DMRoomID != first.DMRoomID || second.JustCreated { + t.Fatalf("portal identity changed: %+v %v", second, err) + } + assertPortalCount(t, lc, 1) + if calls != 2 || lc.UserLogin.Bridge.Matrix.(*resolutionMatrix).created != 1 { + t.Fatalf("unexpected calls=%d or duplicate room", calls) + } +} + +func TestOpaqueMIDExactIdentity(t *testing.T) { + for _, mismatch := range []bool{false, true} { + t.Run(map[bool]string{false: "unchanged", true: "case mismatch"}[mismatch], func(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + if !strings.HasSuffix(req.URL.Path, "/getContactsV2") { + t.Fatalf("wrong endpoint: %s", req.URL.Path) + } + var args []json.RawMessage + if err := json.NewDecoder(req.Body).Decode(&args); err != nil || !strings.Contains(string(args[0]), opaqueMID) { + t.Fatalf("request changed opaque identity: %s", args) + } + mid := opaqueMID + if mismatch { + mid = "U" + strings.ToLower(mid[1:]) + } + return resolutionResponse(`{"code":0,"data":{"contacts":{"` + opaqueMID + `":{"contact":{"mid":"` + mid + `"}}}}}`), nil + }) + result, err := lc.ResolveIdentifier(context.Background(), " "+opaqueMID+" ", true) + if mismatch { + if err == nil || result != nil { + t.Fatal("case-altered identity accepted") + } + } else if err != nil || result == nil || string(result.UserID) != opaqueMID { + t.Fatalf("result=%+v err=%v", result, err) + } + assertPortalCount(t, lc, 0) + }) + } +} + +func TestOpaqueMIDFallbackIdentity(t *testing.T) { + other := "U" + strings.ToLower(opaqueMID[1:]) + for _, tc := range []struct { + name, key, mid string + wantErr bool + }{ + {"valid", opaqueMID, opaqueMID, false}, + {"case mismatched body", opaqueMID, other, true}, + {"case mismatched key", other, opaqueMID, true}, + {"unsolicited case variant", other, other, true}, + } { + t.Run(tc.name, func(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(req.URL.Path, "/findContactByUserid"): + return resolutionResponse(`{"code":0,"data":{}}`), nil + case strings.HasSuffix(req.URL.Path, "/getAllContactIds"): + return resolutionResponse(`{"code":0,"data":["` + opaqueMID + `"]}`), nil + case strings.HasSuffix(req.URL.Path, "/getContactsV2"): + body, _ := json.Marshal(map[string]any{"code": 0, "data": line.ContactsResponse{Contacts: map[string]line.ContactWrapper{tc.key: {Contact: line.Contact{Mid: tc.mid, DisplayName: "Fixture"}}}}}) + return resolutionResponse(string(body)), nil + default: + t.Fatalf("unexpected RPC: %s", req.URL.Path) + return nil, nil + } + }) + results, err := lc.SearchUsers(context.Background(), "Fixture") + if (err != nil) != tc.wantErr { + t.Fatalf("results=%+v err=%v", results, err) + } + if !tc.wantErr && (len(results) != 1 || string(results[0].UserID) != opaqueMID) { + t.Fatalf("opaque fallback identity changed: %+v", results) + } + if tc.wantErr && len(results) != 0 { + t.Fatal("invalid fallback accepted") + } + assertPortalCount(t, lc, 0) + }) + } +} diff --git a/pkg/connector/outbound_pipeline_test.go b/pkg/connector/outbound_pipeline_test.go new file mode 100644 index 00000000..2970f5fc --- /dev/null +++ b/pkg/connector/outbound_pipeline_test.go @@ -0,0 +1,119 @@ +package connector + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" + + "github.com/beeper/line/pkg/line" +) + +func outboundTestMessage(t *testing.T, lc *LineClient) *bridgev2.MatrixMessage { + t.Helper() + portal, err := lc.UserLogin.Bridge.GetPortalByKey(context.Background(), networkid.PortalKey{ID: resolutionMID, Receiver: lc.UserLogin.ID}) + if err != nil { + t.Fatal(err) + } + return &bridgev2.MatrixMessage{MatrixEventBase: bridgev2.MatrixEventBase[*event.MessageEventContent]{ + Portal: portal, + Event: &event.Event{ID: "$test-outbound", Sender: lc.UserLogin.UserMXID, RoomID: "!test:example.com"}, + Content: &event.MessageEventContent{MsgType: event.MsgText, Body: "test message"}, + }} +} + +func TestOutboundOwnerAndCanonicalRecipient(t *testing.T) { + for _, fail := range []bool{false, true} { + t.Run(fmt.Sprint(fail), func(t *testing.T) { + requests := 0 + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + requests++ + if !strings.HasSuffix(req.URL.Path, "/sendMessage") { + return nil, fmt.Errorf("unexpected endpoint: %s", req.URL.Path) + } + var args []json.RawMessage + if err := json.NewDecoder(req.Body).Decode(&args); err != nil || len(args) != 2 { + t.Fatalf("invalid send args: %v %v", args, err) + } + var sent line.Message + if err := json.Unmarshal(args[1], &sent); err != nil { + t.Fatal(err) + } + if sent.To != resolutionMID || sent.From != resolutionOwnMID || sent.Text != "test message" { + t.Fatalf("misrouted/incorrect message: %+v", sent) + } + if fail { + return resolutionResponse(`{"code":42,"message":"send rejected"}`), nil + } + return resolutionResponse(`{"code":0,"data":{"id":"line-message-id"}}`), nil + }) + msg := outboundTestMessage(t, lc) + login, _, err := msg.Portal.FindPreferredLogin(context.Background(), lc.UserLogin.User, true) + if err != nil || login != lc.UserLogin { + t.Fatalf("owner login association: %v", err) + } + other := &bridgev2.User{User: &database.User{MXID: "@other:example.com"}, Bridge: lc.UserLogin.Bridge} + if _, _, err = msg.Portal.FindPreferredLogin(context.Background(), other, true); !errors.Is(err, bridgev2.ErrNotLoggedIn) { + t.Fatalf("unrelated sender was allowed to use owner's login: %v", err) + } + resp, err := lc.HandleMatrixMessage(context.Background(), msg) + if fail { + if err == nil || resp != nil { + t.Fatalf("rejected send reported success: %+v %v", resp, err) + } + } else if err != nil || resp == nil || resp.DB.ID != "line-message-id" { + t.Fatalf("successful send lost LINE acknowledgment: %+v %v", resp, err) + } + if requests != 1 { + t.Fatalf("send RPCs=%d", requests) + } + }) + } +} + +func TestOutboundRequestCancellation(t *testing.T) { + started, release := make(chan struct{}), make(chan struct{}) + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + close(started) + select { + case <-req.Context().Done(): + return nil, req.Context().Err() + case <-release: + return nil, context.Canceled + } + }) + msg := outboundTestMessage(t, lc) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { _, err := lc.HandleMatrixMessage(ctx, msg); done <- err }() + select { + case <-started: + case <-time.After(30 * time.Second): + cancel() + close(release) + <-done + t.Fatal("send did not start") + } + cancel() + select { + case err := <-done: + close(release) + if !errors.Is(err, context.Canceled) { + t.Fatalf("send returned %v", err) + } + case <-time.After(time.Second): + close(release) + <-done + t.Fatal("LINE send did not inherit Matrix request cancellation") + } +} diff --git a/pkg/connector/qr_failure_test.go b/pkg/connector/qr_failure_test.go new file mode 100644 index 00000000..163c6e4a --- /dev/null +++ b/pkg/connector/qr_failure_test.go @@ -0,0 +1,209 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" + + "maunium.net/go/mautrix/bridgev2" + + "github.com/beeper/line/pkg/line" +) + +func TestQRRejectionSurfacesFixedReasonWithoutProviderText(t *testing.T) { + for _, tc := range []struct { + name, body, wantMessage, wantLabel string + wantCode int + }{ + { + name: "invalid context", + body: `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":100,"alertMessage":"PRIVATE-ALERT"}}`, + wantMessage: "LINE rejected the QR sign-in because the sign-in context was no longer valid. Please start the sign-in again with a fresh QR code.", + wantLabel: "INVALID_CONTEXT", + wantCode: 100, + }, + { + name: "retry later", + body: `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":5}}`, + wantMessage: loginRetryLaterMessage, + wantLabel: "RETRY_LATER", + wantCode: 5, + }, + { + name: "not allowed", + body: `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":3}}`, + wantMessage: "LINE doesn't allow QR sign-in for this account. Please sign in with your email and password instead.", + wantLabel: "NOT_ALLOWED_QR_CODE_LOGIN", + wantCode: 3, + }, + { + name: "unknown class", + body: `{"code":10051,"data":{"name":"PRIVATE-CLASS","code":100,"reason":"PRIVATE-REASON"}}`, + wantMessage: "LINE rejected the QR sign-in. Please try again.", + wantCode: 100, + }, + } { + t.Run(tc.name, func(t *testing.T) { + var output bytes.Buffer + logger := zerolog.New(&output) + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + method := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:] + switch method { + case "createSession": + return resolutionResponse(`{"code":0,"data":{"authSessionId":"qr-session"}}`), nil + case "createQrCode": + return resolutionResponse(`{"code":0,"data":{"callbackUrl":"line://au/q/test","longPollingMaxCount":1}}`), nil + case "checkQrCodeVerified": + return resolutionResponse(`{"code":0,"data":{}}`), nil + case "verifyCertificate": + resp := resolutionResponse(`{"code":1,"data":{}}`) + resp.StatusCode = http.StatusBadRequest + return resp, nil + case "createPinCode": + return resolutionResponse(`{"code":0,"data":{"pinCode":"123456"}}`), nil + case "checkPinCodeVerified": + return resolutionResponse(`{"code":0,"data":{}}`), nil + case "qrCodeLoginV2": + resp := resolutionResponse(tc.body) + resp.StatusCode = http.StatusBadRequest + return resp, nil + default: + return nil, fmt.Errorf("unexpected QR RPC: %s", method) + } + }) + lc.UserLogin.User.Log = logger + lq := &LineQRLogin{login: &LineEmailLogin{User: lc.UserLogin.User}} + defer lq.Cancel() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if _, err := lq.Start(ctx); err != nil { + t.Fatal(err) + } + if _, err := lq.Wait(ctx); err != nil { + t.Fatal(err) + } + step, err := lq.Wait(ctx) + if step != nil { + t.Fatalf("rejected QR login returned a step: %+v", step) + } + var respErr bridgev2.RespError + if !errors.As(err, &respErr) { + t.Fatalf("QR rejection is not a user-facing error: %v", err) + } + if respErr.Err != tc.wantMessage { + t.Fatalf("message = %q, want %q", respErr.Err, tc.wantMessage) + } + for _, secret := range []string{"PRIVATE-ALERT", "PRIVATE-CLASS", "PRIVATE-REASON"} { + if strings.Contains(respErr.Err, secret) || strings.Contains(err.Error(), secret) { + t.Fatalf("provider text leaked into the user-facing error: %v", err) + } + } + var event map[string]any + if err := json.Unmarshal(bytes.TrimSpace(output.Bytes()), &event); err != nil { + t.Fatalf("failed to parse log event: %v\n%s", err, output.String()) + } + if event["message"] != "LINE QR login attempt failed" || event["login_flow"] != "qr_token_issue" { + t.Fatalf("unexpected log event: %v", event) + } + if event["qr_method"] != "qrCodeLoginV2" || event["line_error_code"] != float64(tc.wantCode) { + t.Fatalf("missing sanitized QR details: %v", event) + } + if tc.wantLabel == "" { + if _, ok := event["line_error_label"]; ok { + t.Fatalf("unknown class received a code label: %v", event) + } + } else if event["line_error_label"] != tc.wantLabel { + t.Fatalf("label = %v, want %q", event["line_error_label"], tc.wantLabel) + } + }) + } +} + +func TestQRFailureDetailIsNotBuiltFromUnrelatedErrors(t *testing.T) { + if detail, ok := line.QRLoginFailureDetail(errors.New("unrelated")); ok || detail.Method != "" { + t.Fatal("unrelated error was reported as a QR failure") + } +} + +func TestQRVerificationTemporarilyUnavailable(t *testing.T) { + var output bytes.Buffer + logger := zerolog.New(&output) + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + method := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:] + switch method { + case "createSession": + return resolutionResponse(`{"code":0,"data":{"authSessionId":"qr-session"}}`), nil + case "createQrCode": + return resolutionResponse(`{"code":0,"data":{"callbackUrl":"line://au/q/test","longPollingMaxCount":1}}`), nil + case "checkQrCodeVerified": + return resolutionResponse(`{"code":0,"data":{}}`), nil + case "verifyCertificate": + resp := resolutionResponse(`{"code":1,"data":{}}`) + resp.StatusCode = http.StatusBadRequest + return resp, nil + case "createPinCode": + return resolutionResponse(`{"code":0,"data":{"pinCode":"123456"}}`), nil + case "checkPinCodeVerified": + return resolutionResponse(`{"code":0,"data":{}}`), nil + case "qrCodeLoginV2": + resp := resolutionResponse("{\"code\":10051,\"data\":{\"name\":\"SecondaryQrCodeException\",\"code\":100,\"alertMessage\":\"Verification is temporarily unavailable.\\nPlease try again later.\"}}") + resp.StatusCode = http.StatusBadRequest + return resp, nil + default: + return nil, fmt.Errorf("unexpected QR RPC: %s", method) + } + }) + lc.UserLogin.User.Log = logger + lq := &LineQRLogin{login: &LineEmailLogin{User: lc.UserLogin.User}} + defer lq.Cancel() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if _, err := lq.Start(ctx); err != nil { + t.Fatal(err) + } + if _, err := lq.Wait(ctx); err != nil { + t.Fatal(err) + } + _, err := lq.Wait(ctx) + if err == nil { + t.Fatal("verification unavailable QR login was accepted") + } + var respErr bridgev2.RespError + if !errors.As(err, &respErr) { + t.Fatalf("QR rejection is not a user-facing error: %v", err) + } + if !strings.Contains(respErr.Err, "Verification is temporarily unavailable.\nPlease try again later.") { + t.Fatalf("meaningful LINE verification message was lost: %q", respErr.Err) + } + if strings.Contains(strings.ToLower(respErr.Err), "qr sign-in has expired") { + t.Fatalf("verification restriction was misclassified as QR expiration: %q", respErr.Err) + } + + var event map[string]any + if err := json.Unmarshal(bytes.TrimSpace(output.Bytes()), &event); err != nil { + t.Fatalf("failed to parse log event: %v\n%s", err, output.String()) + } + if event["auth_error_category"] != "VERIFICATION_TEMPORARILY_UNAVAILABLE" { + t.Fatalf("auth_error_category = %v, want VERIFICATION_TEMPORARILY_UNAVAILABLE", event["auth_error_category"]) + } + if event["provider_alert_status"] != "recognized" { + t.Fatalf("provider_alert_status = %v, want recognized", event["provider_alert_status"]) + } + if event["cooldown_triggered"] != true { + t.Fatalf("cooldown_triggered = %v, want true", event["cooldown_triggered"]) + } + if event["retry_recommended"] != true { + t.Fatalf("retry_recommended = %v, want true", event["retry_recommended"]) + } + if event["temporary"] != true { + t.Fatalf("temporary = %v, want true", event["temporary"]) + } +} diff --git a/pkg/connector/resolve_identifier.go b/pkg/connector/resolve_identifier.go new file mode 100644 index 00000000..d5b10d83 --- /dev/null +++ b/pkg/connector/resolve_identifier.go @@ -0,0 +1,101 @@ +package connector + +import ( + "context" + "fmt" + "strings" + "time" + "unicode" + + "github.com/beeper/line/pkg/line" +) + +func isCanonicalUserMID(identifier string) bool { + normalized, valid := line.NormalizeUserMID(identifier) + return valid && normalized == identifier +} + +func normalizeUserMID(identifier string) string { + normalized, _ := line.NormalizeUserMID(identifier) + return normalized +} + +// resolveContactIdentifier never matches display names or creates portals. +// Public IDs are resolved afresh: caching an ID->MID mapping could route a +// reassigned public ID to the wrong recipient. Contact data is cached by MID. +func (lc *LineClient) resolveContactIdentifier(ctx context.Context, identifier string) (*line.Contact, error) { + if err := lc.checkLineCall(ctx); err != nil { + return nil, err + } + identifier = strings.TrimSpace(identifier) + if normalized, valid := line.NormalizeUserMID(identifier); valid { + identifier = normalized + } else { + identifier = strings.ToLower(identifier) + } + if identifier == "" || strings.ContainsFunc(identifier, unicode.IsSpace) || strings.ContainsFunc(identifier, unicode.IsControl) { + return nil, fmt.Errorf("provide a LINE public ID or user MID, not a display name") + } + var contact *line.Contact + var err error + if isCanonicalUserMID(identifier) { + lc.cacheMu.Lock() + cached, ok := lc.contactCache[identifier] + lc.cacheMu.Unlock() + if ok && cached.Contact.Mid == identifier && time.Since(cached.cachedAt) < contactCacheTTL { + return &cached.Contact, nil + } + if lc.isOwnMID(identifier) { + _, profile, lookupErr := callLineResult(lc, ctx, func(client *line.Client) (*line.Profile, error) { + return client.GetProfileContext(ctx) + }) + err = lookupErr + if profile != nil { + contact = &line.Contact{Mid: profile.Mid, DisplayName: profile.DisplayName, PicturePath: profile.PicturePath} + } + } else { + _, contacts, lookupErr := callLineResult(lc, ctx, func(client *line.Client) (*line.ContactsResponse, error) { + return client.GetContactsV2Context(ctx, []string{identifier}) + }) + err = lookupErr + if contacts != nil { + for key, wrapper := range contacts.Contacts { + if normalizeUserMID(key) == identifier { + if contact != nil { + return nil, fmt.Errorf("LINE lookup returned ambiguous user MID entries") + } + contact = &wrapper.Contact + } + } + } + if err == nil && contact == nil { + _, buddy, buddyErr := callLineResult(lc, ctx, func(client *line.Client) (*line.BuddyProfile, error) { + return client.GetBuddyProfileContext(ctx, identifier) + }) + err = buddyErr + if buddy != nil && buddy.Mid != "" { + contact = &line.Contact{Mid: buddy.Mid, DisplayName: buddy.DisplayName, PicturePath: buddy.PicturePath} + } + } + } + } else { + _, contact, err = callLineResult(lc, ctx, func(client *line.Client) (*line.Contact, error) { + return client.FindContactByUseridContext(ctx, identifier) + }) + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + if err != nil { + return nil, fmt.Errorf("LINE identifier lookup failed (the account may restrict ID lookup): %w", err) + } + if contact == nil || contact.Mid == "" { + return nil, nil + } + contact.Mid = normalizeUserMID(contact.Mid) + if !isCanonicalUserMID(contact.Mid) || isCanonicalUserMID(identifier) && contact.Mid != identifier { + return nil, fmt.Errorf("LINE lookup returned an invalid or mismatched user MID") + } + lc.setCachedContact(contact.Mid, *contact) + return contact, nil +} diff --git a/pkg/connector/resolve_identifier_test.go b/pkg/connector/resolve_identifier_test.go new file mode 100644 index 00000000..c07fef65 --- /dev/null +++ b/pkg/connector/resolve_identifier_test.go @@ -0,0 +1,473 @@ +package connector + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/rs/zerolog" + "go.mau.fi/util/dbutil" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/bridgeconfig" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/bridgev2/provisionutil" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "github.com/beeper/line/pkg/line" +) + +const resolutionMID = "u0123456789abcdef0123456789abcdef" +const resolutionOwnMID = "ufedcba9876543210fedcba9876543210" + +type resolutionTransport func(*http.Request) (*http.Response, error) + +func (f resolutionTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +type resolutionMatrix struct { + bridgev2.MatrixConnector + mu sync.Mutex + created int + failCreate bool + ownerLeft bool + deleted int +} + +func (*resolutionMatrix) Init(*bridgev2.Bridge) {} +func (m *resolutionMatrix) NewUserIntent(_ context.Context, userID id.UserID, accessToken string) (bridgev2.MatrixAPI, string, error) { + return &resolutionIntent{mxid: userID, matrix: m}, accessToken, nil +} +func (m *resolutionMatrix) BotIntent() bridgev2.MatrixAPI { + return &resolutionIntent{mxid: "@bot:example.com", matrix: m} +} +func (m *resolutionMatrix) GhostIntent(userID networkid.UserID) bridgev2.MatrixAPI { + return &resolutionIntent{mxid: id.UserID("@line_" + userID + ":example.com"), matrix: m} +} +func (*resolutionMatrix) GetCapabilities() *bridgev2.MatrixCapabilities { + return &bridgev2.MatrixCapabilities{AutoJoinInvites: true} +} +func (*resolutionMatrix) ParseGhostMXID(id.UserID) (networkid.UserID, bool) { return "", false } +func (*resolutionMatrix) GenerateDeterministicRoomID(networkid.PortalKey) id.RoomID { return "" } +func (m *resolutionMatrix) GetMembers(context.Context, id.RoomID) (map[id.UserID]*event.MemberEventContent, error) { + m.mu.Lock() + defer m.mu.Unlock() + membership := event.MembershipJoin + if m.ownerLeft { + membership = event.MembershipLeave + } + return map[id.UserID]*event.MemberEventContent{"@owner:example.com": {Membership: membership}}, nil +} + +type resolutionIntent struct { + bridgev2.MatrixAPI + mxid id.UserID + matrix *resolutionMatrix +} + +func (i *resolutionIntent) GetMXID() id.UserID { return i.mxid } +func (*resolutionIntent) IsDoublePuppet() bool { return false } +func (*resolutionIntent) SetDisplayName(context.Context, string) error { return nil } +func (*resolutionIntent) SetAvatarURL(context.Context, id.ContentURIString) error { return nil } +func (*resolutionIntent) SetExtraProfileMeta(context.Context, any) error { return nil } +func (*resolutionIntent) EnsureInvited(context.Context, id.RoomID, id.UserID) error { return nil } +func (*resolutionIntent) EnsureJoined(context.Context, id.RoomID, ...bridgev2.EnsureJoinedParams) error { + return nil +} +func (*resolutionIntent) SendState(context.Context, id.RoomID, event.Type, string, *event.Content, time.Time) (*mautrix.RespSendEvent, error) { + return &mautrix.RespSendEvent{EventID: "$state"}, nil +} +func (i *resolutionIntent) CreateRoom(ctx context.Context, req *mautrix.ReqCreateRoom) (id.RoomID, error) { + i.matrix.mu.Lock() + defer i.matrix.mu.Unlock() + if i.matrix.failCreate { + return "", errors.New("injected Matrix create failure") + } + i.matrix.created++ + return id.RoomID(fmt.Sprintf("!created%d:example.com", i.matrix.created)), nil +} + +func (i *resolutionIntent) DeleteRoom(context.Context, id.RoomID, bool) error { + i.matrix.mu.Lock() + defer i.matrix.mu.Unlock() + i.matrix.deleted++ + return nil +} + +func newResolutionClient(t *testing.T, transport resolutionTransport) *LineClient { + t.Helper() + db, err := dbutil.NewWithDialect("file:"+filepath.ToSlash(filepath.Join(t.TempDir(), "bridge.db")), "sqlite3") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + connector := &LineConnector{} + br := bridgev2.NewBridge("test", db, zerolog.New(io.Discard), &bridgeconfig.BridgeConfig{SplitPortals: true}, &resolutionMatrix{}, connector, func(*bridgev2.Bridge) bridgev2.CommandProcessor { return nil }) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + br.BackgroundCtx = ctx + if err = br.DB.Upgrade(ctx); err != nil { + t.Fatal(err) + } + user := &database.User{MXID: "@owner:example.com"} + if err = br.DB.User.Insert(ctx, user); err != nil { + t.Fatal(err) + } + stored := &database.UserLogin{ID: resolutionOwnMID, UserMXID: user.MXID, Metadata: &UserLoginMetadata{AccessToken: "test-token", Mid: resolutionOwnMID}} + if err = br.DB.UserLogin.Insert(ctx, stored); err != nil { + t.Fatal(err) + } + login, err := br.GetExistingUserLoginByID(ctx, stored.ID) + if err != nil { + t.Fatal(err) + } + t.Cleanup(login.BridgeState.Destroy) + lc := login.Client.(*LineClient) + lc.setCachedContact(resolutionOwnMID, line.Contact{Mid: resolutionOwnMID, DisplayName: "Owner"}) + original := newLineAPIClient + newLineAPIClient = func(token string) *line.Client { + client := line.NewClient(token) + client.HTTPClient.Transport = transport + return client + } + t.Cleanup(func() { newLineAPIClient = original }) + return lc +} + +func resolutionResponse(body string) *http.Response { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))} +} + +func assertPortalCount(t *testing.T, lc *LineClient, want int) { + t.Helper() + portals, err := lc.UserLogin.Bridge.DB.Portal.GetAll(context.Background()) + if err != nil || len(portals) != want { + t.Fatalf("portal count = %d, err=%v, want %d", len(portals), err, want) + } +} + +func TestResolveIdentifierPublicMIDAndSearch(t *testing.T) { + var calls []string + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + calls = append(calls, req.URL.Path) + if !strings.HasSuffix(req.URL.Path, "/findContactByUserid") { + t.Fatalf("unexpected RPC (possible bulk sync or duplicate lookup): %s", req.URL.Path) + } + var args []string + if err := json.NewDecoder(req.Body).Decode(&args); err != nil || len(args) != 1 || args[0] != "carol.id" { + t.Fatalf("public ID arguments = %v, %v", args, err) + } + return resolutionResponse(`{"code":0,"data":{"mid":"` + resolutionMID + `","displayName":"Carol"}}`), nil + }) + ctx := context.Background() + searched, err := lc.SearchUsers(ctx, " CAROL.ID ") + if err != nil || len(searched) != 1 || searched[0].UserID != resolutionMID { + t.Fatalf("search = %+v, %v", searched, err) + } + assertPortalCount(t, lc, 0) + resolved, err := lc.ResolveIdentifier(ctx, "carol.id", true) + if err != nil { + t.Fatal(err) + } + if resolved.UserID != searched[0].UserID || resolved.Chat.PortalKey.ID != resolutionMID || resolved.Chat.PortalKey.Receiver != lc.UserLogin.ID { + t.Fatalf("incorrect canonical portal: %+v", resolved) + } + if resolved.Chat.Portal != nil || resolved.Chat.PortalInfo.Members == nil || *resolved.Chat.PortalInfo.Type != database.RoomTypeDM || *resolved.Chat.PortalInfo.Name != "Carol" { + t.Fatalf("missing prevalidated DM info: %+v", resolved.Chat) + } + assertPortalCount(t, lc, 0) + midResult, err := lc.ResolveIdentifier(ctx, resolutionMID, true) + if err != nil || midResult.Chat.PortalKey != resolved.Chat.PortalKey { + t.Fatalf("MID disagrees with public ID: %+v, %v", midResult, err) + } + lookupOnly, err := lc.ResolveIdentifier(ctx, resolutionMID, false) + if err != nil || lookupOnly.Chat != nil { + t.Fatalf("lookup-only created chat: %+v, %v", lookupOnly, err) + } + if len(calls) != 2 { + t.Fatalf("got %d RPCs, want one per public lookup and no redundant contact fetch", len(calls)) + } +} + +func TestResolveIdentifierMIDLookup(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + if !strings.HasSuffix(req.URL.Path, "/getContactsV2") { + t.Fatalf("MID sent to wrong endpoint: %s", req.URL.Path) + } + return resolutionResponse(`{"code":0,"data":{"contacts":{"` + resolutionMID + `":{"contact":{"mid":"` + resolutionMID + `","displayName":"MID contact"}}}}}`), nil + }) + result, err := lc.ResolveIdentifier(context.Background(), strings.ToUpper(resolutionMID), true) + if err != nil || result == nil || result.Chat.PortalKey.ID != resolutionMID { + t.Fatalf("MID lookup = %+v, %v", result, err) + } +} + +func TestResolveIdentifierPreservesOpaqueMIDCase(t *testing.T) { + opaque := "U" + "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-aDEFg" + for _, tc := range []struct { + name, key, responseMID string + wantErr bool + }{ + {"exact opaque", opaque, opaque, false}, + {"different opaque case", "U" + strings.ToLower(opaque[1:]), opaque, true}, + } { + t.Run(tc.name, func(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(req.URL.Path, "/getContactsV2"): + var args []json.RawMessage + if err := json.NewDecoder(req.Body).Decode(&args); err != nil || len(args) < 1 { + t.Fatalf("opaque MID request malformed: %v", err) + } + var request struct { + TargetUserMids []string `json:"targetUserMids"` + } + if err := json.Unmarshal(args[0], &request); err != nil || len(request.TargetUserMids) != 1 || request.TargetUserMids[0] != opaque { + t.Fatalf("opaque MID was changed in request: %q, %v", request, err) + } + return resolutionResponse(`{"code":0,"data":{"contacts":{"` + tc.key + `":{"contact":{"mid":"` + tc.responseMID + `","displayName":"Opaque"}}}}}`), nil + case strings.HasSuffix(req.URL.Path, "/getBuddyProfile"): + return resolutionResponse(`{"code":0,"data":{}}`), nil + default: + t.Fatalf("opaque MID used unexpected RPC: %s", req.URL.Path) + return nil, nil + } + }) + result, err := lc.ResolveIdentifier(context.Background(), opaque, true) + if tc.wantErr && result != nil || !tc.wantErr && (err != nil || result == nil) { + t.Fatalf("result=%+v err=%v", result, err) + } + if tc.wantErr { + if result != nil { + t.Fatalf("mismatched opaque identity accepted: %+v", result) + } + return + } + if result == nil || string(result.UserID) != opaque || string(result.Chat.PortalKey.ID) != opaque { + t.Fatalf("opaque identity changed: %+v", result) + } + }) + } +} + +func TestSearchUsersRejectsUnrequestedContactMID(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(req.URL.Path, "/findContactByUserid"): + return resolutionResponse(`{"code":0,"data":{}}`), nil + case strings.HasSuffix(req.URL.Path, "/getAllContactIds"): + return resolutionResponse(`{"code":0,"data":["` + resolutionMID + `"]}`), nil + case strings.HasSuffix(req.URL.Path, "/getContactsV2"): + return resolutionResponse(`{"code":0,"data":{"contacts":{"` + resolutionOwnMID + `":{"contact":{"mid":"` + resolutionOwnMID + `","displayName":"spoofed"}}}}}`), nil + default: + t.Fatalf("unexpected search RPC: %s", req.URL.Path) + return nil, nil + } + }) + results, err := lc.SearchUsers(context.Background(), "alice") + if err == nil || len(results) != 0 { + t.Fatalf("unrequested contact accepted: results=%+v err=%v", results, err) + } +} + +func TestSearchUsersEmptyQueryDoesNotEnumerateContacts(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + t.Fatalf("empty search made LINE request: %s", req.URL.Path) + return nil, nil + }) + for _, query := range []string{"", " \t\n "} { + results, err := lc.SearchUsers(context.Background(), query) + if err != nil || len(results) != 0 { + t.Fatalf("empty query %q: results=%v err=%v", query, results, err) + } + } +} + +func TestResolveIdentifierFailuresDoNotPersistPortals(t *testing.T) { + for _, tc := range []struct { + name, query, response string + wantErr bool + }{ + {"unknown", "unknown.id", `{"code":0,"data":{}}`, false}, + {"restricted", "restricted.id", `{"code":1,"message":"ID search restricted"}`, true}, + {"invalid response", "carol.id", `{"code":0,"data":{"mid":"carol.id"}}`, true}, + {"group response", "carol.id", `{"code":0,"data":{"mid":"c0123456789abcdef0123456789abcdef"}}`, true}, + {"display name", "Carol Smith", "", true}, + {"empty", " ", "", true}, + } { + t.Run(tc.name, func(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + if tc.response == "" { + t.Fatal("invalid identifier made a network request") + } + return resolutionResponse(tc.response), nil + }) + result, err := lc.ResolveIdentifier(context.Background(), tc.query, true) + if result != nil || (err != nil) != tc.wantErr { + t.Fatalf("result=%+v, err=%v", result, err) + } + assertPortalCount(t, lc, 0) + }) + } +} + +func TestResolveIdentifierCancellation(t *testing.T) { + started := make(chan struct{}) + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + close(started) + <-req.Context().Done() + return nil, req.Context().Err() + }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { _, err := lc.ResolveIdentifier(ctx, "carol.id", true); done <- err }() + select { + case <-started: + case <-time.After(30 * time.Second): + t.Fatal("lookup did not start") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v", err) + } + case <-time.After(time.Second): + t.Fatal("lookup ignored cancellation") + } + assertPortalCount(t, lc, 0) +} + +func TestResolveIdentifierDeadlineAndMismatchedMID(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + if !strings.HasSuffix(req.URL.Path, "/getContactsV2") { + return nil, fmt.Errorf("unexpected endpoint: %s", req.URL.Path) + } + return resolutionResponse(`{"code":0,"data":{"contacts":{"` + resolutionMID + `":{"contact":{"mid":"u22222222222222222222222222222222","displayName":"Wrong recipient"}}}}}`), nil + }) + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + if resp, err := lc.ResolveIdentifier(ctx, resolutionMID, true); resp != nil || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expired lookup: %+v %v", resp, err) + } + if resp, err := lc.ResolveIdentifier(context.Background(), resolutionMID, true); resp != nil || err == nil { + t.Fatalf("mismatched MID accepted: %+v %v", resp, err) + } + assertPortalCount(t, lc, 0) +} + +func TestResolveIdentifierConcurrentCanonicalPortalReuse(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + if !strings.HasSuffix(req.URL.Path, "/findContactByUserid") { + return nil, fmt.Errorf("unexpected RPC: %s", req.URL.Path) + } + return resolutionResponse(`{"code":0,"data":{"mid":"` + resolutionMID + `","displayName":"Carol"}}`), nil + }) + ctx := context.Background() + const count = 12 + portals := make(chan *bridgev2.Portal, count) + errors := make(chan error, count) + var wg sync.WaitGroup + for range count { + wg.Go(func() { + resolved, err := lc.ResolveIdentifier(ctx, "carol.id", true) + if err != nil { + errors <- err + return + } + portal, err := lc.UserLogin.Bridge.GetPortalByKey(ctx, resolved.Chat.PortalKey) + if err != nil { + errors <- err + return + } + portals <- portal + }) + } + wg.Wait() + close(errors) + for err := range errors { + t.Error(err) + } + close(portals) + var first *bridgev2.Portal + for portal := range portals { + if first == nil { + first = portal + } else if first != portal { + t.Fatal("duplicate cached portal") + } + } + assertPortalCount(t, lc, 1) + if first == nil || first.ID != resolutionMID { + t.Fatal("missing canonical portal") + } + // An existing Matrix mapping must survive subsequent resolution. + first.MXID = "!existing:example.com" + if err := first.Save(ctx); err != nil { + t.Fatal(err) + } + resolved, err := lc.ResolveIdentifier(ctx, resolutionMID, true) + if err != nil { + t.Fatal(err) + } + existing, err := lc.UserLogin.Bridge.GetPortalByKey(ctx, resolved.Chat.PortalKey) + if err != nil || existing.MXID != first.MXID { + t.Fatalf("existing portal not reused: %v", err) + } +} + +func TestStartChatCreatesReusesAndRetriesRoom(t *testing.T) { + lc := newResolutionClient(t, func(req *http.Request) (*http.Response, error) { + if !strings.HasSuffix(req.URL.Path, "/findContactByUserid") { + return nil, fmt.Errorf("unexpected RPC: %s", req.URL.Path) + } + return resolutionResponse(`{"code":0,"data":{"mid":"` + resolutionMID + `","displayName":"Carol"}}`), nil + }) + matrix := lc.UserLogin.Bridge.Matrix.(*resolutionMatrix) + ctx := context.Background() + matrix.failCreate = true + if _, err := provisionutil.ResolveIdentifier(ctx, lc.UserLogin, "carol.id", true); err == nil { + t.Fatal("injected room creation failure was ignored") + } + // bridgev2 intentionally retains a canonical, retryable portal row after + // Matrix creation fails; no public-ID alias or wrong-recipient row exists. + assertPortalCount(t, lc, 1) + key := networkid.PortalKey{ID: resolutionMID, Receiver: lc.UserLogin.ID} + row, err := lc.UserLogin.Bridge.DB.Portal.GetByKey(ctx, key) + if err != nil || row == nil || row.MXID != "" { + t.Fatalf("failed room state: %+v %v", row, err) + } + matrix.failCreate = false + created, err := provisionutil.ResolveIdentifier(ctx, lc.UserLogin, "carol.id", true) + if err != nil || created == nil || created.DMRoomID == "" { + t.Fatalf("create: %+v %v", created, err) + } + existing, err := provisionutil.ResolveIdentifier(ctx, lc.UserLogin, resolutionMID, true) + if err != nil || existing.DMRoomID != created.DMRoomID || existing.JustCreated { + t.Fatalf("reuse: %+v %v", existing, err) + } + if matrix.created != 1 { + t.Fatalf("created %d rooms", matrix.created) + } + assertPortalCount(t, lc, 1) + oldRoomID := created.DMRoomID + matrix.ownerLeft = true + reopened, err := provisionutil.ResolveIdentifier(ctx, lc.UserLogin, "carol.id", true) + if err != nil || reopened.DMRoomID == oldRoomID || reopened.DMRoomID == "" { + t.Fatalf("reopen after leaving: %+v %v", reopened, err) + } + if matrix.created != 2 || matrix.deleted != 1 { + t.Fatalf("create/delete counts = %d/%d", matrix.created, matrix.deleted) + } + assertPortalCount(t, lc, 1) +} diff --git a/pkg/connector/send_message.go b/pkg/connector/send_message.go index bbdac0f7..43f135d8 100644 --- a/pkg/connector/send_message.go +++ b/pkg/connector/send_message.go @@ -69,6 +69,14 @@ func lineGroupE2EEFetchFailureError(err error) error { } func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) { + logContext := lc.UserLogin.Bridge.Log.With(). + Str("line_login_id", string(lc.UserLogin.ID)). + Str("line_recipient", string(msg.Portal.ID)) + if msg.Event != nil { + logContext = logContext.Stringer("matrix_event_id", msg.Event.ID).Stringer("matrix_sender", msg.Event.Sender) + } + log := logContext.Logger() + log.Debug().Msg("Handling outbound Matrix message for LINE") if err := lc.checkLineCall(ctx); err != nil { return nil, err } @@ -88,8 +96,13 @@ func (lc *LineClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.Mat var err error var sentMsg *line.Message client, sentMsg, err = callLineResultUsing(lc, ctx, client, func(client *line.Client) (*line.Message, error) { - return client.SendMessage(int64(reqSeq), lineMsg) + return client.SendMessageContext(ctx, int64(reqSeq), lineMsg) }) + resultLog := log.Debug().Bool("success", err == nil) + if sentMsg != nil { + resultLog = resultLog.Str("line_message_id", sentMsg.ID) + } + resultLog.Msg("Finished LINE API send attempt") return sentMsg, err } portalMid := string(msg.Portal.ID) diff --git a/pkg/connector/sync.go b/pkg/connector/sync.go index 1b5662bf..50bce57f 100644 --- a/pkg/connector/sync.go +++ b/pkg/connector/sync.go @@ -769,6 +769,7 @@ func (lc *LineClient) prefetchMessages(ctx context.Context) { // a hidden resync so a truncated recent-message window cannot leave a room // at an old membership or name. if backfilledSystemEvents.Load() && ctx.Err() == nil { + lc.UserLogin.Bridge.Log.Debug().Str("sync_trigger", "backfill_state_restore").Msg("Starting LINE conversation synchronization") lc.syncChatsNow(ctx) } } @@ -1668,7 +1669,7 @@ func (lc *LineClient) pollLoop(ctx context.Context) { // handle fullsync requests if eventType == "fullSync" { - lc.UserLogin.Bridge.Log.Info().Msg("Received fullSync request") + lc.UserLogin.Bridge.Log.Info().Str("sync_trigger", "line_full_sync").Msg("Received fullSync request") var fsPayload struct { NextRevision string `json:"nextRevision"` diff --git a/pkg/connector/userinfo.go b/pkg/connector/userinfo.go index 7f21f54c..1f89a5f0 100644 --- a/pkg/connector/userinfo.go +++ b/pkg/connector/userinfo.go @@ -204,6 +204,10 @@ func (lc *LineClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) } contact := lc.getContact(ctx, string(portal.ID)) + return lc.directChatInfo(mid, contact), nil +} + +func (lc *LineClient) directChatInfo(mid string, contact line.Contact) *bridgev2.ChatInfo { dmType := database.RoomTypeDM chatName := contact.EffectiveDisplayName() return &bridgev2.ChatInfo{ @@ -224,14 +228,14 @@ func (lc *LineClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) }, { EventSender: bridgev2.EventSender{ - Sender: networkid.UserID(portal.ID), + Sender: networkid.UserID(mid), }, Membership: event.MembershipJoin, PowerLevel: ptr.Ptr(0), }, }), }, - }, nil + } } func (lc *LineClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) { @@ -305,24 +309,30 @@ func (lc *LineClient) setCachedContact(mid string, contact line.Contact) { } func (lc *LineClient) ResolveIdentifier(ctx context.Context, identifier string, createChat bool) (*bridgev2.ResolveIdentifierResponse, error) { - userID := makeUserID(strings.TrimSpace(identifier)) - portalID := networkid.PortalKey{ID: makePortalID(string(userID)), Receiver: lc.UserLogin.ID} + contact, err := lc.resolveContactIdentifier(ctx, identifier) + if err != nil || contact == nil { + return nil, err + } + userID := makeUserID(contact.Mid) ghost, err := lc.UserLogin.Bridge.GetGhostByID(ctx, userID) if err != nil { return nil, fmt.Errorf("failed to get ghost: %w", err) } - portal, err := lc.UserLogin.Bridge.GetPortalByKey(ctx, portalID) - if err != nil { - return nil, fmt.Errorf("failed to get portal: %w", err) - } - ghostInfo, _ := lc.GetUserInfo(ctx, ghost) - portalInfo, _ := lc.GetChatInfo(ctx, portal) - return &bridgev2.ResolveIdentifierResponse{ + name := contact.EffectiveDisplayName() + resp := &bridgev2.ResolveIdentifierResponse{ Ghost: ghost, UserID: userID, - UserInfo: ghostInfo, - Chat: &bridgev2.CreateChatResponse{Portal: portal, PortalKey: portalID, PortalInfo: portalInfo}, - }, nil + UserInfo: &bridgev2.UserInfo{Identifiers: []string{contact.Mid}, Name: &name, Avatar: lc.avatarFromPicturePath(contact.PicturePath)}, + } + if createChat { + // Resolve and validate before bridgev2 persists anything. Its canonical + // portal cache and room-creation event loop handle reuse and concurrency. + resp.Chat = &bridgev2.CreateChatResponse{ + PortalKey: networkid.PortalKey{ID: makePortalID(contact.Mid), Receiver: lc.UserLogin.ID}, + PortalInfo: lc.directChatInfo(contact.Mid, *contact), + } + } + return resp, nil } func (lc *LineClient) midToResolveIdentifier(ctx context.Context, mid string) *bridgev2.ResolveIdentifierResponse { @@ -346,27 +356,36 @@ const maxSearchResults = 20 func (lc *LineClient) SearchUsers(ctx context.Context, query string) ([]*bridgev2.ResolveIdentifierResponse, error) { var results []*bridgev2.ResolveIdentifierResponse + var exactErr, validationErr error - // Try by LINE user ID first + // Exact public ID and MID lookup is shared with start-chat. Only search + // falls back to non-unique display-name matches. lowerQuery := strings.ToLower(strings.TrimSpace(query)) + if lowerQuery == "" { + return nil, nil + } if lowerQuery != "" { - _, contact, err := callLineResult(lc, ctx, func(client *line.Client) (*line.Contact, error) { - return client.FindContactByUserid(lowerQuery) - }) - if err == nil && contact != nil && contact.Mid != "" { + contact, err := lc.resolveContactIdentifier(ctx, query) + exactErr = err + if ctx.Err() != nil { + return nil, ctx.Err() + } + if err == nil && contact != nil { if r := lc.midToResolveIdentifier(ctx, contact.Mid); r != nil { - results = append(results, r) + return []*bridgev2.ResolveIdentifierResponse{r}, nil } } } // Search contacts by display name client, allMids, err := callLineResult(lc, ctx, func(client *line.Client) ([]string, error) { - return client.GetAllContactIds() + return client.GetAllContactIdsContext(ctx) }) if err != nil { - lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to get all contact IDs for search") - return results, nil + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("LINE contact search failed: %w", err) } // Fetch contacts in batches to check display names @@ -379,13 +398,26 @@ func (lc *LineClient) SearchUsers(ctx context.Context, query string) ([]*bridgev end = len(allMids) } batch := allMids[i:end] - contactsResp, err := client.GetContactsV2(batch) + requested := make(map[string]bool, len(batch)) + for _, mid := range batch { + requested[normalizeUserMID(mid)] = true + } + contactsResp, err := client.GetContactsV2Context(ctx, batch) if err != nil { - continue + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("LINE contact search failed: %w", err) + } + keyCounts := make(map[string]int, len(contactsResp.Contacts)) + for key := range contactsResp.Contacts { + keyCounts[normalizeUserMID(key)]++ } - for _, wrapper := range contactsResp.Contacts { + for key, wrapper := range contactsResp.Contacts { c := wrapper.Contact - if c.Mid == "" { + c.Mid = normalizeUserMID(c.Mid) + if !isCanonicalUserMID(c.Mid) || normalizeUserMID(key) != c.Mid || !requested[c.Mid] || keyCounts[c.Mid] != 1 { + validationErr = fmt.Errorf("LINE contact search returned invalid or ambiguous user MIDs") continue } lc.setCachedContact(c.Mid, c) @@ -400,6 +432,14 @@ func (lc *LineClient) SearchUsers(ctx context.Context, query string) ([]*bridgev } } } + if len(results) == 0 { + if exactErr != nil { + return nil, fmt.Errorf("exact LINE lookup failed and no matching contacts were found: %w", exactErr) + } + if validationErr != nil { + return nil, validationErr + } + } return results, nil } @@ -424,12 +464,21 @@ func (lc *LineClient) GetContactList(ctx context.Context) ([]*bridgev2.ResolveId end = len(allMids) } batch := allMids[i:end] + requested := make(map[string]bool, len(batch)) + for _, mid := range batch { + requested[normalizeUserMID(mid)] = true + } contactsResp, err := client.GetContactsV2(batch) if err != nil { continue } - for mid, wrapper := range contactsResp.Contacts { - if wrapper.Contact.Mid == "" { + keyCounts := make(map[string]int, len(contactsResp.Contacts)) + for key := range contactsResp.Contacts { + keyCounts[normalizeUserMID(key)]++ + } + for key, wrapper := range contactsResp.Contacts { + mid := normalizeUserMID(wrapper.Contact.Mid) + if !isCanonicalUserMID(mid) || normalizeUserMID(key) != mid || !requested[mid] || keyCounts[mid] != 1 { continue } lc.setCachedContact(mid, wrapper.Contact) diff --git a/pkg/e2ee/manager_test.go b/pkg/e2ee/manager_test.go index c5934abb..d5355e37 100644 --- a/pkg/e2ee/manager_test.go +++ b/pkg/e2ee/manager_test.go @@ -39,6 +39,9 @@ func TestV1WireHeaderUsesV1DecryptWithoutMetadata(t *testing.T) { if err != nil { t.Fatal(err) } + if version := ChunksVersion(chunks); version != 1 { + t.Fatalf("V1 chunks version = %d, want 1", version) + } msg := &line.Message{Chunks: chunks, From: "u-sender", To: "u-receiver"} for i := 0; i < 1800; i++ { got, err := m.DecryptMessageV2(msg) @@ -52,6 +55,32 @@ func TestV1WireHeaderUsesV1DecryptWithoutMetadata(t *testing.T) { } m.groupKeys["c-test"] = map[int]int{myRaw: myKey} + if _, err := m.UnwrapGroupSharedKey("c-test", &line.E2EEGroupSharedKey{GroupKeyID: myRaw, SpecVersion: 1}); err != nil { + t.Fatalf("cache group spec version 1: %v", err) + } + if m.groupSpecVersion["c-test"] != 1 { + t.Fatalf("stored group spec version = %d, want 1", m.groupSpecVersion["c-test"]) + } + groupChunks, err := m.EncryptGroupMessageRaw("c-test", "u-sender", 0, []byte(plaintext)) + if err != nil { + t.Fatalf("spec-version-1 group encryption: %v", err) + } + if version := ChunksVersion(groupChunks); version != 1 { + t.Fatalf("spec-version-1 group chunks version = %d, want 1", version) + } + if _, err := m.UnwrapGroupSharedKey("c-test", &line.E2EEGroupSharedKey{GroupKeyID: myRaw, SpecVersion: 2}); err != nil { + t.Fatalf("cache group spec version 2: %v", err) + } + if m.groupSpecVersion["c-test"] != 2 { + t.Fatalf("rotated group spec version = %d, want 2", m.groupSpecVersion["c-test"]) + } + groupChunks, err = m.EncryptGroupMessageRaw("c-test", "u-sender", 0, []byte(plaintext)) + if err != nil { + t.Fatalf("spec-version-2 group encryption: %v", err) + } + if got, _, err := m.DecryptGroupMessage(&line.Message{Chunks: groupChunks, From: "u-sender", To: "u-receiver"}, "c-test"); err != nil || got != plaintext { + t.Fatalf("spec-version-2 group fallback decrypt = (%q, %v)", got, err) + } got, groupKey, err := m.DecryptGroupMessage(msg, "c-test") if err != nil || got != plaintext || groupKey != myRaw { t.Fatalf("group V1 decrypt = (%q, %d, %v)", got, groupKey, err) @@ -91,6 +120,14 @@ func TestV1WireHeaderUsesV1DecryptWithoutMetadata(t *testing.T) { } } +func TestChunksVersionDefaultsMalformedAndEmptyInputToV2(t *testing.T) { + for _, chunks := range [][]string{nil, {}, {"not-base64"}} { + if version := ChunksVersion(chunks); version != 2 { + t.Fatalf("ChunksVersion(%v) = %d, want 2", chunks, version) + } + } +} + func TestIsFatalLTSMError(t *testing.T) { if !isFatalLTSMError(ltsm.ErrAbort) { t.Fatal("direct LTSM abort was not classified as fatal") diff --git a/pkg/line/auth_error.go b/pkg/line/auth_error.go new file mode 100644 index 00000000..fc11fe95 --- /dev/null +++ b/pkg/line/auth_error.go @@ -0,0 +1,325 @@ +package line + +import ( + "errors" + "fmt" + "regexp" + "strings" + "time" + "unicode" +) + +// AuthErrorCategory is a normalized classification of an authentication failure. +// It bridges LINE provider-specific exceptions into stable, bridge-side +// categories that drive user-facing messages, cooldowns, and diagnostics. +type AuthErrorCategory int + +const ( + // AuthErrorUnknown is the default category for unrecognized failures. + AuthErrorUnknown AuthErrorCategory = iota + // AuthErrorInvalidCredentials means LINE rejected the supplied credentials. + AuthErrorInvalidCredentials + // AuthErrorVerificationRequired means LINE requires additional verification. + AuthErrorVerificationRequired + // AuthErrorVerificationTemporarilyUnavailable means LINE temporarily cannot + // complete verification (the confirmed "Verification is temporarily + // unavailable" provider alert). + AuthErrorVerificationTemporarilyUnavailable + // AuthErrorTooManyAttempts means LINE rate-limited login attempts. + AuthErrorTooManyAttempts + // AuthErrorRetryLater means LINE asked the client to come back later. + AuthErrorRetryLater + // AuthErrorQRLoginNotAllowed means LINE does not allow QR sign-in for this + // account or session. + AuthErrorQRLoginNotAllowed + // AuthErrorQRContextExpired means the QR sign-in context expired before + // completion (only when explicitly supported by the provider response). + AuthErrorQRContextExpired + // AuthErrorNetworkUnavailable means LINE could not be reached. + AuthErrorNetworkUnavailable + // AuthErrorServiceUnavailable means LINE returned a service error. + AuthErrorServiceUnavailable + // AuthErrorUnsupportedMethod means LINE does not support the requested + // authentication method. + AuthErrorUnsupportedMethod +) + +// String returns a stable schema label for the category. +func (c AuthErrorCategory) String() string { + switch c { + case AuthErrorInvalidCredentials: + return "INVALID_CREDENTIALS" + case AuthErrorVerificationRequired: + return "VERIFICATION_REQUIRED" + case AuthErrorVerificationTemporarilyUnavailable: + return "VERIFICATION_TEMPORARILY_UNAVAILABLE" + case AuthErrorTooManyAttempts: + return "TOO_MANY_ATTEMPTS" + case AuthErrorRetryLater: + return "RETRY_LATER" + case AuthErrorQRLoginNotAllowed: + return "QR_LOGIN_NOT_ALLOWED" + case AuthErrorQRContextExpired: + return "QR_CONTEXT_EXPIRED" + case AuthErrorNetworkUnavailable: + return "NETWORK_UNAVAILABLE" + case AuthErrorServiceUnavailable: + return "SERVICE_UNAVAILABLE" + case AuthErrorUnsupportedMethod: + return "UNSUPPORTED_METHOD" + default: + return "UNKNOWN" + } +} + +// AuthErrorStage identifies the stage of the authentication flow where the +// failure occurred. +type AuthErrorStage int + +const ( + // AuthStageUnknown is the default stage. + AuthStageUnknown AuthErrorStage = iota + // AuthStageQRCreate is the QR session/code creation stage. + AuthStageQRCreate + // AuthStageQRVerify is the QR verification/polling stage. + AuthStageQRVerify + // AuthStageQRTokenIssue is the qrCodeLoginV2 token-issue stage. + AuthStageQRTokenIssue + // AuthStageEmailLogin is the email/password login stage. + AuthStageEmailLogin + // AuthStageEmailVerification is PIN/email challenge completion after credentials were accepted. + AuthStageEmailVerification + // AuthStageSessionEstablish is the session establishment stage. + AuthStageSessionEstablish +) + +// String returns a stable label for the stage. +func (s AuthErrorStage) String() string { + switch s { + case AuthStageQRCreate: + return "qr_create" + case AuthStageQRVerify: + return "qr_verify" + case AuthStageQRTokenIssue: + return "qr_token_issue" + case AuthStageEmailLogin: + return "email_login" + case AuthStageEmailVerification: + return "email_verification" + case AuthStageSessionEstablish: + return "session_establish" + default: + return "unknown" + } +} + +// AuthError is a structured authentication error that carries LINE provider +// metadata, normalized classification, and user-facing guidance. It is safe to +// log and to present to operators; user-facing messages are derived separately. +type AuthError struct { + // Flow is the authentication flow (qr or email). + Flow string + // Stage is the authentication stage where the failure occurred. + Stage AuthErrorStage + // HTTPStatus is the HTTP status code returned by LINE, if any. + HTTPStatus int + // ResponseCode is LINE's response code (e.g. 10051), if any. + ResponseCode *int + // ExceptionType is LINE's exception type name, if any. + ExceptionType string + // ExceptionCode is LINE's exception code, if any. + ExceptionCode *int + // Category is the normalized bridge-side classification. + Category AuthErrorCategory + // ProviderReason is a safe, normalized provider reason string, never raw + // provider text. + ProviderReason string + // ProviderAlertStatus describes whether the provider alert was recognized. + ProviderAlertStatus string + // ProviderMessage is safe provider wording for logs and user-facing copy. + ProviderMessage string + // ProviderMessageStatus distinguishes recognized, sanitized, redacted, and suppressed text. + ProviderMessageStatus string + // UserMessage is the concise user-facing explanation. + UserMessage string + // RetryRecommended indicates whether retrying is appropriate. + RetryRecommended bool + // RetryAfter is the suggested wait duration before retrying, if known. + RetryAfter *time.Duration + // Temporary indicates whether the failure is likely temporary. + Temporary bool + // UserActionRequired indicates whether the user must take action. + UserActionRequired bool + // BridgeFailure indicates whether the failure originated in the bridge. + BridgeFailure bool + // CooldownTriggered indicates whether a local cooldown was triggered. + CooldownTriggered bool + // Err is the original wrapped error, if any. + Err error +} + +// Error implements the error interface. +func (e *AuthError) Error() string { + if e.UserMessage != "" { + return e.UserMessage + } + return fmt.Sprintf("LINE authentication failed: %s", e.Category.String()) +} + +// Unwrap returns the original wrapped error. +func (e *AuthError) Unwrap() error { + return e.Err +} + +var ( + providerCredentialPattern = regexp.MustCompile(`(?i)\b(access[_ -]?token|refresh[_ -]?token|id[_ -]?token|password|passwd|authorization|cookie|authsessionid|session[_ -]?id|qr[_ -]?(token|secret)|credential|secret)\b\s*[:=]\s*("[^"]*"|'[^']*'|[^\s,;.!?]+)([.!?]?)`) + providerEmailPattern = regexp.MustCompile(`(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b`) + providerJWTpattern = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{8,}\b`) + providerOpaqueMIDPattern = regexp.MustCompile(`\bU[A-Za-z0-9_-]{43}\b`) + providerLongSecretPattern = regexp.MustCompile(`\b[A-Za-z0-9_-]{32,}\b`) +) + +// SanitizeProviderMessage keeps useful provider wording while removing common +// credentials and private identifiers. Unknown text is presented only when it +// is short, printable, and not structured or credential-like. +func SanitizeProviderMessage(message string) (safe, status string) { + if strings.TrimSpace(message) == "" { + return "", "absent" + } + for _, r := range message { + if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' || unicode.Is(unicode.Cf, r) { + return "", "suppressed" + } + } + message = strings.ReplaceAll(strings.ReplaceAll(message, "\r\n", "\n"), "\r", "\n") + message = strings.ReplaceAll(message, "\t", " ") + message = strings.TrimSpace(message) + if len([]rune(message)) > 512 { + return "", "suppressed" + } + message = providerCredentialPattern.ReplaceAllString(message, "$1=[redacted]$4") + message = providerEmailPattern.ReplaceAllString(message, "[redacted email]") + message = providerJWTpattern.ReplaceAllString(message, "[redacted token]") + message = providerOpaqueMIDPattern.ReplaceAllString(message, "[redacted LINE ID]") + message = providerLongSecretPattern.ReplaceAllString(message, "[redacted token]") + if strings.ContainsAny(message, "{}<>") || strings.Contains(strings.ToLower(message), "bearer ") || strings.Contains(message, "https://") || strings.Contains(message, "http://") { + return "", "suppressed" + } + if (strings.Contains(strings.ToLower(message), "token ") || strings.Contains(strings.ToLower(message), "password ")) && !strings.Contains(message, "[redacted") && !isRecognizedProviderMessage(message) { + return "", "suppressed" + } + if strings.Contains(message, "[redacted") { + return message, "partially_redacted" + } + if isRecognizedProviderMessage(message) { + return message, "recognized" + } + if len([]rune(message)) < 5 { + return "", "suppressed" + } + if !strings.ContainsAny(message, " \n") && !strings.Contains(message, "[redacted") { + return "", "suppressed" + } + return message, "safe_unrecognized" +} + +func isRecognizedProviderMessage(message string) bool { + lower := strings.ToLower(strings.Join(strings.Fields(message), " ")) + for _, phrase := range []string{"verification is temporarily unavailable", "too many login attempts", "retry later", "blocked user", "authentication failed", "account id or password is invalid", "qr code has expired", "qr login is not allowed"} { + if strings.Contains(lower, phrase) { + return true + } + } + return false +} + +// SanitizeProviderAlert maps a raw LINE provider alert to a safe category +// string. It never returns raw provider text. An empty alert returns "absent". +func SanitizeProviderAlert(alert string) string { + trimmed := strings.TrimSpace(alert) + if trimmed == "" { + return "absent" + } + lower := strings.ToLower(trimmed) + switch { + case strings.Contains(lower, "verification is temporarily unavailable"): + return "verification_temporarily_unavailable" + case strings.Contains(lower, "too many login attempts"): + return "too_many_login_attempts" + case strings.EqualFold(lower, "blocked user"): + return "blocked_user" + case strings.EqualFold(lower, "authentication failed"): + return "authentication_failed" + case strings.EqualFold(lower, "failed to issue v3 token"): + return "failed_to_issue_v3_token" + case strings.EqualFold(lower, "account id or password is invalid"): + return "account_id_or_password_is_invalid" + default: + return "unrecognized" + } +} + +// UserMessageForCategory returns the default user-facing message for a +// category. Callers may override with stage-specific wording. +func UserMessageForCategory(c AuthErrorCategory) string { + switch c { + case AuthErrorInvalidCredentials: + return "LINE rejected the email or password. Check your credentials and try again." + case AuthErrorVerificationRequired: + return "LINE requires additional verification. Please complete the verification prompt and try again." + case AuthErrorVerificationTemporarilyUnavailable: + return "LINE verification is temporarily unavailable for this sign-in. Please wait before trying again." + case AuthErrorTooManyAttempts: + return "LINE is temporarily limiting login attempts. Please wait before trying again." + case AuthErrorRetryLater: + return "LINE asked us to try the sign-in again later. Please wait before trying again." + case AuthErrorQRLoginNotAllowed: + return "LINE is not allowing QR-code login for this account or session. Please sign in with your email and password instead." + case AuthErrorQRContextExpired: + return "This QR sign-in has expired. Start a new QR login." + case AuthErrorNetworkUnavailable: + return "Unable to reach LINE right now. Please try again later." + case AuthErrorServiceUnavailable: + return "LINE is temporarily unavailable. Please try again later." + case AuthErrorUnsupportedMethod: + return "LINE does not support this authentication method. Please try a different sign-in method." + default: + return "LINE rejected the login request, but did not provide a recognized reason. Please check the bridge logs or contact your administrator." + } +} + +// IsNetworkError returns true if the error is a network/transport failure. +func IsNetworkError(err error) bool { + if err == nil { + return false + } + var ae *AuthError + if errorsAs(err, &ae) { + switch ae.Category { + case AuthErrorNetworkUnavailable, AuthErrorServiceUnavailable: + return true + } + } + return false +} + +// IsRateLimited returns true if the error is a rate-limit rejection. +func IsRateLimited(err error) bool { + if err == nil { + return false + } + var ae *AuthError + if errorsAs(err, &ae) { + switch ae.Category { + case AuthErrorTooManyAttempts, AuthErrorRetryLater: + return true + } + } + return false +} + +// errorsAs wraps errors.As for *AuthError without exposing it in the public +// API surface beyond what callers need. +func errorsAs(err error, target interface{}) bool { + return errors.As(err, target) +} diff --git a/pkg/line/auth_error_test.go b/pkg/line/auth_error_test.go new file mode 100644 index 00000000..ca511d6e --- /dev/null +++ b/pkg/line/auth_error_test.go @@ -0,0 +1,27 @@ +package line + +import "testing" + +func TestSanitizeProviderMessage(t *testing.T) { + for _, tc := range []struct { + name, input, want, status string + }{ + {"verified verification alert", "Verification is temporarily unavailable.\nPlease try again later.", "Verification is temporarily unavailable.\nPlease try again later.", "recognized"}, + {"token field redacted", "Login failed; access_token=private-value. Please try later.", "Login failed; access_token=[redacted]. Please try later.", "partially_redacted"}, + {"email redacted", "Account for person@example.org is locked.", "Account for [redacted email] is locked.", "partially_redacted"}, + {"opaque token redacted", "Login failed: abcdefghijklmnopqrstuvwxyz0123456789, please retry.", "Login failed: [redacted token], please retry.", "partially_redacted"}, + {"bounded safe provider text", "LINE could not complete this sign-in. Try again later.", "LINE could not complete this sign-in. Try again later.", "safe_unrecognized"}, + {"structured response suppressed", `{"token":"private"}`, "", "suppressed"}, + {"markup suppressed", "unsafe", "", "suppressed"}, + {"control sequence suppressed", "safe text\x1b[31mprivate", "", "suppressed"}, + {"oversized message suppressed", string(make([]byte, 600)), "", "suppressed"}, + {"short unknown suppressed", "no", "", "suppressed"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, status := SanitizeProviderMessage(tc.input) + if got != tc.want || status != tc.status { + t.Fatalf("SanitizeProviderMessage() = %q, %q; want %q, %q", got, status, tc.want, tc.status) + } + }) + } +} diff --git a/pkg/line/client.go b/pkg/line/client.go index f69bfcb0..24135686 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -478,7 +478,7 @@ func (c *Client) callRPCWithBaseURLContext(ctx context.Context, baseURL, service respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode != 200 { - return nil, newHTTPError(resp.StatusCode, "API error %d: %s", resp.StatusCode, string(respBody)) + return nil, newHTTPResponseError(resp, "API error %d: %s", resp.StatusCode, string(respBody)) } return respBody, nil @@ -563,7 +563,7 @@ func (c *Client) postWithHMAC(fullURL string, body []byte) ([]byte, error) { if resp.StatusCode != 200 { respBody, _ := io.ReadAll(resp.Body) - return nil, newHTTPError(resp.StatusCode, "HTTP %d: %s", resp.StatusCode, string(respBody)) + return nil, newHTTPResponseError(resp, "HTTP %d: %s", resp.StatusCode, string(respBody)) } return io.ReadAll(resp.Body) diff --git a/pkg/line/errors.go b/pkg/line/errors.go index fcc46d47..9e40f902 100644 --- a/pkg/line/errors.go +++ b/pkg/line/errors.go @@ -9,6 +9,7 @@ import ( "net/http" "strconv" "strings" + "time" ) var ( @@ -21,6 +22,7 @@ var ( // HTTPError is returned when a LINE endpoint answers with an unexpected HTTP status. type HTTPError struct { StatusCode int + RetryAfter time.Duration text string } @@ -28,6 +30,13 @@ func newHTTPError(statusCode int, format string, args ...any) *HTTPError { return &HTTPError{StatusCode: statusCode, text: fmt.Sprintf(format, args...)} } +// newHTTPResponseError retains bounded retry guidance from an HTTP response. +func newHTTPResponseError(resp *http.Response, format string, args ...any) *HTTPError { + err := newHTTPError(resp.StatusCode, format, args...) + err.RetryAfter = parseRetryAfter(resp.Header.Get("Retry-After"), time.Now()) + return err +} + func (e *HTTPError) Error() string { return e.text } @@ -42,6 +51,15 @@ func HTTPStatusCode(err error) int { return 0 } +// HTTPRetryAfter returns a parsed Retry-After duration carried by an HTTPError. +func HTTPRetryAfter(err error) time.Duration { + var httpErr *HTTPError + if errors.As(err, &httpErr) { + return httpErr.RetryAfter + } + return 0 +} + // IsTalkException returns true when LINE rejected a Talk API call with a // TalkException response. func IsTalkException(err error) bool { diff --git a/pkg/line/methods.go b/pkg/line/methods.go index 17643a32..0ead2237 100644 --- a/pkg/line/methods.go +++ b/pkg/line/methods.go @@ -391,7 +391,11 @@ func (c *Client) GetE2EEPublicKey(mid string, keyVersion, keyID int) (*E2EEPubli } func (c *Client) SendMessage(reqSeq int64, msg *Message) (*Message, error) { - resp, err := c.callRPC("TalkService", "sendMessage", reqSeq, msg) + return c.SendMessageContext(c.requestContext(), reqSeq, msg) +} + +func (c *Client) SendMessageContext(ctx context.Context, reqSeq int64, msg *Message) (*Message, error) { + resp, err := c.callRPCContext(ctx, "TalkService", "sendMessage", reqSeq, msg) if err != nil { return nil, err } @@ -404,7 +408,12 @@ func (c *Client) SendMessage(reqSeq int64, msg *Message) (*Message, error) { return nil, err } if wrapper.Code != 0 { - return nil, fmt.Errorf("sendMessage failed: %s", wrapper.Message) + // Keep TalkException codes available to auth recovery and send retries, + // including when LINE wraps an error in an HTTP 200 response. + return nil, fmt.Errorf("sendMessage failed: %s", resp) + } + if wrapper.Data == nil || wrapper.Data.ID == "" { + return nil, fmt.Errorf("sendMessage returned no message acknowledgement") } return wrapper.Data, nil } @@ -487,8 +496,12 @@ func (c *Client) SendChatChecked(chatMid, messageID string) error { // GetContactsV2 fetches contact details for a list of MIDs. func (c *Client) GetContactsV2(mids []string) (*ContactsResponse, error) { + return c.GetContactsV2Context(c.requestContext(), mids) +} + +func (c *Client) GetContactsV2Context(ctx context.Context, mids []string) (*ContactsResponse, error) { req := GetContactsV2Request{TargetUserMids: mids} - resp, err := c.callRPC("TalkService", "getContactsV2", req, 2) + resp, err := c.callRPCContext(ctx, "TalkService", "getContactsV2", req, 2) if err != nil { return nil, err } @@ -508,7 +521,11 @@ func (c *Client) GetContactsV2(mids []string) (*ContactsResponse, error) { // GetBuddyProfile fetches the profile of a LINE official/business account (buddy). func (c *Client) GetBuddyProfile(mid string) (*BuddyProfile, error) { - resp, err := c.callRPC("BuddyService", "getBuddyProfile", mid) + return c.GetBuddyProfileContext(c.requestContext(), mid) +} + +func (c *Client) GetBuddyProfileContext(ctx context.Context, mid string) (*BuddyProfile, error) { + resp, err := c.callRPCContext(ctx, "BuddyService", "getBuddyProfile", mid) if err != nil { return nil, err } @@ -1039,7 +1056,11 @@ func (c *Client) RejectChatInvitationContext(ctx context.Context, reqSeq int64, // FindContactByUserid looks up a LINE user by their user ID (not MID). func (c *Client) FindContactByUserid(userid string) (*Contact, error) { - resp, err := c.callRPC("TalkService", "findContactByUserid", userid) + return c.FindContactByUseridContext(c.requestContext(), userid) +} + +func (c *Client) FindContactByUseridContext(ctx context.Context, userid string) (*Contact, error) { + resp, err := c.callRPCContext(ctx, "TalkService", "findContactByUserid", userid) if err != nil { return nil, err } @@ -1059,7 +1080,11 @@ func (c *Client) FindContactByUserid(userid string) (*Contact, error) { // GetAllContactIds returns the MIDs of all contacts. func (c *Client) GetAllContactIds() ([]string, error) { - resp, err := c.callRPC("TalkService", "getAllContactIds") + return c.GetAllContactIdsContext(c.requestContext()) +} + +func (c *Client) GetAllContactIdsContext(ctx context.Context) ([]string, error) { + resp, err := c.callRPCContext(ctx, "TalkService", "getAllContactIds") if err != nil { return nil, err } diff --git a/pkg/line/methods_context_test.go b/pkg/line/methods_context_test.go index b6b0bcf1..65f10dff 100644 --- a/pkg/line/methods_context_test.go +++ b/pkg/line/methods_context_test.go @@ -21,14 +21,29 @@ func testRPCContextCancellation(t *testing.T, method string, call func(*Client, } ctx, cancel := context.WithCancel(context.Background()) + defer cancel() result := make(chan error, 1) + done := make(chan struct{}) go func() { + defer close(done) result <- call(client, ctx) }() + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(30 * time.Second): + t.Errorf("%s worker did not exit after cleanup cancellation", method) + } + }) select { case <-requestStarted: - case <-time.After(time.Second): + case err := <-result: + t.Fatalf("%s failed before reaching transport: %v", method, err) + // Cold white-box signing happens before RoundTrip and is substantially + // slower under -race. This is a setup bound, not the cancellation bound. + case <-time.After(30 * time.Second): t.Fatalf("%s request did not start", method) } cancel() @@ -56,3 +71,23 @@ func TestGetProfileContextCancellation(t *testing.T) { return err }) } + +func TestIdentifierLookupContextCancellation(t *testing.T) { + for name, call := range map[string]func(*Client, context.Context) error{ + "findContactByUserid": func(c *Client, ctx context.Context) error { + _, err := c.FindContactByUseridContext(ctx, "carol") + return err + }, + "getContactsV2": func(c *Client, ctx context.Context) error { + _, err := c.GetContactsV2Context(ctx, []string{"u0123456789abcdef0123456789abcdef"}) + return err + }, + "getBuddyProfile": func(c *Client, ctx context.Context) error { + _, err := c.GetBuddyProfileContext(ctx, "u0123456789abcdef0123456789abcdef") + return err + }, + "getAllContactIds": func(c *Client, ctx context.Context) error { _, err := c.GetAllContactIdsContext(ctx); return err }, + } { + t.Run(name, func(t *testing.T) { testRPCContextCancellation(t, name, call) }) + } +} diff --git a/pkg/line/qr.go b/pkg/line/qr.go index 75b63134..c1543f34 100644 --- a/pkg/line/qr.go +++ b/pkg/line/qr.go @@ -12,6 +12,7 @@ import ( "net/http" "net/url" "strconv" + "strings" "time" gen "github.com/beeper/line/pkg" @@ -89,23 +90,329 @@ func (c *Client) VerifyQRCertificate(ctx context.Context, session, certificate s err := c.callQRRPC(ctx, "verifyCertificate", "", 0, map[string]string{"authSessionId": session, "certificate": certificate}, nil) var response *qrRPCError if errors.As(err, &response) && response.httpStatus == http.StatusBadRequest { + if authErr := response.AuthError(); authErr != nil { + switch authErr.Category { + case AuthErrorTooManyAttempts, AuthErrorRetryLater, AuthErrorVerificationTemporarilyUnavailable, AuthErrorServiceUnavailable: + return false, err + } + } return false, nil } return err == nil, err } type qrRPCError struct { - method string - httpStatus, code, statusCode int + method string + httpStatus int + code *int + statusCode *int + exceptionCode *int + exceptionType string + exceptionLabel string + reason string + alert string + alertStatus string + authError *AuthError + retryAfter time.Duration +} + +// QRRPCError is the exported alias for qrRPCError, allowing callers to +// type-assert and access the structured AuthError. +type QRRPCError = qrRPCError + +// secondaryQrCodeErrorLabels maps the fixed error codes of +// SecondaryQrCodeException to their schema labels. The codes are only +// interpreted for that exception class, since other classes reuse the numbers. +var secondaryQrCodeErrorLabels = map[int]string{ + 0: "INTERNAL_ERROR", + 1: "ILLEGAL_ARGUMENT", + 2: "VERIFICATION_FAILED", + 3: "NOT_ALLOWED_QR_CODE_LOGIN", + 4: "VERIFICATION_NOTICE_FAILED", + 5: "RETRY_LATER", + 100: "INVALID_CONTEXT", + 101: "APP_UPGRADE_REQUIRED", } func (e *qrRPCError) Error() string { - return fmt.Sprintf("QR %s failed: HTTP %d, code %d, status %d", e.method, e.httpStatus, e.code, e.statusCode) + message := fmt.Sprintf("QR %s failed: HTTP %d", e.method, e.httpStatus) + if e.code != nil { + message += fmt.Sprintf(", code %d", *e.code) + } + if e.statusCode != nil { + message += fmt.Sprintf(", status %d", *e.statusCode) + } + if e.exceptionCode != nil { + message += fmt.Sprintf(", exception code %d", *e.exceptionCode) + } + if e.exceptionType != "" { + message += ", exception type " + e.exceptionType + } + if e.reason != "" { + message += ", reason " + e.reason + } + if e.alert != "" { + message += ", alert " + e.alert + } + return message +} + +// buildAuthError constructs a structured AuthError from the raw provider +// fields captured before sanitization. It never exposes raw provider text +// through user-facing fields. +func (e *qrRPCError) buildAuthError(rawName string, rawCode *int, rawReason, rawAlert string) *AuthError { + stage := AuthStageQRTokenIssue + switch e.method { + case "createSession", "createQrCode": + stage = AuthStageQRCreate + case "verifyCertificate", "createPinCode", "checkQrCodeVerified", "checkPinCodeVerified": + stage = AuthStageQRVerify + } + providerMessage := rawAlert + if strings.TrimSpace(providerMessage) == "" { + providerMessage = rawReason + } + safeProviderMessage, providerMessageStatus := SanitizeProviderMessage(providerMessage) + _, providerAlertStatus := SanitizeProviderMessage(rawAlert) + if e.alertStatus != "" { + providerAlertStatus = e.alertStatus + } + ae := &AuthError{ + Flow: "qr", + Stage: stage, + HTTPStatus: e.httpStatus, + ResponseCode: e.code, + ExceptionType: e.exceptionType, + ExceptionCode: e.exceptionCode, + Category: AuthErrorUnknown, + ProviderReason: SanitizeProviderAlert(rawReason), + ProviderAlertStatus: providerAlertStatus, + ProviderMessage: safeProviderMessage, + ProviderMessageStatus: providerMessageStatus, + UserMessage: UserMessageForCategory(AuthErrorUnknown), + RetryRecommended: false, + Temporary: false, + UserActionRequired: true, + BridgeFailure: false, + CooldownTriggered: false, + RetryAfter: durationPtr(e.retryAfter), + Err: e, + } + if ae.ProviderReason == "absent" && rawAlert != "" { + ae.ProviderReason = SanitizeProviderAlert(rawAlert) + } + + switch { + case e.httpStatus == http.StatusTooManyRequests: + ae.Category = AuthErrorTooManyAttempts + ae.RetryRecommended, ae.Temporary, ae.CooldownTriggered = true, true, true + case rawName == "SecondaryQrCodeException" && e.exceptionCode != nil: + ae.Category, ae.RetryRecommended, ae.Temporary, ae.CooldownTriggered = + classifySecondaryQrCode(*e.exceptionCode, rawAlert) + case rawName == "SecondaryQrCodeLoginException" && e.exceptionCode != nil: + ae.Category, ae.RetryRecommended, ae.Temporary, ae.CooldownTriggered = + classifySecondaryQrCode(*e.exceptionCode, rawAlert) + case e.exceptionType == "TalkException" || e.exceptionType == "LoginException": + ae.Category = classifyTalkException(e.exceptionCode, rawReason, rawAlert) + case e.exceptionType == "other": + ae.Category = AuthErrorUnknown + } + + // Reason and alert text can override schema-label classification when they + // unambiguously indicate a rate limit or verification unavailability. + switch { + case SanitizeProviderAlert(rawReason) == "too_many_login_attempts" || SanitizeProviderAlert(rawAlert) == "too_many_login_attempts": + ae.Category = AuthErrorTooManyAttempts + case SanitizeProviderAlert(rawReason) == "blocked_user" || SanitizeProviderAlert(rawAlert) == "blocked_user": + ae.Category = AuthErrorTooManyAttempts + case SanitizeProviderAlert(rawReason) == "verification_temporarily_unavailable" || SanitizeProviderAlert(rawAlert) == "verification_temporarily_unavailable": + ae.Category = AuthErrorVerificationTemporarilyUnavailable + } + + // Apply consistent retry/temporary/cooldown semantics for categories not + // already configured by their classifier. + applyCategoryDefaults(&ae.RetryRecommended, &ae.Temporary, &ae.CooldownTriggered, ae.Category) + + ae.UserMessage = userMessageForStageAndCategory(ae.Stage, ae.Category, rawAlert) + if providerMessageStatus == "recognized" || providerMessageStatus == "partially_redacted" || providerMessageStatus == "safe_unrecognized" { + ae.UserMessage = safeProviderMessage + } + return ae +} + +func durationPtr(duration time.Duration) *time.Duration { + if duration <= 0 { + return nil + } + return &duration +} + +func parseRetryAfter(value string, now time.Time) time.Duration { + value = strings.TrimSpace(value) + if seconds, err := strconv.ParseInt(value, 10, 64); err == nil { + if seconds > int64((24*time.Hour)/time.Second) { + return 24 * time.Hour + } + if seconds > 0 { + return time.Duration(seconds) * time.Second + } + return 0 + } + if at, err := http.ParseTime(value); err == nil && at.After(now) { + return min(at.Sub(now), 24*time.Hour) + } + return 0 +} + +// applyCategoryDefaults sets RetryRecommended, Temporary, and CooldownTriggered +// from the category when the classifier did not already set them. +func applyCategoryDefaults(retry, temporary, cooldown *bool, cat AuthErrorCategory) { + switch cat { + case AuthErrorTooManyAttempts, AuthErrorRetryLater, AuthErrorVerificationTemporarilyUnavailable, AuthErrorServiceUnavailable: + *retry, *temporary, *cooldown = true, true, true + case AuthErrorVerificationRequired: + *retry, *temporary, *cooldown = true, true, true + case AuthErrorInvalidCredentials, AuthErrorQRLoginNotAllowed, AuthErrorUnsupportedMethod: + *retry, *temporary, *cooldown = false, false, true + case AuthErrorQRContextExpired: + *retry, *temporary, *cooldown = true, false, false + default: + *retry, *temporary, *cooldown = false, false, false + } +} + +// AuthError returns the structured authentication error, or nil if the error +// did not carry structured provider metadata. +func (e *qrRPCError) AuthError() *AuthError { + return e.authError +} + +// classifySecondaryQrCode maps SecondaryQrCodeException exception codes to +// bridge-side categories. Code 100 is intentionally NOT treated as ordinary +// QR expiration by default; it maps to VERIFICATION_TEMPORARILY_UNAVAILABLE +// only when the provider alert confirms that meaning. +func classifySecondaryQrCode(code int, alert string) (AuthErrorCategory, bool, bool, bool) { + switch code { + case 0: + return AuthErrorServiceUnavailable, true, true, true + case 1: + return AuthErrorInvalidCredentials, false, false, true + case 2: + return AuthErrorVerificationRequired, true, true, true + case 3: + return AuthErrorQRLoginNotAllowed, false, false, true + case 4: + return AuthErrorVerificationRequired, true, true, true + case 5: + return AuthErrorRetryLater, true, true, true + case 100: + // Code 100 is INVALID_CONTEXT in the schema label map, but the actual + // provider alert determines the meaning. A confirmed temporary + // verification alert maps accordingly; otherwise use a neutral + // fallback rather than claiming QR expiration. + if SanitizeProviderAlert(alert) == "verification_temporarily_unavailable" { + return AuthErrorVerificationTemporarilyUnavailable, true, true, true + } + return AuthErrorQRContextExpired, true, false, false + case 101: + return AuthErrorUnsupportedMethod, false, false, true + default: + return AuthErrorUnknown, false, false, false + } +} + +// classifyTalkException maps TalkException codes to categories. +func classifyTalkException(code *int, reason, alert string) AuthErrorCategory { + if code == nil { + return AuthErrorUnknown + } + switch *code { + case 1: + return AuthErrorInvalidCredentials + case 83: + return AuthErrorInvalidCredentials + case 99: + return AuthErrorServiceUnavailable + default: + if SanitizeProviderAlert(reason) == "too_many_login_attempts" || + SanitizeProviderAlert(alert) == "too_many_login_attempts" { + return AuthErrorTooManyAttempts + } + return AuthErrorUnknown + } +} + +// userMessageForStageAndCategory returns a stage-aware user-facing message. +func userMessageForStageAndCategory(stage AuthErrorStage, cat AuthErrorCategory, alert string) string { + switch cat { + case AuthErrorVerificationTemporarilyUnavailable: + return "LINE verification is temporarily unavailable for this sign-in. Please wait before trying again." + case AuthErrorTooManyAttempts: + return "LINE is temporarily limiting login attempts. Please wait before trying again." + case AuthErrorRetryLater: + return "LINE asked us to try the sign-in again later. Please wait before trying again." + case AuthErrorQRLoginNotAllowed: + return "LINE is not allowing QR-code login for this account or session. Please sign in with your email and password instead." + case AuthErrorQRContextExpired: + return "This QR sign-in has expired. Start a new QR login." + case AuthErrorInvalidCredentials: + return "LINE rejected the sign-in credentials. Check your credentials and try again." + case AuthErrorVerificationRequired: + return "LINE requires additional verification. Please complete the verification prompt and try again." + case AuthErrorServiceUnavailable: + return "LINE is temporarily unavailable. Please try again later." + case AuthErrorUnsupportedMethod: + return "LINE does not support this authentication method. Please try a different sign-in method." + case AuthErrorNetworkUnavailable: + return "Unable to reach LINE right now. Please try again later." + default: + if stage == AuthStageQRTokenIssue { + return "LINE rejected the QR sign-in request. Please check the bridge logs or contact your administrator." + } + return "LINE rejected the login request, but did not provide a recognized reason. Please check the bridge logs or contact your administrator." + } +} + +// QRFailureDetail describes a rejected QR login request. Every field is a fixed +// schema label or an allowlisted category, never raw provider text. +type QRFailureDetail struct { + Method string + HTTPStatus int + ResponseCode int + ExceptionType string + ExceptionCode int + ExceptionLabel string + Reason string + Alert string +} + +// QRLoginFailureDetail extracts the sanitized details of a rejected QR login +// request. It reports false for errors that did not come from a QR request. +func QRLoginFailureDetail(err error) (QRFailureDetail, bool) { + var response *qrRPCError + if !errors.As(err, &response) { + return QRFailureDetail{}, false + } + detail := QRFailureDetail{ + Method: response.method, + HTTPStatus: response.httpStatus, + ExceptionType: response.exceptionType, + ExceptionLabel: response.exceptionLabel, + Reason: response.reason, + Alert: response.alert, + } + if response.code != nil { + detail.ResponseCode = *response.code + } + if response.exceptionCode != nil { + detail.ExceptionCode = *response.exceptionCode + } + return detail, true } func IsQRLoginPollExpired(err error) bool { var response *qrRPCError - return errors.As(err, &response) && response.code == 10052 && response.statusCode == 410 + return errors.As(err, &response) && response.code != nil && *response.code == 10052 && response.statusCode != nil && *response.statusCode == 410 } func (c *Client) CreatePinCode(ctx context.Context, session string) (string, error) { @@ -206,15 +513,37 @@ func (c *Client) callQRRPC(ctx context.Context, method, session string, pollTime } decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&wrapper) if resp.StatusCode != http.StatusOK || (wrapper.Code != nil && *wrapper.Code != 0) { - response := &qrRPCError{method: method, httpStatus: resp.StatusCode} - if wrapper.Code != nil { - response.code = *wrapper.Code - } + response := &qrRPCError{method: method, httpStatus: resp.StatusCode, code: wrapper.Code, retryAfter: parseRetryAfter(resp.Header.Get("Retry-After"), time.Now())} var details struct { - StatusCode int `json:"statusCode"` + StatusCode *int `json:"statusCode"` + Code *int `json:"code"` + Name string `json:"name"` + Reason string `json:"reason"` + Alert string `json:"alertMessage"` } if json.Unmarshal(wrapper.Data, &details) == nil { response.statusCode = details.StatusCode + response.exceptionCode = details.Code + // Provider strings may contain credentials. Retain fixed schema labels + // and allowlisted reasons only, never arbitrary message/reason values. + switch details.Name { + case "TalkException", "LoginException", "SecondaryQrCodeLoginException", "SecondaryQrCodeException": + response.exceptionType = details.Name + case "": + default: + response.exceptionType = "other" + } + response.reason, _ = SanitizeProviderMessage(details.Reason) + response.alert, response.alertStatus = SanitizeProviderMessage(details.Alert) + if details.Alert == "" { + response.alertStatus = "" + } + if details.Name == "SecondaryQrCodeException" && details.Code != nil { + response.exceptionLabel = secondaryQrCodeErrorLabels[*details.Code] + } + response.authError = response.buildAuthError( + details.Name, details.Code, details.Reason, details.Alert, + ) } return response } diff --git a/pkg/line/qr_contract_test.go b/pkg/line/qr_contract_test.go new file mode 100644 index 00000000..cbe7f4da --- /dev/null +++ b/pkg/line/qr_contract_test.go @@ -0,0 +1,107 @@ +package line + +import ( + "context" + "encoding/json" + "io" + "net/http" + "reflect" + "strings" + "testing" + "time" +) + +// These fixtures verify the bridge's request contract, not LINE's acceptance of +// a live login. No live authentication is performed by this test. +func TestQRRequestContractAndMIDRepresentations(t *testing.T) { + for _, mid := range []string{ + "u0123456789abcdef0123456789abcdef", + "U0123456789ABCDEF0123456789ABCDEF", + "UAbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-aDEFg", + } { + t.Run(mid, func(t *testing.T) { + client := NewClient("") + var calls []string + client.HTTPClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + method := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:] + calls = append(calls, method) + service := "SecondaryQrCodeLoginService" + poll := method == "checkQrCodeVerified" || method == "checkPinCodeVerified" + if poll { + service = "SecondaryQrCodeLoginPermitNoticeService" + } + if req.Method != "POST" || req.URL.Host != "line-chrome-gw.line-apps.com" || req.URL.Path != "/api/talk/thrift/LoginQrCode/"+service+"/"+method { + t.Fatal("QR endpoint contract changed") + } + if req.Header.Get("x-hmac") == "" || req.Header.Get("x-line-chrome-version") != ExtensionVersion || req.Header.Get("x-line-access") != "" || req.Header.Get("Cookie") != "" { + t.Fatal("missing signing header or existing session credentials sent to QR login") + } + if poll && req.Header.Get("X-Line-Session-ID") != "fixture-session" || !poll && req.Header.Get("X-Line-Session-ID") != "" { + t.Fatal("wrong QR session header scope") + } + var args []map[string]any + if err := json.NewDecoder(req.Body).Decode(&args); err != nil || len(args) != 1 { + t.Fatal("invalid QR argument list") + } + want := map[string]any{"authSessionId": "fixture-session"} + body := `{"code":0,"data":{}}` + status := 200 + switch method { + case "createSession": + want = map[string]any{} + body = `{"code":0,"data":{"authSessionId":"fixture-session"}}` + case "createQrCode": + body = `{"code":0,"data":{"callbackUrl":"line://au/q/fixture","longPollingIntervalSec":30,"longPollingMaxCount":1}}` + case "checkQrCodeVerified": + if req.Header.Get("X-LST") != "30000" { + t.Fatal("QR poll duration changed") + } + case "verifyCertificate": + want["certificate"] = "" + status = 400 + body = `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":2}}` + case "createPinCode": + body = `{"code":0,"data":{"pinCode":"123456"}}` + case "checkPinCodeVerified": + if req.Header.Get("X-LST") != "110000" { + t.Fatal("PIN poll duration changed") + } + case "qrCodeLoginV2": + want["systemName"], want["modelName"], want["autoLoginIsRequired"] = "CHROMEOS", "CHROME", false + body = `{"code":0,"data":{"mid":"` + mid + `","tokenV3IssueResult":{"accessToken":"fixture-token"}}}` + default: + t.Fatalf("unexpected QR operation %s", method) + } + if !reflect.DeepEqual(args[0], want) { + t.Fatalf("%s args=%v want=%v", method, args[0], want) + } + return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil + }) + ctx := context.Background() + session, _, err := client.StartQRLogin(ctx) + if err != nil { + t.Fatal(err) + } + defer client.LoginAttempt.Close() + if err = client.CheckQRCodeVerifiedContext(ctx, session, 30*time.Second); err != nil { + t.Fatal(err) + } + if ok, err := client.VerifyQRCertificate(ctx, session, ""); err != nil || ok { + t.Fatalf("certificate result=%v,%v", ok, err) + } + if pin, err := client.CreatePinCode(ctx, session); err != nil || pin != "123456" { + t.Fatal("PIN issuance failed") + } + if err = client.CheckPinCodeVerifiedContext(ctx, session); err != nil { + t.Fatal(err) + } + result, err := client.QRCodeLoginV2(ctx, session) + if err != nil || result.Mid != mid || result.AuthToken != "fixture-token" || client.AccessToken != "fixture-token" { + t.Fatalf("identity/token altered: result=%+v err=%v", result, err) + } + if strings.Join(calls, ",") != "createSession,createQrCode,checkQrCodeVerified,verifyCertificate,createPinCode,checkPinCodeVerified,qrCodeLoginV2" { + t.Fatalf("unexpected sequence: %v", calls) + } + }) + } +} diff --git a/pkg/line/qr_error_test.go b/pkg/line/qr_error_test.go new file mode 100644 index 00000000..2735df07 --- /dev/null +++ b/pkg/line/qr_error_test.go @@ -0,0 +1,215 @@ +package line + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" + + gen "github.com/beeper/line/pkg" +) + +func TestQRErrorDetailsAndRedaction(t *testing.T) { + for _, tc := range []struct { + name, body, want string + status int + expired bool + }{ + {"nested rejection", `{"code":10051,"message":"PRIVATE","data":{"name":"TalkException","code":35,"reason":"blocked user","accessToken":"PRIVATE","authSessionId":"PRIVATE"}}`, "exception code 35, exception type TalkException, reason blocked user", 400, false}, + {"HTTP success API failure", `{"code":10051,"data":{"name":"SecondaryQrCodeLoginException","code":0,"reason":"PRIVATE"}}`, "exception code 0, exception type SecondaryQrCodeLoginException", 200, false}, + {"unknown exception", `{"code":10051,"data":{"name":"PRIVATE","code":99,"reason":"PRIVATE"}}`, "exception code 99, exception type other", 400, false}, + {"QR exception with private alert", `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":100,"alertMessage":"PRIVATE"}}`, "exception code 100, exception type SecondaryQrCodeException", 400, false}, + {"QR exception with allowlisted alert", `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":5,"alertMessage":"Too many login attempts"}}`, "exception code 5, exception type SecondaryQrCodeException, alert Too many login attempts", 400, false}, + {"poll expired", `{"code":10052,"data":{"statusCode":410}}`, "code 10052, status 410", 400, true}, + {"missing detail", `{"code":10051,"data":{}}`, "QR qrCodeLoginV2 failed: HTTP 400, code 10051", 400, false}, + {"malformed", `PRIVATE`, "QR qrCodeLoginV2 failed: HTTP 400", 400, false}, + {"invalid numeric detail", `{"code":10051,"data":{"code":"PRIVATE"}}`, "QR qrCodeLoginV2 failed: HTTP 400, code 10051", 400, false}, + } { + t.Run(tc.name, func(t *testing.T) { + runner, err := gen.NewRunner() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + client := NewClient("") + client.LoginAttempt = &LoginAttempt{Runner: runner, Context: ctx, cancel: cancel} + defer client.LoginAttempt.Close() + client.HTTPClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + if !strings.HasSuffix(req.URL.Path, "/qrCodeLoginV2") { + t.Fatal("unexpected method") + } + return &http.Response{StatusCode: tc.status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(tc.body))}, nil + }) + result, err := client.QRCodeLoginV2(ctx, "PRIVATE-SESSION") + var qrErr *qrRPCError + if result != nil || !errors.As(err, &qrErr) || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("result=%v error=%v", result, err) + } + if strings.Contains(err.Error(), "PRIVATE") || strings.Contains(err.Error(), "status 0") { + t.Fatalf("unsafe or misleading error: %v", err) + } + if IsQRLoginPollExpired(err) != tc.expired { + t.Fatal("expiry classification changed") + } + if client.AccessToken != "" { + t.Fatal("failed login installed token") + } + }) + } +} + +func TestQRLoginFailureDetailIgnoresOtherErrors(t *testing.T) { + detail, ok := QRLoginFailureDetail(errors.New("not a QR error")) + if ok || detail.Method != "" { + t.Fatal("unrelated error was reported as a QR failure") + } +} + +func TestParseRetryAfter(t *testing.T) { + now := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC) + if got := parseRetryAfter("17", now); got != 17*time.Second { + t.Fatalf("seconds Retry-After = %s", got) + } + if got := parseRetryAfter(now.Add(2*time.Minute).Format(http.TimeFormat), now); got != 2*time.Minute { + t.Fatalf("date Retry-After = %s", got) + } + if got := parseRetryAfter("invalid", now); got != 0 { + t.Fatalf("invalid Retry-After = %s", got) + } +} + +func TestHTTPErrorPreservesRetryAfter(t *testing.T) { + response := &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Retry-After": []string{"45"}}, + } + err := newHTTPResponseError(response, "API error %d", response.StatusCode) + if got := HTTPRetryAfter(err); got != 45*time.Second { + t.Fatalf("HTTP Retry-After = %s, want 45s", got) + } +} + +func TestQRAuthErrorStage(t *testing.T) { + for method, want := range map[string]AuthErrorStage{ + "createSession": AuthStageQRCreate, + "createQrCode": AuthStageQRCreate, + "verifyCertificate": AuthStageQRVerify, + "checkQrCodeVerified": AuthStageQRVerify, + "createPinCode": AuthStageQRVerify, + "checkPinCodeVerified": AuthStageQRVerify, + "qrCodeLoginV2": AuthStageQRTokenIssue, + } { + err := (&qrRPCError{method: method, httpStatus: http.StatusBadRequest}).buildAuthError("SecondaryQrCodeException", nil, "", "") + if err.Stage != want { + t.Errorf("method %s has stage %s, want %s", method, err.Stage, want) + } + } +} + +func TestQRRPCErrorAuthError(t *testing.T) { + for _, tc := range []struct { + name string + body string + status int + wantCategory AuthErrorCategory + wantCooldownTriggered bool + wantRetryRecommended bool + wantProviderAlertStatus string + wantProviderReason string + retryAfter string + wantRetryAfter time.Duration + }{ + { + name: "HTTP 429", + body: `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":100}}`, + status: http.StatusTooManyRequests, + wantCategory: AuthErrorTooManyAttempts, + wantCooldownTriggered: true, + wantRetryRecommended: true, + wantProviderAlertStatus: "absent", + wantProviderReason: "absent", + retryAfter: "17", + wantRetryAfter: 17 * time.Second, + }, + { + name: "retry later", + body: `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":5}}`, + status: http.StatusBadRequest, + wantCategory: AuthErrorRetryLater, + wantCooldownTriggered: true, + wantRetryRecommended: true, + wantProviderAlertStatus: "absent", + wantProviderReason: "absent", + }, + { + name: "verification temporarily unavailable", + body: `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":100,"alertMessage":"Verification is temporarily unavailable"}}`, + status: http.StatusBadRequest, + wantCategory: AuthErrorVerificationTemporarilyUnavailable, + wantCooldownTriggered: true, + wantRetryRecommended: true, + wantProviderAlertStatus: "recognized", + wantProviderReason: "verification_temporarily_unavailable", + }, + { + name: "QR login not allowed", + body: `{"code":10051,"data":{"name":"SecondaryQrCodeException","code":3}}`, + status: http.StatusBadRequest, + wantCategory: AuthErrorQRLoginNotAllowed, + wantCooldownTriggered: true, + wantRetryRecommended: false, + wantProviderAlertStatus: "absent", + wantProviderReason: "absent", + }, + } { + t.Run(tc.name, func(t *testing.T) { + runner, err := gen.NewRunner() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + client := NewClient("") + client.LoginAttempt = &LoginAttempt{Runner: runner, Context: ctx, cancel: cancel} + defer client.LoginAttempt.Close() + client.HTTPClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + headers := make(http.Header) + if tc.retryAfter != "" { + headers.Set("Retry-After", tc.retryAfter) + } + return &http.Response{StatusCode: tc.status, Header: headers, Body: io.NopCloser(strings.NewReader(tc.body))}, nil + }) + _, err = client.QRCodeLoginV2(ctx, "session") + cancel() + + var qrErr *QRRPCError + if !errors.As(err, &qrErr) { + t.Fatalf("error is not a QRRPCError: %v", err) + } + ae := qrErr.AuthError() + if ae == nil { + t.Fatal("AuthError was nil") + } + if ae.Category != tc.wantCategory { + t.Fatalf("category = %v, want %v", ae.Category, tc.wantCategory) + } + if ae.CooldownTriggered != tc.wantCooldownTriggered { + t.Fatalf("cooldownTriggered = %v, want %v", ae.CooldownTriggered, tc.wantCooldownTriggered) + } + if ae.RetryRecommended != tc.wantRetryRecommended { + t.Fatalf("retryRecommended = %v, want %v", ae.RetryRecommended, tc.wantRetryRecommended) + } + if ae.ProviderAlertStatus != tc.wantProviderAlertStatus { + t.Fatalf("providerAlertStatus = %q, want %q", ae.ProviderAlertStatus, tc.wantProviderAlertStatus) + } + if ae.ProviderReason != tc.wantProviderReason { + t.Fatalf("providerReason = %q, want %q", ae.ProviderReason, tc.wantProviderReason) + } + if ae.RetryAfter == nil && tc.wantRetryAfter > 0 || ae.RetryAfter != nil && *ae.RetryAfter != tc.wantRetryAfter { + t.Fatalf("retryAfter = %v, want %s", ae.RetryAfter, tc.wantRetryAfter) + } + }) + } +} diff --git a/pkg/line/send_message_response_test.go b/pkg/line/send_message_response_test.go new file mode 100644 index 00000000..fff1be9b --- /dev/null +++ b/pkg/line/send_message_response_test.go @@ -0,0 +1,34 @@ +package line + +import ( + "io" + "net/http" + "strings" + "testing" +) + +func TestSendMessageRejectsMissingAcknowledgement(t *testing.T) { + for _, body := range []string{`{"code":0}`, `{"code":0,"data":null}`, `{"code":0,"data":{}}`} { + t.Run(body, func(t *testing.T) { + client := NewClient("test-token") + client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}, nil + }) + msg, err := client.SendMessage(1, &Message{To: "u0123456789abcdef0123456789abcdef", Text: "test"}) + if err == nil || msg != nil { + t.Fatalf("missing acknowledgement accepted: %+v, %v", msg, err) + } + }) + } +} + +func TestSendMessagePreservesAuthenticationError(t *testing.T) { + client := NewClient("test-token") + client.HTTPClient.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":10051,"message":"RESPONSE_ERROR","data":{"name":"TalkException","code":119,"reason":"Access token refresh required"}}`))}, nil + }) + _, err := client.SendMessage(1, &Message{To: "u0123456789abcdef0123456789abcdef", Text: "test"}) + if !IsAuthError(err) { + t.Fatalf("send error lost auth classification: %v", err) + } +} diff --git a/pkg/line/user_mid.go b/pkg/line/user_mid.go new file mode 100644 index 00000000..1e5ea44d --- /dev/null +++ b/pkg/line/user_mid.go @@ -0,0 +1,25 @@ +package line + +import "strings" + +// NormalizeUserMID preserves case-sensitive Chrome gateway IDs and canonicalizes +// legacy hexadecimal MIDs, which are case-insensitive. +func NormalizeUserMID(mid string) (string, bool) { + if len(mid) == 44 && mid[0] == 'U' { + for _, ch := range mid[1:] { + if !(ch >= 'A' && ch <= 'Z' || ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9' || ch == '_' || ch == '-') { + return mid, false + } + } + return mid, true + } + if len(mid) != 33 || mid[0] != 'u' && mid[0] != 'U' { + return mid, false + } + for _, ch := range mid[1:] { + if !(ch >= '0' && ch <= '9' || ch >= 'a' && ch <= 'f' || ch >= 'A' && ch <= 'F') { + return mid, false + } + } + return strings.ToLower(mid), true +} diff --git a/pkg/line/user_mid_test.go b/pkg/line/user_mid_test.go new file mode 100644 index 00000000..4e4d3421 --- /dev/null +++ b/pkg/line/user_mid_test.go @@ -0,0 +1,32 @@ +package line + +import ( + "strings" + "testing" +) + +func TestNormalizeUserMID(t *testing.T) { + opaque := "U" + "AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-aDEFg" + legacy := "u0123456789abcdef0123456789abcdef" + for _, tc := range []struct { + name, input, want string + valid bool + }{ + {"opaque", opaque, opaque, true}, + {"case-sensitive opaque", "U" + strings.ToLower(opaque[1:]), "U" + strings.ToLower(opaque[1:]), true}, + {"legacy", legacy, legacy, true}, + {"legacy uppercase", strings.ToUpper(legacy), legacy, true}, + {"lowercase opaque prefix", strings.ToLower(opaque), "", false}, + {"wrong kind", "C" + opaque[1:], "", false}, + {"short", opaque[:43], "", false}, + {"padding", opaque[:43] + "=", "", false}, + {"nonhex legacy", legacy[:32] + "g", "", false}, + } { + t.Run(tc.name, func(t *testing.T) { + got, valid := NormalizeUserMID(tc.input) + if valid != tc.valid || valid && got != tc.want || !valid && got != tc.input { + t.Fatalf("NormalizeUserMID(%q) = %q, %v", tc.input, got, valid) + } + }) + } +} diff --git a/pkg/ltsm/memory.go b/pkg/ltsm/memory.go new file mode 100644 index 00000000..40565230 --- /dev/null +++ b/pkg/ltsm/memory.go @@ -0,0 +1,19 @@ +package ltsm + +// AllocMemory allocates bytes from the module's native linear-memory heap and +// returns their offset in ModuleMem. It returns zero if the fixed heap cannot +// satisfy the allocation. Like the other low-level Runtime methods, callers +// must serialize access to a runtime. +// +// This complements ModuleMem for resource-pressure fixtures: tests can reserve +// real native allocations without repeatedly decoding cryptographic keys. It +// does not replace the allocator, resize the heap, or synthesize an abort. +func (rt *Runtime) AllocMemory(size uint32) uint32 { + return rt.imp.malloc(size) +} + +// FreeMemory releases an allocation made by AllocMemory on this runtime. The +// caller must not use the allocation afterwards or free it more than once. +func (rt *Runtime) FreeMemory(ptr uint32) { + rt.imp.free(ptr) +} diff --git a/pkg/ltsm/memory_test.go b/pkg/ltsm/memory_test.go new file mode 100644 index 00000000..fc74bed5 --- /dev/null +++ b/pkg/ltsm/memory_test.go @@ -0,0 +1,55 @@ +package ltsm + +import "testing" + +func TestRuntimeNativeMemoryAllocation(t *testing.T) { + rt, err := NewRuntime() + if err != nil { + t.Fatal(err) + } + t.Cleanup(rt.Close) + const size = 64 * 1024 + first, second := rt.AllocMemory(size), rt.AllocMemory(size) + if first == 0 || second == 0 || uint64(first)+size > uint64(len(rt.ModuleMem())) || uint64(second)+size > uint64(len(rt.ModuleMem())) { + t.Fatalf("invalid native allocations: %d, %d", first, second) + } + if first < second+size && second < first+size { + t.Fatal("native allocations overlap") + } + rt.ModuleMem()[first] = 42 + rt.ModuleMem()[second] = 17 + if rt.ModuleMem()[first] != 42 { + t.Fatal("writing one allocation corrupted another") + } + rt.FreeMemory(first) + rt.FreeMemory(second) +} + +func TestRuntimeNativeMemoryExhaustionAndReuse(t *testing.T) { + rt, err := NewRuntime() + if err != nil { + t.Fatal(err) + } + t.Cleanup(rt.Close) + const size = 64 * 1024 + var allocated []uint32 + for range len(rt.ModuleMem()) / size { + ptr := rt.AllocMemory(size) + if ptr == 0 { + break + } + allocated = append(allocated, ptr) + } + if len(allocated) == 0 || rt.AllocMemory(size) != 0 { + t.Fatal("native allocator did not report fixed-heap exhaustion") + } + for _, ptr := range allocated { + rt.FreeMemory(ptr) + } + // Coalescing the freed blocks must make a large real allocation possible. + ptr := rt.AllocMemory(uint32(len(allocated) * size)) + if ptr == 0 { + t.Fatal("freed native memory was not reusable") + } + rt.FreeMemory(ptr) +} diff --git a/pkg/ltsm/vector_test.go b/pkg/ltsm/vector_test.go index bbd8a0dd..18fd881e 100644 --- a/pkg/ltsm/vector_test.go +++ b/pkg/ltsm/vector_test.go @@ -7,8 +7,8 @@ import ( "encoding/binary" "encoding/hex" "encoding/json" - "fmt" "os" + "path/filepath" "testing" "golang.org/x/crypto/curve25519" @@ -17,6 +17,7 @@ import ( // TestGenerateV2TestVector generates a complete V2 encryption test vector // with all intermediate values exposed for cross-validation. func TestGenerateV2TestVector(t *testing.T) { + t.Parallel() // Use deterministic keys so the test vector is reproducible senderPriv, _ := hex.DecodeString("a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4") receiverPriv, _ := hex.DecodeString("4b66e9d4d1b4673c5ad22691957d6af5c11b6421e0ea01d42ca4169e7918ba0d") @@ -196,17 +197,13 @@ func TestGenerateV2TestVector(t *testing.T) { jsonBytes, _ := json.MarshalIndent(vec, "", " ") t.Logf("\n--- JSON Test Vector ---\n%s", string(jsonBytes)) - // Write to file - if err := os.WriteFile("/tmp/ltsm_v2_test_vector.json", jsonBytes, 0644); err != nil { - t.Logf("Warning: could not write test vector file: %v", err) - } else { - t.Logf("Test vector written to /tmp/ltsm_v2_test_vector.json") - } + writeTestVector(t, "ltsm_v2_test_vector.json", jsonBytes) } // TestGenerateV2DeterministicVector generates a V2 vector with a deterministic // salt and nonce (by encrypting manually) for truly reproducible test vectors. func TestGenerateV2DeterministicVector(t *testing.T) { + t.Parallel() // Deterministic keys senderPriv, _ := hex.DecodeString("a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4") receiverPriv, _ := hex.DecodeString("4b66e9d4d1b4673c5ad22691957d6af5c11b6421e0ea01d42ca4169e7918ba0d") @@ -367,12 +364,16 @@ func TestGenerateV2DeterministicVector(t *testing.T) { } jsonBytes, _ := json.MarshalIndent(vec, "", " ") - fmt.Println(string(jsonBytes)) + t.Logf("\n--- JSON Test Vector ---\n%s", jsonBytes) + + writeTestVector(t, "ltsm_v2_deterministic_vector.json", jsonBytes) +} - // Write to file - if err := os.WriteFile("/tmp/ltsm_v2_deterministic_vector.json", jsonBytes, 0644); err != nil { - t.Logf("Warning: could not write test vector file: %v", err) - } else { - t.Logf("Test vector written to /tmp/ltsm_v2_deterministic_vector.json") +func writeTestVector(t *testing.T, name string, data []byte) { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatalf("write test vector: %v", err) } + t.Logf("Test vector written to %s (JSON is also retained in test output)", path) } diff --git a/pkg/runner_secret_test.go b/pkg/runner_secret_test.go index 3ce7a194..42f0a34f 100644 --- a/pkg/runner_secret_test.go +++ b/pkg/runner_secret_test.go @@ -2,6 +2,7 @@ package line import ( "bytes" + "crypto/sha256" "encoding/base64" "errors" "fmt" @@ -185,8 +186,16 @@ func TestGetSignatureEvictsSigningContexts(t *testing.T) { } r := newSigningTestRunner(t) wantByToken := make(map[string]string) - for i := 0; i < 6000; i++ { + t.Cleanup(r.Close) + // Three complete rotations exercise initial fill, eviction and recreation + // of every token. Thousands of white-box key derivations test throughput, + // not additional cache states, and exceed the race suite's time budget. + for i := 0; i < 3*(signingCacheCapacity+2); i++ { token := fmt.Sprintf("token-%d", i%(signingCacheCapacity+2)) + var victim [sha256.Size]byte + if len(r.signingLRU) == signingCacheCapacity { + victim = r.signingLRU[0] + } signature, err := r.GetSignature("/api/test", "{}", token) if err != nil { t.Fatalf("signature %d failed: %v", i+1, err) @@ -196,10 +205,39 @@ func TestGetSignatureEvictsSigningContexts(t *testing.T) { } else if signature != want { t.Fatalf("signature for %q changed: got %q want %q", token, signature, want) } + if got := len(r.signingContexts); got != min(i+1, signingCacheCapacity) { + t.Fatalf("signature %d: cache size %d", i+1, got) + } + if len(r.signingLRU) != len(r.signingContexts) { + t.Fatal("LRU and context map sizes differ") + } + if _, retained := r.signingContexts[victim]; retained { + t.Fatal("least recently used signing context was not evicted") + } + key := sha256.Sum256([]byte(token)) + if r.signingLRU[len(r.signingLRU)-1] != key { + t.Fatal("new signing context is not most recently used") + } } if got := len(r.signingContexts); got != signingCacheCapacity { t.Fatalf("unexpected signing cache size: got %d want %d", got, signingCacheCapacity) } + // Exercise the refresh boundary with a populated, churned cache without + // deriving thousands of keys just to advance the successful-call counter. + oldRuntime := r.signingRT + r.signingCalls = signingRefreshCalls - 1 + for i := 0; i < 2; i++ { + got, err := r.GetSignature("/api/test", "{}", "token-5") + if err != nil || got != wantByToken["token-5"] { + t.Fatalf("signature across refresh = %q, %v", got, err) + } + if (r.signingRT != oldRuntime) != (i == 1) { + t.Fatal("signing runtime refreshed at the wrong call boundary") + } + } + if len(r.signingContexts) != 1 || len(r.signingLRU) != 1 { + t.Fatal("refresh retained stale signing contexts") + } } func TestGetSignatureRecoversFromExhaustedRuntime(t *testing.T) { @@ -214,6 +252,23 @@ func TestGetSignatureRecoversFromExhaustedRuntime(t *testing.T) { oldRuntime := r.signingRT e2eeMemory := bytes.Clone(r.rt.ModuleMem()) + // Establish heap pressure with the real native allocator, rather than + // spending most of the test repeatedly decoding the same token. Keep the + // final real key allocations and the original abort/recovery assertions: + // malloc exhaustion alone must not be mistaken for a cryptographic abort. + const reserveSize = 64 * 1024 + reserved := 0 + for range len(oldRuntime.ModuleMem()) / reserveSize { + if oldRuntime.AllocMemory(reserveSize) == 0 { + break + } + reserved += reserveSize + } + if reserved == 0 { + t.Fatal("failed to establish native heap pressure") + } + t.Logf("reserved %d native heap bytes before real key allocation", reserved) + // Retain native keys until the fixed WASM heap is exhausted, so the next // cache miss exercises real allocator failure during key derivation. func() { diff --git a/readme/DEBUGGING.md b/readme/DEBUGGING.md new file mode 100644 index 00000000..9cecab12 --- /dev/null +++ b/readme/DEBUGGING.md @@ -0,0 +1,160 @@ +# Direct chats, synchronization, outbound messages and QR login + +## Choosing a login method + +In the bot management room, use `login` or `login help` to list available +methods. `login email` starts email/password login; `login qr` starts QR login +when `network.qr_login` is enabled. Method names are case-insensitive. In other +rooms, prefix the command (for example, `!line login qr`). Fully qualified +mautrix-go flow IDs remain accepted for provisioning and existing clients. + +## Starting a direct chat + +In the bot management room: + +```text +search carol.id +start-chat carol.id +start-chat u0123456789abcdef0123456789abcdef +``` + +These are illustrative identifiers, not live test accounts. In another Matrix +room, use the configured command prefix (default `!line`), for example +`!line start-chat carol.id`. With several LINE logins, the framework also accepts +`start-chat `. + +Public LINE IDs use the existing `TalkService.findContactByUserid` RPC. The +bridge validates the returned user MID before creating a ghost or portal. User +MIDs are `u` followed by 32 hexadecimal digits; surrounding whitespace and input +case are normalized. A direct MID uses cached contact data or `getContactsV2`, +with the existing BuddyService fallback for official accounts. The bridge never +chooses a recipient by display name. `search` can still return display-name +matches when there is no exact ID match, but `start-chat` cannot use that fallback. + +Both commands share exact identifier resolution. A public-ID result already +contains the contact profile: it is reused for ghost/DM information rather than +re-fetching the same contact. A public ID is resolved afresh on each request so a +reassigned public ID cannot use a stale ID-to-MID alias cache. + +The former implementation treated every argument as a MID, created a portal +record immediately, and ignored the error returned by `GetChatInfo`. An argument +beginning with `c` was then routed to `getChats`; an empty result produced +`chat not found`. Framework room creation retried this missing chat-info lookup +and reported `Failed to create portal room`. + +The corrected response contains the canonical MID, login-scoped portal key and +complete DM information. bridgev2 retains responsibility for its locked portal +cache, database identity, room-creation event loop, membership checks and reuse. +Lookup-only requests do not create portal records. Failed remote lookups create +neither invalid portal records nor public-ID aliases. If Matrix room creation +fails later, bridgev2 retains one valid canonical row with no room ID for retry; +deleting it blindly would race another request. Leaving an existing room follows +bridgev2's existing orphaned-DM cleanup/recreation behavior. + +Historical invalid public-ID portal records are not migrated or deleted +automatically. Review such records separately before any production cleanup. + +## Why many conversations may appear + +Neither exact ID resolution nor its error path calls conversation synchronization. +The search-only display-name fallback enumerates contacts, not conversations, and +does not create portal rooms. Authentication recovery on these calls refreshes or +re-authenticates the session without invoking `Connect` or a broad chat sync. + +Existing legitimate broad synchronization sources are: + +* `LineClient.Connect`: group synchronization, DM synchronization and recent-message + prefetch on connection/startup. +* LINE SSE `fullSync`: refreshes block state, group/DM state and recent messages; + this can occur when server/client revision state requires reconciliation. +* Recent-message backfill containing system events: re-fetches current group state + after replay so historical membership/name events cannot leave stale state. + +New `sync_trigger` log fields identify `connect`, `line_full_sync`, and +`backfill_state_restore`. Existing portal mappings, message deduplication rows, +LINE revision state and persisted chat-deletion cutoffs affect what those paths +create or skip. No legitimate sync path has been disabled. The reported temporal +association with `start-chat` is not sufficient to establish a causal connection. +The earlier #196/#189 integrations changed media processing, not these sync paths. + +## Diagnosing an outbound message + +Trace one Matrix event ID rather than retrying across unrelated rooms/accounts: + +1. Matrix connector filtering and decryption. Relevant messages include + `Dropping ... event from user with no permission to send events`, + `Dropping unencrypted event as encryption is configured to be required`, + `Decrypting received event`, and `Failed to decrypt event`. +2. Portal lookup and `FindPreferredLogin`. A login-scoped portal uses its receiver + login. A different Matrix sender cannot use that login unless an explicitly + configured relay permits it. `Failed to get user login to handle Matrix event` + distinguishes this stage. Working bot commands do not establish that every + portal event has the same sender/login association or encryption session. +3. `Handling outbound Matrix message for LINE`: the connector has been reached. + Correlation fields include `matrix_event_id`, `matrix_sender`, `line_login_id` + and `line_recipient`. The recipient is the portal's canonical remote ID. +4. Message conversion and Letter Sealing selection/encryption, then the signed + `TalkService.sendMessage` RPC. `Finished LINE API send attempt` records + `success` and, on acknowledgement, `line_message_id`. Message bodies, access + tokens and QR approval data are not included in these new diagnostics. +5. The returned LINE message ID becomes the bridge message record. bridgev2 sends + the corresponding success/error status. An API acknowledgement is not proof of + recipient-side delivery/read status. + +Three defects were reproduced with mock responses and fixed: the send RPC did not +inherit the Matrix request context; HTTP-200 error wrappers lost TalkException +codes required by existing recovery logic; and a missing acknowledgement could +produce success with an empty ID or a nil dereference. Authentication/permission +rules, supported encryption paths and inbound conversion are unchanged. + +The actual Element delivery incident still needs a failing event's correlated +stage/error/status to identify its cause. Useful evidence is the event ID, sender, +portal receiver/login ownership, and the messages above, with tokens, message +bodies and approval codes removed. Do not infer a Synapse connectivity failure +from this report; command delivery and MID-based portal creation already work. + +## QR login: separate state machine + +Enable `network.qr_login: true` in the runtime configuration, then use: + +```text +login dev.highest.matrix.line.qr_login +relogin dev.highest.matrix.line.qr_login +cancel +``` + +Outside the management room, prefix these with `!line` (or the configured prefix). +The bridge requests a session and QR callback, adds the login public key, and +returns a display-and-wait step. bridgev2 renders the QR image for Matrix. After +LINE scan approval, a reusable certificate may satisfy verification; otherwise +the user must enter/approve a PIN on the primary device. Only then does token +exchange occur. The bridge verifies the token's profile/MID and required E2EE +keys before persisting a login, and checks cancellation/account identity during +finalization. + +Overlapping Start/Wait operations are now rejected instead of consuming another +waiter's polling result or blocking indefinitely. Startup deadline errors retain +`context.DeadlineExceeded`, rather than always becoming `context.Canceled`. +Cancel still interrupts the underlying attempt without waiting on the operation +guard. Expired polling is retried only within the existing server count/attempt +deadline; exhausted verification requires a fresh login attempt and QR scan. +There is no new speculative refresh endpoint or device-approval bypass. + +V3 refresh tokens are persisted by the existing finalizer. If refresh fails for a +passwordless QR account, reconnect must request a new QR approval rather than +inventing email/password credentials. Mock tests cover this and ensure that a +token response without a usable required keychain does not replace the stored +session. These lifecycle fixes do not establish the cause of an unspecified live +QR scanning/token-exchange failure. Successful live QR login and reconnection +have not been validated in this workspace. + +## Test boundaries + +New tests use mock LINE HTTP responses and isolated SQLite databases. They cover +shared resolution, invalid identities, lookup failures, cancellation/deadlines, +canonical portal-cache concurrency, existing-room reuse, creation failure/retry, +reopening after leaving, outbound ownership/routing/error propagation and QR state +transitions. The production PostgreSQL database and live Matrix/LINE accounts are +not used. Full live command concurrency, recipient delivery, and successful live +QR/session restoration require operator validation after deployment; mocked tests +are not a claim that those account-specific workflows have been verified. diff --git a/readme/QR_REGRESSION_AUDIT.md b/readme/QR_REGRESSION_AUDIT.md new file mode 100644 index 00000000..8447e64f --- /dev/null +++ b/readme/QR_REGRESSION_AUDIT.md @@ -0,0 +1,161 @@ +# QR/session regression audit — October 21, 2026 + +## Scope and evidence boundary + +The operator reported that QR login worked before deployment of the public-ID +repair, and that iOS now displays an error after entering the PIN. The bridge +reports `qrCodeLoginV2`, HTTP 400, wrapper code 10051, nested exception code 100, +exception type `other`. The exact iOS error and LINE app version are still needed. + +The live lookup repair is confirmed by successful resolve/search and start-chat +lookup records. This does not establish that authentication or outbound messaging +works end to end. No standalone token probe, login attempt or remote configuration +change was performed during this audit. + +## Compared revisions + +* `7615af2`: executable installed when this troubleshooting session began; QR + login was completed by the operator before diagnostic deployment. +* `160ac20`: lookup diagnostic build, which reconnected that saved login. +* `05a1607`: opaque-ID repair; no QR, login, token recovery, SSE or send-path edits. +* `009d81c`: nested QR error reporting; changes only response error representation + and equivalent expired-poll classification, not outgoing request construction. + +`pkg/connector/login_qr.go` and `finishLogin` did not change across these builds. +The QR create-session, create-code, certificate, PIN, poll and token-issuance +request construction in `pkg/line/qr.go` also did not change. Header version, +authentication-session arguments, signing input, endpoint selection and the +`autoLoginIsRequired` value were retained. + +`TestQRRequestContractAndMIDRepresentations` runs the mocked seven-RPC QR sequence +and checks the method, URL, session continuity, argument values, header scope, +poll durations and unmodified returned account ID. It passes both on baseline +`160ac20` in an isolated worktree and on the current source. Fixtures include +lowercase and uppercase 33-byte hex IDs and a mixed-case 44-byte opaque ID. These +tests establish request-contract compatibility, not remote protocol acceptance. + +## Identity handling audit + +* `NormalizeUserMID` supports both legacy `u`/`U` + 32 hexadecimal digits and the + observed gateway `U` + 43 URL-safe ASCII characters. Legacy hex normalization + remains; opaque IDs are preserved exactly. +* Lookup input, returned contacts, contact-map keys, cache keys and portal IDs + retain that distinction. The normalization helper is not called by QR login. +* Login verifies the returned profile identity against the login response using + exact equality and preserves its bytes in stored metadata and login ID. +* `makeUserID`/`makePortalID` are string type conversions; send constructs the + recipient from the portal ID without lowercasing it. Lowercase temporaries in + chat classification only inspect prefixes; they do not replace the recipient. +* New mocked lookup-to-send tests verify legacy and opaque recipient bytes at the + outbound RPC boundary. Unsupported identity shape fails without invalidating the + login or creating a portal. Unknown shapes are not accepted merely by length or + prefix: that would undo wrong-recipient safeguards. + +No newly introduced identity transformation explaining QR rejection was found. +The observed QR failure occurs before `QRCodeLoginV2` returns a successful result, +before profile verification or lookup/portal validation runs. This does not prove +that the deployment or earlier diagnostic activity could not have affected remote +session state; the initiating cause of token invalidation remains unknown. + +## Account login rate limits + +The operator observed that the affected account could not complete a QR login +while the account had not been used for login attempts that day, and that a +later attempt succeeded. That is consistent with an account-level login rate +limit rather than a protocol or identity defect, and it matches the repeated +attempts made during this troubleshooting session. The hypothesis is not proven: +the rejected response reported a nested exception code of 100, which the public +`SecondaryQrCodeException` enum names `INVALID_CONTEXT`, and the response did +not carry an explicit rate-limit reason. + +The bridge now handles the rate limit explicitly instead of failing opaquely: + +* Rate limits are recognised from the signals LINE actually returns: HTTP 429, + the fixed "too many login attempts" reason, and the fixed `RETRY_LATER` code + of `SecondaryQrCodeException`. A verification or credential failure is not + treated as a rate limit. +* A rate-limited QR login is reported to the user as the standard + "Too many login attempts" error, and a "retry later" rejection as a request + to wait a few minutes, instead of a generic failure. +* After a rate-limited rejection the bridge stops starting new login attempts + for that account for 15 minutes (5 minutes for a "retry later" rejection) and + tells the user when to come back. Repeated attempts keep the account locked, + so the bridge waits instead of retrying. The wait is in-memory and per + account, and it is cleared once it expires. +* The same handling applies to the email login flow, because the limit is + enforced per account rather than per sign-in method. The automatic reconnect + after a bridge restart still makes a single attempt, because that is the only + way to restore a saved session, and it is reported the same way if the + account is still locked. + +The cooldown lengths are conservative defaults, not measured LINE values. The +bridge cannot know how long the account stays locked, so it waits and reports +the remaining time instead of guessing an exact unlock moment. + +## Structured authentication errors + +LINE login failures are now normalized into a structured `AuthError` model in +`pkg/line/auth_error.go` before they reach connector logic: + +* Provider-specific exception names, codes, reasons and alerts are classified + into stable categories such as `TOO_MANY_ATTEMPTS`, `RETRY_LATER`, + `VERIFICATION_TEMPORARILY_UNAVAILABLE`, `INVALID_CONTEXT`/`QR_CONTEXT_EXPIRED`, + `QR_LOGIN_NOT_ALLOWED`, etc. +* Provider text is never exposed directly. Fixed schema labels and a small + allowlist of safe reason strings are used; everything else is recorded as + `unrecognized`/`redacted`. +* Each `AuthError` carries flags (`retry_recommended`, `temporary`, + `user_action_required`, `bridge_failure`, `cooldown_triggered`) and a safe + `provider_alert_status` (`recognized`, `unrecognized`, `absent`). +* The connector uses these categories to pick user-facing messages, decide + whether a cooldown applies, and emit structured log fields. Raw error strings + still appear in logs only at the original wrapped error level. + +## Extended cooldown handling + +A confirmed "Verification is temporarily unavailable" alert now triggers its +own cooldown category (`loginRateLimitVerificationUnavailable`) with a +10-minute bridge-side wait. This prevents rapid retry loops when LINE indicates +verification is temporarily unavailable, while still allowing a fresh attempt +after the cooldown expires. The category is detected both from the structured +`AuthError` and, as a fallback, from the raw provider reason text during email +login. + +## Actual shortcomings corrected locally + +1. The diagnostic allowlist omitted `SecondaryQrCodeException`, and did not inspect + its `alertMessage` field. It now recognizes that fixed name and reports alerts + through the existing fixed login-reason allowlist; other alerts are redacted. +2. The existing session-invalidated notice asserted that another client connected. + A `V3_TOKEN_CLIENT_LOGGED_OUT` response does not identify an initiating device. + The notice and internal error now state session invalidation without attributing + it to another client. Invalidation/recovery behavior and key-preservation checks + are unchanged. + +These are diagnostic/user-facing corrections, not a verified fix for iOS PIN +rejection. Regression tests retain strict privacy and authentication-failure checks. + +## Public protocol references (not official LINE documentation) + +Two reverse-engineered definitions agree that **within SecondaryQrCodeException** +code 100 means `INVALID_CONTEXT`, and that the exception contains `alertMessage`: + +* https://github.com/DeachSword/CHRLINE-Thrift/blob/49aed159e08d8db368add63fddc6c63562187068/subs/Types.thrift +* https://github.com/DeachSword/CHRLINE-Thrift/blob/49aed159e08d8db368add63fddc6c63562187068/subs/Exceptions.thrift +* https://github.com/Shivelight/line-messenger-protocol/blob/65681da9301a65e6103ad441718fdc89e5aa46f0/thrift/SecondaryQrCodeServices/enums.thrift +* https://github.com/Shivelight/line-messenger-protocol/blob/65681da9301a65e6103ad441718fdc89e5aa46f0/thrift/SecondaryQrCodeServices/exceptions.thrift + +The live exception class was redacted to `other`; this mapping must therefore not +be represented as a confirmed interpretation of that response yet. Neither the +mapping nor an `INVALID_CONTEXT` label establishes why the context was rejected. +Android-oriented alternate secure-QR implementations are not evidence to change +this iOS/Chrome gateway flow without matching Chrome protocol evidence. + +## Required next observation + +Obtain the phone's exact non-sensitive error after PIN entry and its LINE version. +If another instrumented attempt is authorized, identify the exact fixed exception +class and safe alert category from the bridge's own login request. Do not reuse +the token in a separate client or request credentials/QR images. A controlled +baseline comparison would require explicit deployment approval; no rollback or +additional authentication attempt has been performed as part of this audit. diff --git a/scripts/check_test_coverage.py b/scripts/check_test_coverage.py new file mode 100755 index 00000000..47050e7a --- /dev/null +++ b/scripts/check_test_coverage.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Enforce measured package coverage floors without hiding generated coverage.""" +import argparse +from pathlib import Path +import sys + + +def read_profile(path): + lines = path.read_text().splitlines() + if not lines or lines[0] not in ("mode: set", "mode: count", "mode: atomic"): + raise ValueError("missing or invalid coverage mode") + packages = {} + seen = set() + for line in lines[1:]: + block, statements, count = line.rsplit(" ", 2) + if block in seen: + raise ValueError(f"duplicate coverage block: {block}") + seen.add(block) + filename, _ = block.rsplit(":", 1) + package = filename.rsplit("/", 1)[0] + statements, count = int(statements), int(count) + if statements < 0 or count < 0: + raise ValueError("negative statement or execution count") + # covered, total, non-generated covered, non-generated total + values = packages.setdefault(package, [0, 0, 0, 0]) + values[0] += statements if count else 0 + values[1] += statements + if not filename.endswith("/wbc_generated.go"): + values[2] += statements if count else 0 + values[3] += statements + if not packages: + raise ValueError("empty coverage profile") + return packages + + +def compare(current, baseline): + errors = [] + checks = [(package, current.get(package), expected) for package, expected in baseline.items()] + # New packages must not dilute aggregate coverage while every older package + # still meets its own floor. Keep generated and handwritten scopes separate. + checks.append(("all packages", [sum(row[i] for row in current.values()) for i in range(4)], + [sum(row[i] for row in baseline.values()) for i in range(4)])) + for package, actual, expected in checks: + if actual is None: + errors.append(f"missing package: {package}") + continue + for start, scope in ((0, "all"), (2, "non-generated")): + covered, total = actual[start:start+2] + old_covered, old_total = expected[start:start+2] + if covered < old_covered or covered * old_total < old_covered * total: + errors.append(f"{package} ({scope}): {covered}/{total}, baseline {old_covered}/{old_total}") + return errors + + +def read_baseline(path): + result = {} + for line in path.read_text().splitlines(): + if not line or line.startswith("#"): + continue + package, *counts = line.split() + if len(counts) != 4 or package in result: + raise ValueError("invalid or duplicate baseline package") + values = list(map(int, counts)) + if any(value < 0 for value in values) or values[0] > values[1] or values[2] > values[3]: + raise ValueError("invalid baseline counts") + result[package] = values + if not result: + raise ValueError("empty coverage baseline") + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("profile", type=Path) + parser.add_argument("--baseline", type=Path, default=Path(__file__).resolve().parents[1]/".github/coverage-baseline.tsv") + args = parser.parse_args() + try: + actual = read_profile(args.profile) + errors = compare(actual, read_baseline(args.baseline)) + except (OSError, ValueError) as exc: + parser.error(str(exc)) + for package, values in sorted(actual.items()): + percent = 100*values[0]/values[1] if values[1] else 0 + nongenerated = 100*values[2]/values[3] if values[3] else 0 + print(f"{package}: {percent:.3f}% all; {nongenerated:.3f}% non-generated") + if errors: + print("Coverage regressions:\n" + "\n".join(errors), file=sys.stderr) + return 1 + print("All package coverage floors passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/measure_tests.py b/scripts/measure_tests.py new file mode 100755 index 00000000..0f5a44cd --- /dev/null +++ b/scripts/measure_tests.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Measure cold builds and uncached tests serially on Linux (time, bwrap, /proc).""" +import argparse +import json +import os +from pathlib import Path +import resource +import subprocess +import time + + +def tree_stats(root): + processes = {} + for entry in Path('/proc').iterdir(): + if not entry.name.isdigit(): + continue + try: + stat = (entry / 'stat').read_text() + fields = stat[stat.rfind(')') + 2:].split() + processes[int(entry.name)] = (int(fields[1]), int(fields[21]) * os.sysconf('SC_PAGE_SIZE')) + except (OSError, ValueError, IndexError): + continue + selected = {root} + while True: + expanded = selected | {pid for pid, (parent, _) in processes.items() if parent in selected} + if expanded == selected: + break + selected = expanded + return sum(processes.get(pid, (0, 0))[1] for pid in selected), len(selected) + + +def summarize_tests(path): + counts = {'pass': 0, 'fail': 0, 'skip': 0} + tests, packages = [], [] + for raw in path.read_text().splitlines(): + try: + event = json.loads(raw) + except ValueError: + continue + action = event.get('Action') + if action in counts and event.get('Test'): + counts[action] += 1 + tests.append({k: event[k] for k in ('Package', 'Test', 'Action', 'Elapsed') if k in event}) + elif action in ('pass', 'fail', 'skip') and event.get('Package'): + packages.append({k: event[k] for k in ('Package', 'Action', 'Elapsed') if k in event}) + return {'counts': counts, 'packages': sorted(packages, key=lambda x: x.get('Elapsed', 0), reverse=True), + 'tests': sorted(tests, key=lambda x: x.get('Elapsed', 0), reverse=True)} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('label', help='New result-directory label; existing results are never overwritten') + parser.add_argument('--repo', type=Path, default=Path.cwd()) + parser.add_argument('--parallel', type=int, default=2) + parser.add_argument('--gomaxprocs', type=int, default=4) + parser.add_argument('--phases', nargs='*') + args = parser.parse_args() + if Path(args.label).name != args.label or args.label in ('.', '..'): + parser.error('label must be a directory name, not a path') + if args.parallel < 1 or args.gomaxprocs < 1: + parser.error('parallelism must be positive') + repo = args.repo.resolve() + if subprocess.check_output(['git', 'status', '--porcelain'], cwd=repo, text=True).strip(): + parser.error('commit source changes before measuring; the benchmark does not stash or discard work') + out = repo / '.opencode-work/test-optimization' / args.label + out.mkdir(parents=True, exist_ok=True) + env = os.environ.copy() + env.update(GOMAXPROCS=str(args.gomaxprocs), LC_ALL='C') + tmp = out / 'tmp' + tmp.mkdir(exist_ok=True) + env.update(TMPDIR=str(tmp), GOTMPDIR=str(tmp)) + cache = out / 'cache' + cache.mkdir(exist_ok=True) + env['GOCACHE'] = str(cache) + bins = out / 'bins' + for mode in ('normal', 'race', 'coverage'): + (bins / mode).mkdir(parents=True, exist_ok=True) + meta = {'commit': subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo, text=True).strip(), + 'go': subprocess.check_output(['go', 'version'], env=env, text=True).strip(), + 'parallel': args.parallel, 'gomaxprocs': args.gomaxprocs, + 'cpu_count': os.cpu_count(), 'platform': list(os.uname()), + 'environment': {k: env.get(k) for k in ('GOCACHE', 'GOPATH', 'GOTOOLCHAIN', 'GOWORK', 'CGO_ENABLED', 'CGO_CFLAGS', 'CGO_LDFLAGS')}, + 'cache_note': 'New per-label build cache; shared prepopulated module download cache. Tests use explicit -count to avoid result caching.', + 'tmp_note': 'bubblewrap redirects historical hard-coded /tmp vector output into this project-local tmp directory.'} + metadata_path = out / 'environment.json' + if metadata_path.exists(): + previous = json.loads(metadata_path.read_text()) + for key in ('commit', 'go', 'parallel', 'gomaxprocs'): + if previous[key] != meta[key]: + parser.error(f'{key} changed; choose a new result label') + else: + metadata_path.write_text(json.dumps(meta, indent=2) + '\n') + p = str(args.parallel) + full = ['-p', p, '-count=1', '-timeout=60m', '-json', './...'] + phases = [ + ('cold-normal-build', ['go', 'test', '-p', p, '-c', '-o', str(bins/'normal'), './...']), + ('warm-normal-build', ['go', 'test', '-p', p, '-c', '-o', str(bins/'normal'), './...']), + ('normal', ['go', 'test', *full]), + ('normal-repeat', ['go', 'test', '-p', p, '-count=2', '-shuffle=271', '-timeout=60m', '-json', './...']), + ('init-normal', ['go', 'test', '-p', p, '-count=1', '-run=^$', '-json', './...']), + ('profile-signing', [str(bins/'normal/pkg.test'), '-test.count=1', '-test.run=^TestGetSignature', '-test.v', '-test.timeout=60m', + '-test.cpuprofile='+str(out/'signing.cpu.pprof'), '-test.memprofile='+str(out/'signing.mem.pprof')]), + ('cold-race-build', ['go', 'test', '-p', p, '-race', '-c', '-o', str(bins/'race'), './...']), + ('warm-race-build', ['go', 'test', '-p', p, '-race', '-c', '-o', str(bins/'race'), './...']), + ('race', ['go', 'test', '-race', *full]), + ('init-race', ['go', 'test', '-race', '-p', p, '-count=1', '-run=^$', '-json', './...']), + ('cold-coverage-build', ['go', 'test', '-p', p, '-cover', '-covermode=atomic', '-c', '-o', str(bins/'coverage'), './...']), + ('coverage', ['go', 'test', '-covermode=atomic', '-coverprofile='+str(out/'coverage.out'), *full]), + ('cold-normal-suite', ['go', 'test', *full]), + ('cold-race-suite', ['go', 'test', '-race', *full]), + ('focus-repeat', ['go', 'test', '-race', '-p', p, '-count=10', '-shuffle=271', '-timeout=60m', '-json', + '-run=^(TestGetSignatureEvictsSigningContexts|TestGetLastOpRevisionContextCancellation)$', './pkg', './pkg/line']), + ] + valid_phases = {name for name, _ in phases} + if args.phases and set(args.phases) - valid_phases: + parser.error('unknown phase: ' + ', '.join(set(args.phases) - valid_phases)) + for name, cmd in phases: + if args.phases and name not in args.phases: + continue + if subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo, text=True).strip() != meta['commit'] or subprocess.check_output(['git', 'status', '--porcelain'], cwd=repo, text=True).strip(): + parser.error('source changed during measurement; preserve results and use a new label') + result_path = out / (name + '.summary.json') + if result_path.exists(): + if json.loads(result_path.read_text())['exit_status'] != 0: + parser.error(f'{name} previously failed; preserve it and use a new label for reruns') + print('Already measured: ' + name, flush=True) + continue + cwd = repo / 'pkg' if name == 'profile-signing' else repo + phase_env = env.copy() + if name in ('cold-normal-suite', 'cold-race-suite'): + cold_cache = out / (name + '-cache') + cold_cache.mkdir(exist_ok=True) + phase_env['GOCACHE'] = str(cold_cache) + wrapped = ['bwrap', '--bind', '/', '/', '--dev', '/dev', '--bind', str(tmp), '/tmp', '--chdir', str(cwd), '--', *cmd] + print('Starting ' + name + ': ' + ' '.join(cmd), flush=True) + started = time.monotonic() + load_start = os.getloadavg() + usage_before = resource.getrusage(resource.RUSAGE_CHILDREN) + peak_rss, peak_processes = 0, 0 + stdout_path = out / (name + '.jsonl') + with stdout_path.open('w') as stdout, (out / (name + '.stderr')).open('w') as stderr: + proc = subprocess.Popen(['/usr/bin/time', '-v', '-o', str(out/(name+'.time.txt')), *wrapped], cwd=repo, env=phase_env, stdout=stdout, stderr=stderr) + while proc.poll() is None: + rss, count = tree_stats(proc.pid) + peak_rss, peak_processes = max(peak_rss, rss), max(peak_processes, count) + time.sleep(0.1) + elapsed = time.monotonic() - started + usage_after = resource.getrusage(resource.RUSAGE_CHILDREN) + user = usage_after.ru_utime - usage_before.ru_utime + system = usage_after.ru_stime - usage_before.ru_stime + result = {'phase': name, 'command': cmd, 'build_cache': phase_env['GOCACHE'], 'exit_status': proc.returncode, + 'wall_seconds': elapsed, 'user_seconds': user, 'system_seconds': system, + 'cpu_percent': 100*(user+system)/elapsed, 'peak_tree_rss_bytes': peak_rss, + 'peak_processes': peak_processes, 'load_average_start': load_start, + 'load_average_end': os.getloadavg(), **summarize_tests(stdout_path)} + result_path.write_text(json.dumps(result, indent=2) + '\n') + print(json.dumps({k: result[k] for k in ('phase', 'exit_status', 'wall_seconds', 'cpu_percent', 'peak_tree_rss_bytes', 'counts')}), flush=True) + if proc.returncode != 0: + raise SystemExit(proc.returncode) + (out/'complete.json').write_text(json.dumps({'completed_at': time.time(), 'commit': meta['commit']}) + '\n') + + +if __name__ == '__main__': + main() diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 00000000..81101b9a --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail +root=$(git rev-parse --show-toplevel) +cd "$root" +mode=${1:-normal}; if (($#)); then shift; fi +parallel=${TEST_PACKAGE_PARALLELISM:-2}; timeout=${TEST_TIMEOUT:-10m} +out=${TEST_OUTPUT_DIR:-"$root/.opencode-work/test-optimization/validation"} +export GOPATH=${GOPATH:-"$root/.opencode-work/gopath"} +export GOCACHE=${GOCACHE:-"$root/.opencode-work/go-build-cache"} +mkdir -p "$out/tmp" "$GOPATH" "$GOCACHE"; export TMPDIR="$out/tmp" GOTMPDIR="$out/tmp" +export GOMAXPROCS=${GOMAXPROCS:-4} +json=(); [[ ${TEST_JSON:-0} == 1 ]] && json=(-json) +format_check() { + local files; files=$(go list -f '{{range .GoFiles}}{{$.Dir}}/{{.}}{{"\n"}}{{end}}{{range .TestGoFiles}}{{$.Dir}}/{{.}}{{"\n"}}{{end}}{{range .XTestGoFiles}}{{$.Dir}}/{{.}}{{"\n"}}{{end}}' "$@") + [[ -z $(gofmt -l $files) ]] || { gofmt -l $files; return 1; } +} +lint() { + local packages=() package + while IFS= read -r package; do [[ $package == */ltsm ]] || packages+=("$package"); done < <(go list "$@") + ((${#packages[@]})) && { go vet "${packages[@]}"; staticcheck "${packages[@]}"; } +} +normal() { go test -p "$parallel" -count=1 -timeout="$timeout" "${json[@]}" ./...; } +race() { go test -race -p "$parallel" -count=1 -timeout="$timeout" "${json[@]}" ./...; } +coverage() { go test -p "$parallel" -count=1 -timeout="$timeout" -covermode=atomic -coverprofile="$out/coverage.out" "${json[@]}" ./...; python3 -B scripts/check_test_coverage.py "$out/coverage.out"; } +case $mode in + fast) (($#)) || { echo 'usage: scripts/test.sh fast ./pkg'; exit 2; }; format_check "$@"; lint "$@"; run=(); [[ -z ${TEST_RUN:-} ]] || run=(-run "$TEST_RUN"); go test -p "$parallel" -count=1 -timeout="$timeout" "${json[@]}" "${run[@]}" "$@" ;; + normal) normal ;; + race) race ;; + coverage) coverage ;; + release) + format_check ./...; lint ./...; go mod verify; python3 -B -m unittest discover -s scripts -p test_check_test_coverage.py + normal; race; coverage; go build ./... + source_epoch=${SOURCE_DATE_EPOCH:-$(git show -s --format=%ct HEAD)} + SOURCE_DATE_EPOCH=$source_epoch ./build.sh -o "$out/matrix-line" + "$out/matrix-line" --version + ;; + *) echo 'Modes: fast, normal, race, coverage, release'; exit 2 ;; +esac diff --git a/scripts/test_check_test_coverage.py b/scripts/test_check_test_coverage.py new file mode 100644 index 00000000..7be6d30c --- /dev/null +++ b/scripts/test_check_test_coverage.py @@ -0,0 +1,54 @@ +import tempfile +from pathlib import Path +import unittest + +from check_test_coverage import compare, read_baseline, read_profile + + +class CoverageGateTests(unittest.TestCase): + def profile(self, content): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory)/"coverage.out" + path.write_text(content) + return read_profile(path) + + def test_counts_both_scopes(self): + values = self.profile("mode: atomic\nexample/pkg/a.go:1.1,2.2 3 4\nexample/pkg/wbc_generated.go:1.1,2.2 100 0\n") + self.assertEqual(values, {"example/pkg": [3, 103, 3, 3]}) + + def test_unchanged_and_improved_pass(self): + baseline = {"pkg": [3, 10, 2, 5]} + self.assertEqual(compare(baseline, baseline), []) + self.assertEqual(compare({"pkg": [4, 10, 3, 5]}, baseline), []) + + def test_missing_package_fails(self): + self.assertTrue(compare({}, {"pkg": [0, 1, 0, 1]})) + + def test_covered_count_and_fraction_cannot_drop(self): + baseline = {"pkg": [5, 10, 5, 10]} + self.assertTrue(compare({"pkg": [4, 8, 4, 8]}, baseline)) + self.assertTrue(compare({"pkg": [5, 11, 5, 11]}, baseline)) + + def test_generated_gains_cannot_mask_handwritten_loss(self): + self.assertTrue(compare({"pkg": [100, 100, 4, 10]}, {"pkg": [50, 100, 5, 10]})) + + def test_new_uncovered_package_cannot_dilute_total_coverage(self): + baseline = {"pkg": [5, 10, 5, 10]} + current = {**baseline, "new/pkg": [0, 100, 0, 100]} + self.assertTrue(compare(current, baseline)) + + def test_invalid_profiles_fail_closed(self): + for content in ("", "mode: atomic\n", "mode: unknown\n", "mode: atomic\nx/a.go:1.1,2.2 -1 1\n", "mode: atomic\nx/a.go:1.1,2.2 1 1\nx/a.go:1.1,2.2 1 1\n"): + with self.subTest(content=content), self.assertRaises(ValueError): + self.profile(content) + + def test_empty_baseline_is_rejected(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory)/"baseline.tsv" + path.write_text("# no entries\n") + with self.assertRaises(ValueError): + read_baseline(path) + + +if __name__ == "__main__": + unittest.main()