diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 4fbafd3c9..9b33bf4a6 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -15,7 +15,7 @@ permissions: env: DOTNET_VERSION: 10.0.x CONFIGURATION: Release - EXPECTED_VERSION: 1.28.0 + EXPECTED_VERSION: 1.29.0 PACKAGE_CANDIDATE_ARTIFACT: icod-terminal-main-package-candidate jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index b27c6dce7..b5db05601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,27 @@ Notable changes to `Icod.Terminal` are recorded here for consumers who need a concise release history. Detailed design evidence remains in the versioned roadmaps, tranche records, and public-API baseline documents. +## 1.29.0 + +- Add independent immutable session clipboard read/write budgets, preserving the + 65,536-byte defaults and allowing explicit limits up to 16,777,216 decoded bytes. +- Encode larger writes directly into one bounded OSC 52 frame; retain strict + UTF-8 validation, ordering, no truncation and existing cancellation boundaries. +- Incrementally accumulate explicitly larger correlated replies without expanding + ordinary input or unrelated query limits. Bound malformed/overflow recovery, + preserve late-query ownership and fail unresolved input closed. +- Add explicit generated-payload samples, a consented larger round-trip scenario, + privacy-safe diagnostics, allocation regression tests and fresh-package coverage. +- Preserve public method signatures, enum values, .NET 8/9/10 and existing dependencies. + See [1.29.0 release notes](docs/releases/1.29.0.md) and + [qualification](docs/Clipboard-Transfer-Qualification-1.29.md) for measured limits. + ## 1.28.0 - Adds truthful mutation confirmation through `TerminalControlMutationConfirmation` and `TerminalControlMutationResult.Confirmation`, distinguishing local `OutputCommitted` completion from correlated `ProtocolAcknowledged` success without changing existing signatures or enum values. - Applies published action-specific response policy to persistent Kitty animation transfers, controls, composition, and regional edits. The published multi-chunk grammar, bounded ownership, ambiguity rules, and cleanup remain unchanged; no terminal/version branch or repeated-image-id workaround is added. - Records the exact Kitty 0.49.2 missing-acknowledgement deviation and limited positive corrected-source Kitty evidence without converting implementation behavior into protocol requirements or rendering claims. +- Preserves the scope of the graphics observations: 1.27's basic-resource color display qualified that resource/placement scenario only. Reviewed 2026-10-08 source-built Kitty recordings added animation, tile-update and corrected eight-row layout evidence with cleanup and successful exit. See the [transaction compatibility record](docs/Kitty-Graphics-Transaction-Compatibility-1.28.md) for exact provenance and limitations. - Qualifies the downstream Icod.DCurses compose-publish ATLAS workload at exact source `d9ae518f0ba075f5e264acaeb14de2c9e8bc2c3d`, including full-coverage coalescing and maintainer-observed interactive artwork, movement, and scrolling. FRAME and TEXT remain controlled alternatives. - Preserves the .NET 8/9/10 target matrix, existing 1.x compatibility floor, and production dependencies `Icod.TermInfo 1.17.0` and `Icod.Timing 1.0.0`. See [1.28.0 release notes](docs/releases/1.28.0.md). diff --git a/Directory.Build.props b/Directory.Build.props index a25a91d66..1c26e35d6 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,8 +1,7 @@ - 1.28.0 - + 1.29.0 $(VersionPrefix) $(VersionPrefix)-$(VersionSuffix) $(Version) diff --git a/Icod.Terminal-1.29.0-Development-Roadmap.md b/Icod.Terminal-1.29.0-Development-Roadmap.md new file mode 100644 index 000000000..7e305e5ec --- /dev/null +++ b/Icod.Terminal-1.29.0-Development-Roadmap.md @@ -0,0 +1,327 @@ +# Icod.Terminal 1.29.0 Development Roadmap + +> **Execution:** Use `superpowers:executing-plans` task by task in the main +> session, without subagents, after maintainer review of this plan and its design. +> Checked items have implementation or regression evidence. Live qualification and +> stable promotion remain separate release gates. + +**Goal:** Enhance OSC 52 clipboard transfers for editor-sized selections while +preserving the 64 KiB default and bounded single-session input/output ownership. + +**Release theme:** Bounded Clipboard Transfers. + +**Selection:** On 2026-10-10 the maintainer selected clipboard enhancements as +the next Terminal milestone before DCurses/Widgets editor work, and explicitly +confirmed that 64 KiB is the correct default with larger buffers opt-in. + +**Status:** Implemented and qualified for stable `1.29.0` in PR #87. +See [qualification](docs/Clipboard-Transfer-Qualification-1.29.md) for automated +results, measured allocation and outstanding live/release gates. Merge, tagging and +publication remain maintainer actions. + +**Architecture:** Add immutable session clipboard read/write budgets and propagate +them through private encoding and correlated response accumulation. Keep ordinary +input bounds and other protocol response budgets intact. Retain the authoritative +input reader, query transaction manager, serialized output and lifecycle ownership. + +**Tech Stack:** C# 13; net8.0/net9.0/net10.0; managed code; existing +Icod.TermInfo 1.17.0 and Icod.Timing 1.0.0 dependencies; cmd/sh/PowerShell 5.1. + +**Spec:** [Bounded Clipboard Transfers design](docs/Clipboard-Transfer-Design-1.29.md). + +## Global constraints + +- Default decoded read/write capacity: 65,536 bytes (64 KiB), unchanged. +- Approved explicit opt-in ceiling: 16,777,216 bytes (16 MiB). +- Budgets count bytes; text uses strict UTF-8 without BOM. +- Existing clipboard methods and return types remain source/binary compatible. +- Empty write still explicitly clears the selection; never silently truncate. +- Ordinary input ceiling stays 87,400 bytes; paste chunk default stays 4,096 bytes. +- Session policy is local resource authorization, not remote capacity negotiation. + OSC 52 has no standard maximum-size handshake; no branding-based capacity claims. +- No OSC 5522, native OS clipboard, new production dependency, public streaming + API, automatic clipboard access or unrelated graphics changes. +- DLL LGPL-3.0-or-later; tests/samples GPL-3.0-or-later; preserve prescribed headers. +- Inline execution, no subagents. Maintainer merges, tags and publishes. + +## Review focus + +1. Multibyte text near a byte limit and equal-length base64 blocks must enforce + decoded-byte capacity exactly (T2901/T2902). +2. Correlated malformed and over-budget replies must not grow unrelated buffers + or become keyboard input. Wrong-selection frames retain legacy ordinary handling + and receive no larger allowance (T2903/T2904; implementation ruling). +3. Cancellation/late replies/disposal must preserve correlation and release storage + without hanging the caller (T2904). +4. Large single-byte-fragmented responses must avoid quadratic scanning and + excessive retained allocations (T2903/T2905). +5. Sample users must not lose clipboard contents without an explicit operation, + or have clipboard data copied into logs/evidence (T2906). + +## Baseline and source map + +Baseline main: `2c410cd80663fef30912079e2c0de7a550852b97`. +[GitHub release v1.28.0](https://github.com/uniblab/Icod.Terminal/releases/tag/v1.28.0) +was published 2026-10-09. Preserve its evidence and API snapshot. + +| Existing path | Responsibility/change | +| --- | --- | +| `src/Session/TerminalSessionOptions.cs` | Add validated immutable clipboard options | +| `src/Session/TerminalSession.Clipboard.cs` | Apply write budget and strict text preflight | +| `src/Session/TerminalSession.ClipboardQueries.cs` | Build explicit per-query read budget | +| `src/Output/TerminalOsc52PayloadCodec.cs` | Parameterized bounds and checked base64 geometry | +| `src/Output/OscWriter.Osc52.cs` | Bounded encoding without intermediate base64 string | +| `src/Query/TerminalOsc52Protocol.cs` | Budget-aware correlated response validation | +| `src/Query/TerminalQueryResponsePlan.cs` | Carry clipboard-only capacity without broadening generic rules | +| `src/Input/TerminalInputDecoder.cs` | Feed recognized clipboard accumulation from the single reader | +| `src/Input/TerminalInputDecoder.Responses.cs` | Correlation, overflow draining and reader recovery | +| `src/Input/TerminalResponseFramer.cs` | Preserve ordinary family ceilings; integrate dedicated clipboard path | +| `src/Session/TerminalQueryTransactionManager.cs` | Carry budget/ownership through timeout and late replies | +| `src/Session/TerminalSession.Input.cs` | Keep ordinary public input bound; clarify its documentation | +| `src/Session/TerminalSession.Queries.cs` | Reuse explicit response-plan transaction entry point | +| `samples/Icod.Terminal.Clipboard.Sample/Program.cs` | Public-only opt-in examples | +| `samples/Icod.Terminal.Compatibility.Sample/ClipboardCompatibilityScenarios.cs` | Consented evidence and truthful outcomes | +| `tools/package-clipboard-smoke/` | Fresh-package API/runtime witness | + +Proposed new files: + +- `src/Clipboard/TerminalClipboardOptions.cs`: public immutable budgets. +- `src/Clipboard/TerminalClipboardLimits.cs`: internal checked size calculations. +- `src/Input/TerminalClipboardResponseAccumulator.cs`: internal bounded state + for an already-correlated response; no independent reader. +- `tests/Icod.Terminal.Tests/src/Session/TerminalClipboardOptionsTests.cs` +- `tests/Icod.Terminal.Tests/src/Input/TerminalClipboardCapacityTests.cs` +- `tests/Icod.Terminal.Tests/src/Input/TerminalClipboardAllocationTests.cs` +- `docs/Clipboard-Transfer-Qualification-1.29.md`: measured/live evidence. +- `docs/Public-API-Baseline-1.29.md` and matching hash during implementation. + +Preserve existing focused OSC 52/session tests; add cases there rather than +replacing their historical regression coverage. + +## T2900 — Contract freeze and development identity + +**Consumes:** the design and current public/session/query contracts. +**Produces:** reviewed API, bounds and recovery contracts; 1.29.0-alpha.1 identity. + +- [x] Review the proposed `TerminalClipboardOptions` names, independent read/write + budgets, inclusive 1..16 MiB range and default compatibility with the maintainer. +- [x] Trace codec, query rule, ordinary input and discard limits from the baseline. + Record every coupling that must change; identify existing small-buffer fixtures. +- [x] Prove with a minimal internal test fixture that a correlated response can + accumulate past the ordinary-input ceiling without increasing unrelated limits. + Resolve ownership/recovery design before building the public feature. +- [x] Freeze accumulator overflow/EOF/late-response transitions and allocation + accounting. If the prototype contradicts the design, amend the design for review. +- [x] Establish 1.29.0-alpha.1 in `Directory.Build.props` and development identity + tests when implementation begins. Preserve 1.28 historical API/evidence files. + +**Gate:** no runtime work beyond the approved internal proof until the public and +reader-ownership contracts have been reviewed. No protocol capacity handshake is promised. + +## T2901 — Options and checked byte geometry + +**Files:** new options/limits classes; session options; options/codec tests. +**Produces:** `TerminalClipboardOptions` from the design and internal +`GetEncodedLength(int decodedBytes)` / `GetResponseFrameLimit(int maximumDecodedBytes)` +helpers in `TerminalClipboardLimits`. + +- [x] Add failing options tests: defaults equal 65,536; independent 1 MiB read and + write settings; 0, -1 and 16,777,217 rejected; null options rejected; no output or + terminal mode mutation on invalid session settings. +- [x] Add geometry tests for 0, 1, 2, 3, 65,535, 65,536, 65,537, 1 MiB and 16 MiB, + including expected E/F values in the design and integer-overflow rejection. +- [x] Verify the new tests fail, then implement validation/immutable effective + budgets and checked arithmetic without altering old default constants. +- [x] Run options/codec tests on all targets; review default-session behavior; commit. + +**Gate:** defaults and hard ceilings are explicit and no large eager allocation occurs. + +## T2902 — Larger bounded writes + +**Files:** clipboard session methods, codec, OSC writer; +`TerminalSessionClipboardTests.cs`, `TerminalSessionClipboardOrderingTests.cs`, +`Osc52WriterTests.cs`, `TerminalOsc52PayloadCodecTests.cs`. + +**Consumes:** validated write budget and T2901 geometry. +**Produces:** existing write methods honoring session capacity with unchanged completion semantics. + +- [x] Add failing cases: default accepts 65,536/rejects 65,537; explicit 1 MiB and + hard-ceiling writes; one byte over each budget; empty clear; invalid selection; + malformed surrogate text; UTF-8 multi-byte boundaries. +- [x] Assert oversize/invalid inputs emit nothing and do not allocate an output frame. + Assert binary/text exception categories remain those documented in the design. +- [x] Encode directly into bounded frame storage; preserve one serialized logical + OSC operation and existing public signatures. Never split content into multiple + replacement writes as an attempted append mechanism. +- [x] Test cancellation before output gate acquisition and before commitment, then + cancellation after commitment, competing screen output and transport failure. +- [x] Run targeted write/order tests on all targets and commit. + +**Gate:** output remains complete and ordered; success reports emission only. + +## T2903 — Correlated larger reads + +**Files:** query protocol/plan, clipboard query method, decoder/framer integration, +new accumulator; `TerminalClipboardCapacityTests.cs`, +`TerminalOsc52QueryTests.cs`, `TerminalOsc52RoutingTests.cs`. + +**Consumes:** validated read budget, existing query ownership and T2901 geometry. +**Produces:** unchanged `ReadClipboardAsync` result contract with clipboard-specific capacity. + +- [x] Add failing default-limit and explicit larger-limit read tests through the + session API, including empty/binary data and exact returned-byte equality. +- [x] Test 1-byte fragmentation, split introducer/selection/base64 padding/terminator, + and both existing supported OSC termination forms. Verify one incremental scanner feed per input byte (see implementation ruling). +- [x] Introduce the internal incremental accumulator behind complete correlated + OSC 52 selection-prefix recognition. Feed it from the existing reader and + attach it to the active/late query identity. +- [x] Build the clipboard-specific response plan explicitly through the existing + transaction entry point. Keep generic `ForCompletion` and unrelated protocol + budgets at their pre-feature effective values. +- [x] Validate actual decoded bytes as well as encoded/frame length; reject + over-budget payloads that share a base64 block length with an allowed payload. +- [x] Test wrong selection, unsolicited OSC, concurrent sessions with different + budgets, ordinary text/keys/focus/paste before and after replies. + Preserve ordinary-input options and existing small-buffer behavior. +- [x] Run read/routing/input regression tests on all targets and commit. + +**Gate:** larger replies neither require a larger ordinary-input buffer nor permit +unrelated protocol frames to consume the clipboard allowance. + +## T2904 — Failure, cancellation and lifecycle + +**Files:** accumulator, response decoder, query transaction manager as needed; +`TerminalOsc52OversizedRoutingTests.cs`, clipboard ordering and query tests. + +**Consumes:** T2903 query-bound accumulator. +**Produces:** tested completion, overflow, abandoned-query and disposal cleanup. + +- [x] Add failing tests for invalid base64, illegal padding, decoded/frame overflow, + missing terminator, EOF mid-response and transport disconnect. +- [x] Test queue-inclusive timeout before emission: no request bytes. Test timeout + after emission and late reply followed by a second same-selection query. + The late reply must never satisfy the second query. +- [x] Enforce bounded scratch-space discard and the reviewed per-query recovery + allowance. Test input resumption after a valid terminator and failure closed + after recovery exhaustion; never return payload fragments as keystrokes. +- [x] Test caller cancellation during fragmented input, lifecycle suspension, + session disposal and concurrent clipboard/screen operations. +- [x] Verify no clipboard payload appears in exceptions or diagnostics; assert + accumulator buffers and query ownership are released at terminal outcomes. +- [x] Run targeted failures plus existing multi-family transaction tests and commit. + +**Gate:** no partial success, stale-result substitution, second input reader or +unbounded recovery storage; existing timing guarantees remain intact. + +## T2905 — Allocation and performance qualification + +**Files:** `TerminalClipboardAllocationTests.cs`, qualification document. +**Consumes:** implemented transfer paths and deterministic fixture transports. + +- [ ] Measure 64 KiB, 1 MiB and 16 MiB reads/writes, plus long fragmented responses; + include default idle sessions and invalid over-budget calls. +- [x] Verify lazy options/accumulator allocation and rejected-write allocation; + review accumulator disposal/clearing and no intermediate base64 string on writes. +- [x] Establish linear scanner work and geometric allocation bounds through + component measurements, source review and one-byte-fragmented session tests. + Pin deterministic budgets to the measured implementation; do not use fragile + wall-clock thresholds as unit-test pass criteria. +- [ ] Record allocation totals, peak working storage, runtime/OS/architecture and + elapsed time. Compare default-path results with baseline 1.28. +- [x] Resolve regressions and document the cost of a complete output frame, including + the non-cancellable post-commit write interval; commit evidence and tests. + +**Gate:** opt-in capacity has demonstrated bounded cost; defaults do not inherit +the high-capacity allocation footprint. + +## T2906 — Public sample and compatibility evidence + +**Files:** clipboard/compatibility samples, their tests, qualification document. + +- [x] Add sample options `--maximum-read-bytes` and `--maximum-write-bytes` + with validated decimal byte counts and documented defaults. Keep existing modes. +- [x] Offer generated payload fixtures above 64 KiB; require explicit action before + writing or reading the clipboard. Do not automatically inspect/restore prior + contents, print payloads, or embed them in reports. +- [x] Add sample tests showing headless/help paths do not access the clipboard, + limits are applied independently, and over-budget input explains local failure. +- [x] Report emitted bytes separately from decoded read results and operator + observations. Timeout/unavailable policy is not a positive/negative support proof. +- [ ] Run public-only sample checks and consented live scenarios in available + terminals. Record exact terminal/version/transport/commit and tested sizes, + including default transfer, larger transfer and graceful refusal/timeout. +- [x] Mark missing environments NotRun; retain honest limits and commit evidence. + +**Gate:** consumers have runnable examples and precise operational expectations. + +## T2907 — API, package and downstream qualification + +**Files:** public API snapshot/hash, package clipboard smoke tool, existing +package verifier/shards, README, CHANGELOG, security/compatibility docs. + +- [x] Update fresh-package smoke coverage for default and custom budgets through + public APIs only. Verify dependency graph, XML docs and license/header gates. +- [x] Generate identical public API snapshots for net8.0/net9.0/net10.0 and review + the diff against 1.28 for accidental removals/signature/enum changes. +- [x] Update README with durable examples, UTF-8 byte accounting, local-versus-remote + limits and fallback guidance. Keep measured qualification in its dedicated doc. +- [x] Record implemented changes in CHANGELOG only once delivered. Add curated + alpha/stable release notes at the appropriate promotion step. +- [ ] Run full solution, integration, package and existing downstream DCurses gates. + Confirm ordinary input, graphics, semantic events and clipboard ordering regressions + remain covered without changing downstream repositories. +- [x] Commit docs/package evidence and report remaining caveats. + +**Gate:** the package delivers the same reviewed contract as source, with existing +dependencies and a clear contract for the later DCurses facade. + +## T2908 — Release closure + +- [ ] Resolve every unchecked implementation gate or explicitly recorded release + exception; no invented live-platform result. +- [ ] Promote version metadata from alpha to stable 1.29.0 only when authorized. +- [ ] Run complete CI/package verification on the final candidate commit and + record workflow IDs, API fingerprints and artifact identities. +- [x] Update this roadmap and the main roadmap with actual completion/evidence. +- [ ] Present the reviewed branch to the maintainer for merge/tag/publication. + Do not perform those actions automatically. + +## Verification workflow + +For each implementation tranche: add focused failing tests, verify the failure, +implement the smallest coherent change, rerun targeted tests, then commit. +Run commands from repository root: + +```text +dotnet build Icod.Terminal.sln -c Release +dotnet test tests/Icod.Terminal.Tests/Icod.Terminal.Tests.csproj -c Release --no-build +dotnet test tests/Icod.Terminal.TermInfoIntegration.Tests/Icod.Terminal.TermInfoIntegration.Tests.csproj -c Release --no-build +dotnet run --project samples/Icod.Terminal.Clipboard.Sample/Icod.Terminal.Clipboard.Sample.csproj -c Release -f net10.0 -- --help +``` + +Targeted unit runs may add +`--filter "FullyQualifiedName~Clipboard|FullyQualifiedName~Osc52"`. +Use the repository's established packaging scripts and CI shard invocation +contracts after inspecting their current parameters; do not invent a parallel +release workflow. Cross-platform automated results and live-terminal observations +are recorded separately. + +## Implementation checkpoint + +The maintainer approved the design and implementation. T2900–T2904 are delivered; +regressions include default/custom byte limits, 16 MiB transfers, fragmentation, +small ordinary buffers, malformed/oversized replies, recovery exhaustion, +timeout, cancellation, EOF, disposal and independent sessions. + +T2905 component allocation/scanner qualification is complete. Whole-session +peak-memory profiling across operating systems remains a release follow-up; +the recorded peak is an analytical live-storage bound, not measured process RSS. +T2906 samples and consented scenario are delivered; all live environments remain +NotRun. T2907 local solution, integration, fresh-package and downstream acceptance +are recorded in the qualification document; final GitHub matrix status belongs +to PR #87. Unchecked measurement/live/CI items are explicit release follow-ups, +not claims of completed qualification. + +The remaining release decision is maintainer review, any accepted qualification +exceptions, stable promotion, merge and publication. DCurses/Widgets implementation +follows the accepted Terminal contract. diff --git a/Icod.Terminal-Development-Roadmap.md b/Icod.Terminal-Development-Roadmap.md index 213bd61b5..3aca798de 100644 --- a/Icod.Terminal-Development-Roadmap.md +++ b/Icod.Terminal-Development-Roadmap.md @@ -4,11 +4,11 @@ - **Package:** `Icod.Terminal` - **Language:** C# 13 - **Target frameworks:** `net8.0`; `net9.0`; `net10.0` -- **Current stable feature line:** `1.27.0` — Live Terminal Environment Awareness -- **Latest historical patch line:** `1.24.1` — Persistent Kitty resource-identity verification -- **Development status:** `1.27.0` was merged through [PR #83](https://github.com/uniblab/Icod.Terminal/pull/83) at `485bedb014ab7b66391661c709833a6a4fa4afcc` and tagged `v1.27.0` on 2026-10-06; the maintainer reports that NuGet upload is in progress. Final PR head `df6c42189f6b6a2f40771ad2115af41a46dcb4b1` passed all ten jobs in workflow `37536612435`, including 2,821 unit tests and 15 integration tests per framework on Windows, Linux and macOS. The graphics hold is now reopened only for the bounded 1.28 specification-conformance scope below; persistent ATLAS remains unqualified until that scope and downstream acceptance complete. -- **Active development target:** `1.28.0` — Published-Spec Kitty Graphics Transactions. -- **Selected scope:** Adhere to the published Kitty Graphics Protocol, distinguish output commitment from protocol acknowledgement, record Kitty 0.49.2 deviations without encoding them as a private dialect, and qualify the downstream raster atlas. See the [1.28 development roadmap](Icod.Terminal-1.28.0-Development-Roadmap.md). +- **Current stable feature line:** `1.29.0` — Bounded Clipboard Transfers; merge, tag, and publication remain maintainer actions. +- **Latest historical patch line:** `1.24.1` — Persistent Kitty resource-identity verification. +- **Development status:** Stable `1.29.0` release preparation; reviewed qualification is tracked in the versioned roadmap. +- **Active development target:** `1.29.0` — Bounded Clipboard Transfers. +- **Selected scope:** Preserve the 65,536-byte default while allowing explicit larger read/write budgets, with coordinated codec/framing limits, bounded correlated response accumulation and truthful outcomes. See the [1.29 development roadmap](Icod.Terminal-1.29.0-Development-Roadmap.md) and [design](docs/Clipboard-Transfer-Design-1.29.md). - **Stable compatibility floor:** `1.0.0` ## Purpose @@ -19,24 +19,23 @@ The original pre-1.0 roadmap is preserved at [`docs/history/Icod.Terminal-Initia ## Latest accepted checkpoint -`Icod.Terminal 1.27.0` is merged and tagged. Its appearance query, independent -appearance/in-band resize leases, lifecycle hardening, compatibility evidence, -DA1 trailing-separator correction, package gates and documentation are complete. -The maintainer reports that NuGet upload is in progress. Tag `v1.27.0` resolves -to merge commit `485bedb014ab7b66391661c709833a6a4fa4afcc`; final PR head -`df6c42189f6b6a2f40771ad2115af41a46dcb4b1` passed all ten jobs in -[workflow 37536612435](https://github.com/uniblab/Icod.Terminal/actions/runs/37536612435). -The [1.27 development roadmap](Icod.Terminal-1.27.0-Development-Roadmap.md), -[release notes](docs/releases/1.27.0.md) and -[reviewed matrix](docs/compatibility/1.27.0.md) preserve its exact limits. - -On 2026-10-06 the maintainer selected a bounded 1.28 reopening of graphics work: -follow the published Kitty Graphics Protocol, make transaction confirmation -truthful, record Kitty 0.49.2 deviations separately, and qualify the downstream -ATLAS path without terminal-brand heuristics or an undocumented repeated-id -workaround. The [1.28 development roadmap](Icod.Terminal-1.28.0-Development-Roadmap.md) -defines T2800–T2810 and requires a detailed contract/API-regret gate before runtime -implementation. +Stable `1.28.0` is present on main at +`2c410cd80663fef30912079e2c0de7a550852b97`; its +[GitHub release](https://github.com/uniblab/Icod.Terminal/releases/tag/v1.28.0) +was published 2026-10-09. The +[1.28 development roadmap](Icod.Terminal-1.28.0-Development-Roadmap.md) and +[release notes](docs/releases/1.28.0.md) preserve the exact graphics and downstream +qualification limits. This status does not expand their terminal coverage. + +On 2026-10-10 the maintainer selected clipboard enhancement before the next +DCurses/Widgets editor milestones for Icod.Dedit, and explicitly retained +**64 KiB as the default**. Applications may opt into larger library budgets; +that local policy is not remote OSC 52 capacity negotiation. The +[1.29 roadmap](Icod.Terminal-1.29.0-Development-Roadmap.md) defines T2900–T2908. +The maintainer approved the 16 MiB hard ceiling and implementation. Independent +budgets, bounded transfers and explicit samples are qualified for the 1.29.0 +stable release. Dependencies remain unchanged; publication is a separate +maintainer action. ### Historical checkpoints @@ -88,7 +87,7 @@ terminal applications - `Icod.DCurses` owns cells, windows, virtual-screen state, screen coordinates, clipping, scrolling, layout, refresh/diff policy, damage, and higher-level presentation policy. - PTY/process hosting remains orthogonal to the `Icod.Terminal` runtime contract. -The production dependency baseline for stable 1.27 and planned 1.28 is: +The production dependency baseline for stable 1.28 and planned 1.29 is: ```text Icod.TermInfo 1.17.0 @@ -127,7 +126,8 @@ Optional integration tests/samples use `Icod.TermInfo.Inspection 1.17.0`; Inspec 1.25.0 ordered screen raster transactions STABLE SOURCE / GRAPHICS HOLD 1.26.0 terminal compatibility qualification PUBLISHED 1.27.0 appearance and resize awareness MERGED / TAGGED -1.28.0 published-spec Kitty graphics transactions ALPHA / LIVE-DOWNSTREAM GATES OPEN +1.28.0 published-spec Kitty graphics transactions RELEASED +1.29.0 bounded opt-in clipboard transfers STABLE PREPARATION ``` The unchanged 1.18–1.19 public API fingerprint is: @@ -633,11 +633,43 @@ T2810 live qualification and stable closure The [1.28 development roadmap](Icod.Terminal-1.28.0-Development-Roadmap.md) pins the protocol/source evidence, classifies the Kitty 0.49.2 behaviors, defines the required completion states and records the test and release gates. This -development line implements the approved design as `1.28.0-alpha.1`. Reviewed -2026-10-08 corrected-source Kitty recordings provide limited positive sample -evidence including the corrected eight-row visual witness. Full provenance, -downstream ATLAS acceptance and stable closure remain open under Task 9 of the -approved plan. +development line progressed through `1.28.0-alpha.1` to the released stable +`1.28.0` line. Reviewed corrected-source Kitty recordings and the maintainer's +downstream ATLAS acceptance are preserved in the 1.28 promotion record and release +notes; their precise provenance and limitations remain authoritative. + +## 1.29 selected line — Bounded Clipboard Transfers + +The next release extends existing OSC 52 clipboard functionality for applications +such as Icod.Dedit. Keep 65,536 decoded bytes as the default for both reads and +writes; offer independent, explicit larger budgets with a reviewed hard ceiling. +Terminal owns transport bounds and protocol truth; applications own internal +clipboard fallback, selected text and user messaging. + +The selected work coordinates codec, frame, query and decoder limits; preserves +ordinary input and bracketed-paste policy; handles malformed/oversized/late replies; +and includes public samples, allocation measurement, package and compatibility gates. +Successful output is not remote acceptance, and a query timeout is not proof of +unsupported functionality. No OSC 52 capacity negotiation is invented. + +| Tranche | Deliverable | +| --- | --- | +| T2900 | Contract freeze, reader-ownership proof and development identity | +| T2901 | Immutable clipboard budgets and checked byte geometry | +| T2902 | Larger bounded writes and output-ordering preservation | +| T2903 | Correlated larger reads without broader ordinary-input limits | +| T2904 | Failure, cancellation, late response and lifecycle hardening | +| T2905 | Allocation and performance qualification | +| T2906 | Public sample and exact-environment compatibility evidence | +| T2907 | API, package, documentation and downstream gates | +| T2908 | Maintainer-reviewed release closure | + +See the [development roadmap](Icod.Terminal-1.29.0-Development-Roadmap.md) +and [design](docs/Clipboard-Transfer-Design-1.29.md). +MIME-aware OSC 5522 (earlier option 4) remains deferred; this decision enhances +the existing OSC 52 path. No TermInfo or production dependency change is planned. +No changes to DCurses/Widgets or the shared workflow-template repository are part +of this milestone. Execution remains inline, without subagents. ## Later development candidates diff --git a/Icod.Terminal.csproj b/Icod.Terminal.csproj index e9cb9ee69..28c914c53 100644 --- a/Icod.Terminal.csproj +++ b/Icod.Terminal.csproj @@ -39,7 +39,7 @@ Icod.Terminal Timothy J. Bruce Managed, cross-platform live-terminal session, endpoint, mode, input, lifecycle, and terminal-control foundation for .NET. - Icod.Terminal 1.28.0 adds truthful confirmation strength for published-spec Kitty Graphics mutations: output-only controls report OutputCommitted, while required or observed correlated success reports ProtocolAcknowledged. It preserves the published multi-chunk frame grammar, bounded ownership and cleanup, existing animation signatures, enum values, .NET 8/9/10 targets, and production dependencies. Exact live evidence includes the documented Kitty 0.49.2 continuation deviation, a corrected source-built Kitty witness, and downstream Icod.DCurses ATLAS qualification; none implies atomic or gapless rendering. Release notes: https://github.com/uniblab/Icod.Terminal/blob/main/docs/releases/1.28.0.md. Compatibility policy: https://github.com/uniblab/Icod.Terminal/blob/main/docs/Compatibility-and-Versioning.md. + Icod.Terminal 1.29.0 adds independent bounded clipboard read/write budgets: 64 KiB defaults with explicit opt-in through 16 MiB. Larger correlated reads use incremental bounded storage; writes encode directly into a complete ordered frame. Includes explicit public samples and package validation while preserving .NET 8/9/10, existing APIs and production dependencies. Local capacity does not negotiate remote acceptance. Release notes: https://github.com/uniblab/Icod.Terminal/blob/main/docs/releases/1.29.0.md. Compatibility policy: https://github.com/uniblab/Icod.Terminal/blob/main/docs/Compatibility-and-Versioning.md. Usage: https://github.com/uniblab/Icod.Terminal/blob/main/docs/Clipboard-Transfers.md. README.md icon.png https://github.com/uniblab/Icod.Terminal diff --git a/README.md b/README.md index 6aad41213..a36c1676e 100644 --- a/README.md +++ b/README.md @@ -9,59 +9,14 @@ ## Status -Current stable release: `Icod.Terminal 1.28.0`. +Stable release: **1.29.0**. It adds bounded, opt-in OSC 52 clipboard transfers; +the stable **1.0.0 compatibility floor** remains unchanged. -See the [1.28.0 release notes](docs/releases/1.28.0.md) for the published-spec Kitty Graphics transaction contract, exact implementation evidence, downstream ATLAS qualification, and retained limits. - -Version 1.27 adds terminal appearance and in-band resize awareness through bounded queries and independently owned reporting leases. It also accepts one trailing separator after populated Primary Device Attributes, preserving Kitty 0.49.2's attributes from `CSI ?62;52;c` while retaining numeric bounds and malformed-field rejection. Reviewed alpha source observations record positive appearance query/reporting and initial/changed resize in Kitty 0.49.2 through WSL, plus unavailable reporting with working native input and resize in Windows Terminal hosting Bash through WSL. See the [1.27 release notes](docs/releases/1.27.0.md), [1.27 API baseline](docs/Public-API-Baseline-1.27.md), and [1.27 matrix](docs/compatibility/1.27.0.md) for exact versions and limits. Reporting is opt-in; applications own theme selection and repaint policy. - -Version 1.26 adds no production runtime API. It adds a public-only executable compatibility sample, bounded reviewed JSON evidence, deterministic matrix generation, and fresh-package acceptance across .NET 8, 9, and 10. The initial matrix records exact, scenario-scoped identity and dimension observations for Windows Terminal and Kitty through WSL; every unrecorded scenario remains `NotRun`. See the [1.26 release notes](docs/releases/1.26.0.md), [compatibility matrix](docs/compatibility/1.26.0.md), and [sample walkthrough](samples/Icod.Terminal.Compatibility.Sample/README.md). - -Version 1.25 adds `TerminalScreenOutputTransaction.WriteRaster(...)` for a complete, caller-owned image in the same ordered commit as cursor plans and text. Call `VerifyCapabilityAsync(TerminalCapability.RasterGraphics)` before building the transaction. The commit uses already verified Kitty Graphics or Sixel evidence, encodes all frames within the aggregate 64 MiB bound before output, and does not infer placement or cursor position after the raster. It includes the alpha.1–alpha.5 corrections: newline-safe cursor planning, coalesced Sixel writes, immediate Unix byte input, Kitty DA1 compatibility and bounded persistent-transfer deadlines. See the [1.25 release notes](docs/releases/1.25.0.md). - -Version 1.25 derives persistent upload, animation-append and partial-frame-edit deadlines from their Base64 wire size, adds one second for correlated acknowledgement, and caps the result at one minute. Small control-only transactions remain at one second. See the [alpha.5 release notes](docs/releases/1.25.0-alpha.5.md). - -Version 1.28 applies action-specific Kitty Graphics transaction semantics while -keeping the published wire grammar. `Unspecified` preserves unclassified success, -`OutputCommitted` records completion through the write-and-flush boundary, and -`ProtocolAcknowledged` records a correlated successful protocol response. None of -these values claims visible rendering. Kitty 0.49.2 on WSL2 reproduces the missing -acknowledgement for a documented chunked animation-frame continuation; the -post-0.49.2 upstream correction is tracked without a repeated-image-id workaround -or terminal-version branch. Version 1.27's brief basic-resource color display -qualifies only that persistent resource/placement scenario, not animation or -persistent ATLAS. Reviewed 2026-10-08 source-built Kitty recordings add limited -positive animation, tile-update and corrected eight-row layout evidence, including -cleanup and exit status 0. The reviewed 2026-10-09 Icod.DCurses 2.3 sample then -qualified the exact downstream compose-publish ATLAS workload with responsive -movement and scrolling in that corrected-source Kitty lane. This remains scoped -evidence, not an atomic, gapless, or universal rendering claim. See the -[1.28 release notes](docs/releases/1.28.0.md), [transaction compatibility record](docs/Kitty-Graphics-Transaction-Compatibility-1.28.md), -and [hold record and evidence](docs/Graphics-Development-Hold.md). - -Ordinary raster capability selection prefers verified Kitty, then verified Sixel. -A failed write is not automatically retried through another backend. Applications -own text fallback; Sixel does not emulate persistent Kitty identities or animation. - -Version 1.24.1 verifies the persistent Kitty resource-identity path independently from generic Kitty graphics support. A terminal must accept a bounded 1x1 persistent upload and return a nonzero terminal-assigned image id before `PersistentRasterGraphics` becomes usable; the probe resource is then deleted. Zero ids and other malformed acknowledgements no longer escape from capability verification as raw format failures. See the [1.24.1 release notes](docs/releases/1.24.1.md). - -Version 1.24 adds direct and exactly derived terminal-pixel geometry, intrinsic persistent-resource geometry, advisory local raster-planning snapshots, and focused generation-scoped evidence for acknowledged composition and RGB24/RGBA32 frame-region updates. The [1.24 roadmap](Icod.Terminal-1.24.0-Development-Roadmap.md), [release notes](docs/releases/1.24.0.md), [ownership guide](docs/Persistent-Raster-Ownership.md), and [tile-atlas witness](samples/Icod.Terminal.RasterAnimation.Sample/README.md) define the contract and its Terminal/DCurses/game boundary. - -Version 1.23 adds bounded caller-supplied partial frame replacement, completes current legacy Menu phase decoding, and fixes malformed modern-keyboard end-of-input recovery. See the [1.23 roadmap](Icod.Terminal-1.23.0-Development-Roadmap.md), [release notes](docs/releases/1.23.0.md), and [animation sample](samples/Icod.Terminal.RasterAnimation.Sample/README.md). - -Version 1.22 adds acknowledged bounded pixel composition between known animation frames of one persistent raster resource. See the [development roadmap](Icod.Terminal-1.22.0-Development-Roadmap.md) and [animation sample](samples/Icod.Terminal.RasterAnimation.Sample/README.md) for the implementation and a runnable example. - -Version 1.21 adds Kitty functional-key phase reporting and temporary cursor visibility for one screen transaction, restoring the presentation lease owner or ordinary cursor afterward. See the [development roadmap](Icod.Terminal-1.21.0-Development-Roadmap.md) and [screen-output sample](samples/Icod.Terminal.ScreenOutput.Sample/README.md) for the implementation and an input-driven refresh. - -Version 1.20 adds immutable screen-advertisement facts, fixes generation and late-response ownership in existing support verification, and expands the [capability guide](docs/Capability-Inspection-and-Planning.md) and [runnable sample](samples/Icod.Terminal.CapabilityPlanning.Sample/README.md). Static advertisement, concrete plans, live support evidence, and endpoint availability remain separate decisions. - -Version 1.18 adds `TerminalScreenPlanner.PlanRenditionBaseline()`, allowing a Terminal-only renderer to establish the normalized default rendition safely when the physical starting state is unknown. The operation returns no plan when any profile-exposed rendition axis cannot be restored unconditionally. - -Version 1.17 adds Terminal-owned dimensions, an immutable semantic terminal profile, side-effect-free screen-operation planning, and bounded session-bound output transactions. These contracts provide the Terminal-side boundary used by the decoupled `Icod.DCurses 2.x` renderer. - -The stable `1.0.0` compatibility floor remains unchanged. Version 1.28 preserves every existing public signature and enum value while adding the confirmation enum and read-only result property. Existing screen planning, transactions, rendition, raster, input, lifecycle, and compatibility contracts remain available. - -See the [1.28 release notes](docs/releases/1.28.0.md), additive [1.28 API baseline](docs/Public-API-Baseline-1.28.md), and [changelog](CHANGELOG.md) for release-specific details. +See the [1.29.0 release notes](https://github.com/uniblab/Icod.Terminal/blob/main/docs/releases/1.29.0.md), +[changelog](https://github.com/uniblab/Icod.Terminal/blob/main/CHANGELOG.md) for release history, +the [development roadmap](https://github.com/uniblab/Icod.Terminal/blob/main/Icod.Terminal-Development-Roadmap.md) +for current work, and the [compatibility policy](https://github.com/uniblab/Icod.Terminal/blob/main/docs/Compatibility-and-Versioning.md) +for support guarantees. ## Support the Project @@ -108,10 +63,10 @@ See [`docs/Architecture.md`](docs/Architecture.md) for the permanent architectur ## Quick Start -Install the stable release after publication: +Install the stable release: ```text -dotnet add package Icod.Terminal --version 1.28.0 +dotnet add package Icod.Terminal --version 1.29.0 ``` The partial animation-frame API requires `Icod.Terminal 1.23.0` or later. The geometry and planning APIs described below require `Icod.Terminal 1.24.0` or later. @@ -177,6 +132,41 @@ This capability check is a conservative fallback, not a guarantee: the effective `Profile` contains immutable selected-profile facts, while `GetDimensions()` reports the current Terminal-owned size result. A plan is opaque and session-bound; creating it emits nothing, and the transaction preserves ordering under one output gate and flush boundary. Retained cells, layout, Unicode width, clipping, damage, and repaint policy remain caller-owned. +### Clipboard transfers + +Configurable clipboard budgets are available in `1.29.0` and later. +Read and write limits independently default to 64 KiB and allow explicit opt-in +through 16 MiB. These authorize local resources; they do not negotiate terminal +capacity. + +Run the clipboard sample's headless help from a checkout: + +```text +dotnet run --project samples/Icod.Terminal.Clipboard.Sample -c Release -f net10.0 -- --help +``` + +To authorize larger transfers when opening an application session: + +```csharp +using Icod.Terminal; + +await using TerminalSession session = await TerminalSession.OpenAsync(new TerminalSessionOptions { + ClipboardOptions = new TerminalClipboardOptions { + MaximumReadBytes = 1_048_576, + MaximumWriteBytes = 1_048_576 + } +}); +``` + +Clipboard operations remain explicit. Text limits count strict UTF-8 bytes, +and write completion reports emission rather than remote acceptance. Applications +own internal clipboard fallback; a read timeout alone establishes no support status. + +See the [clipboard guide](https://github.com/uniblab/Icod.Terminal/blob/main/docs/Clipboard-Transfers.md) +for byte accounting and recovery, and the +[qualification record](https://github.com/uniblab/Icod.Terminal/blob/main/docs/Clipboard-Transfer-Qualification-1.29.md) +for measured costs and live-test status. + ### Choosing a capability API | Question | API | Terminal traffic | @@ -268,6 +258,10 @@ The root README describes the current product by capability rather than by the r ## Raster Ownership at a Glance +Ordinary raster capability selection prefers verified Kitty, then verified Sixel. +A failed write is not automatically retried through another backend. Applications +own text fallback; Sixel does not emulate persistent Kitty identities or animation. + The current persistent-raster ownership model is: ```text diff --git a/docs/Clipboard-Transfer-Design-1.29.md b/docs/Clipboard-Transfer-Design-1.29.md new file mode 100644 index 000000000..6d300c544 --- /dev/null +++ b/docs/Clipboard-Transfer-Design-1.29.md @@ -0,0 +1,234 @@ +# Bounded Clipboard Transfers — 1.29 Design + +**Status:** Approved by the maintainer; implemented in the 1.29.0-alpha.1 candidate. +**Decision date:** 2026-10-10 (America/New_York). +**Baseline:** Icod.Terminal main `2c410cd80663fef30912079e2c0de7a550852b97`, +stable [v1.28.0](https://github.com/uniblab/Icod.Terminal/releases/tag/v1.28.0). +**Consumer:** Icod.Dedit, through the forthcoming DCurses clipboard facade. + +## Goal + +Allow applications to opt into larger, explicitly bounded OSC 52 clipboard +transfers without changing the existing 65,536-byte default, inventing an +acknowledgement, or weakening input/query ownership. + +The maintainer explicitly confirmed the 64 KiB default and application opt-in to +larger buffers. Local budget selection is distinct from remote negotiation: +OSC 52 provides no standard maximum-size handshake. Do not call local acceptance +negotiated terminal capacity. + +The current implementation caps decoded read and write payloads at 65,536 bytes. +Its base64 ceiling is 87,384 bytes and complete response-frame ceiling 87,400 +bytes. Those constants also feed the response framer and input-buffer ceiling. +Changing one constant would affect unrelated input and response behavior. + +## Scope and ownership + +Terminal owns protocol encoding, response correlation, bounded transport buffers, +serialized output, deadlines, cancellation and lifecycle cleanup. +Applications own selected text, internal clipboard contents, user commands, +fallback messages and document undo. DCurses provides access through its +existing session; it does not create a second reader. + +This release covers configurable read/write capacity, efficient bounded encoding +and response accumulation, adversarial tests, public sample/package consumers, +and exact-environment compatibility evidence. + +Excluded: OSC 5522/MIME, native operating-system clipboard APIs, clipboard +history/synchronization, automatic clipboard probes, public raw protocol access, +new transfer streams, protocol-specific append workarounds, multiplexer forwarding, +and changes to Widgets or Dedit. Bracketed paste remains a distinct existing +chunked input facility; its total size is not the OSC 52 limit. + +## Options considered + +1. Raise the global constant: simplest edit, but changes all callers' memory and + framing policy and does not preserve the default contract. Rejected. +2. Session-scoped read/write budgets with a separately bounded, correlated + clipboard-response accumulator: recommended. More internal work, but preserves + ordinary input limits and avoids new overloads or ambiguous default arguments. +3. Public streaming clipboard API or another protocol: larger contract and + lifecycle scope than the editor currently needs. Deferred. + +## Approved public contract + +Names and values below are the concrete review proposal. Freeze them at T2900; +they are not shipped API until implemented and qualified. + +```csharp +public sealed class TerminalClipboardOptions { + public static int DefaultMaximumBytes { get; } // 65_536 + public static int SupportedMaximumBytes { get; } // 16_777_216 + public int MaximumReadBytes { get; init; } = 65_536; + public int MaximumWriteBytes { get; init; } = 65_536; +} + +// Add to TerminalSessionOptions: +public TerminalClipboardOptions ClipboardOptions { get; init; } = new(); +``` + +Both budgets accept inclusive range 1..16,777,216 decoded bytes. Zero is not a +disable sentinel. An empty clipboard payload is valid and an empty write retains +its existing explicit-clear meaning. Null options are rejected during session +validation; invalid budgets fail before acquiring modes or emitting output. +The session retains immutable effective values for its lifetime. + +The 16 MiB hard ceiling is an approved library resource policy, not a terminal +capability claim or an advertised guarantee that any terminal accepts that size. +Applications may choose any lower positive budget independently for reads and writes. + +Existing methods and return types remain unchanged: + +- `WriteClipboardAsync(TerminalClipboardSelection, ReadOnlyMemory, CancellationToken)` +- `WriteClipboardAsync(TerminalClipboardSelection, string, CancellationToken)` +- `ReadClipboardAsync(TerminalClipboardSelection, TimeSpan, CancellationToken)` + +The methods use their owning session's budgets. Read/write ceilings are +independent: increasing write capacity must not increase accepted response size. +Text is strict UTF-8 without BOM; limits count encoded bytes, not UTF-16 code +units or displayed characters. Binary reads remain exact bytes; no new text-read +convenience overload is required. + +## Derived bounds and ordinary input + +For decoded capacity D: + +- encoded capacity E = checked(4 * ((D + 2) / 3)); +- canonical outgoing frame length = checked(E + 9); +- accepted response framing budget F = checked(E + 16), preserving legacy slack. + +At D = 65,536: E = 87,384 and F = 87,400. +At D = 16,777,216: E = 22,369,624 and F = 22,369,640. +Validation additionally checks actual decoded length, canonical base64 and +padding: equal encoded lengths can represent different decoded lengths. + +Keep `TerminalSession.MaximumBufferedInputBytes` at 87,400 and preserve +`TerminalInputDecoderOptions.MaximumBufferedBytes` validation/default behavior. +Do not raise every input/response buffer to the clipboard hard maximum. +Explain that the existing property bounds ordinary undecoded input; a recognized +clipboard response may use a separate bounded accumulation budget. + +Only a complete matching OSC 52 selection prefix belonging to an active query +or its bounded late-response owner can enter the clipboard accumulator. +Unrelated OSC/CSI/DCS/APC, unsolicited data, wrong-selection responses and +ordinary input retain their existing rules. The generic `ForCompletion` +response-plan behavior must not globally inherit the new clipboard ceiling. + +For read budgets at or below the legacy 65,536-byte default, preserve the existing +effective framing ceiling imposed by a caller's smaller ordinary-input buffer. +Only an explicit read budget above 65,536 enables the dedicated larger-response +path. This avoids silently changing existing small-buffer callers. Document this +compatibility rule and test both paths; callers requesting larger reads do not +also have to enlarge their ordinary keyboard buffer. + +The accumulator is internal and attached to the existing decoder/query owner. +It accepts fragmented data from that reader; it never opens another input +reader. Allocate lazily according to received bytes, not at session open. +Track incremental parse position rather than rescanning the entire accumulated +response after each byte. Reset/release storage on every terminal outcome. + +## Output contract + +Validate selection, payload size and text encoding before output commitment. +Serialize through the existing output gate. Emit one complete OSC 52 operation; +several replacement operations are not an append protocol. + +Avoid the existing intermediate base64 string plus ASCII conversion for large +writes. Encode directly into the bounded output-frame buffer or use an internal +bounded encoder that preserves a single serialized logical frame. No public +streaming API is introduced. + +Preserve pre-commit caller cancellation and complete-frame integrity once +transmission begins. Do not add mid-frame caller cancellation or claim rollback. +Transport failure may leave remote state uncertain; surface the failure and +retain the library's existing lifecycle/failure handling. A nonresponsive output +transport cannot be made universally interruptible by this feature. + +Completion means output was emitted, not that the terminal changed its clipboard. +No automatic readback, support inference or write-acknowledgement claim. + +## Input, deadlines and recovery + +Build an explicit clipboard response plan carrying the session read budget. +The query deadline still starts at queueing and includes waiting for output. +Use the existing query transaction manager and late-response ownership. +A timed-out/cancelled request must not supply a result to a later query. + +Return the complete decoded byte array only after validation. Oversized, +malformed or truncated correlated replies yield failure, never partial success. +No silent truncation; no response fragments may become ordinary typed input. + +On correlated overflow, stop accumulating and discard through the OSC terminator +using bounded scratch space. Freeze an explicit recovery byte budget from the +query's frame budget and reuse the existing late-ownership/lifecycle timing +contract. Recommended additional discard allowance: one F-byte frame budget; +after that, fail closed with the existing decoder failure mechanism rather than +resuming ordinary input inside an unresolved OSC string. Do not replace the old +global discard limit with an effectively unbounded global limit. + +At EOF, release retained storage and expose failure/end-of-input according to +the existing query contract. Caller cancellation does not authorize abandoning +a partial frame and treating its remainder as input. Disposal cancels the +underlying reader and releases retained state deterministically. + +Timeout does not establish that clipboard reads are unsupported or denied. +The terminal may ignore reads by policy. Samples must distinguish transmission, +decoded response, timeout and operator-observed clipboard behavior. + +## Memory, performance and privacy + +Default sessions must not allocate capacity for the 16 MiB ceiling. Write +preflight rejects over-budget input before allocating a frame. Incoming storage +must remain proportional to the active query's configured budget, not peer input. + +Use deterministic allocation/buffer-growth instrumentation in tests: +no retained accumulator after completion/disposal; no full-prefix reparse per +fragment; peak response storage stays within F plus bounded growth/copy overhead. +Before merge, record peak allocation and elapsed time for 64 KiB, 1 MiB and +16 MiB reads/writes and fragmented reads. No "unlimited" or zero-copy claims. + +Clipboard payloads must not appear in exceptions, diagnostics, sample reports, +CI artifacts or compatibility evidence. Tests/samples use generated fixtures. +Interactive read/write scenarios require an explicit operator action; do not +query or overwrite the user's clipboard during sample startup or session open. + +## Compatibility and release gates + +- Preserve existing overload signatures, enum values, default frame grammar, + strict UTF-8 policy and empty-content behavior. +- Retain existing exception categories: local binary size violation is + ArgumentOutOfRangeException; text-size/invalid-text violation is ArgumentException; + correlated malformed/oversized response is FormatException. +- Preserve small/default sessions, concurrent-session isolation, query ordering, + unsolicited events, bracketed paste and late-response behavior. +- C# 13, net8.0/net9.0/net10.0, AnyCPU where possible; existing dependencies only. +- DLL LGPL-3.0-or-later; samples/tests GPL-3.0-or-later; established file headers. +- Scripts remain cmd/sh/PowerShell 5.1-compatible; no Python or native helpers. +- Execute inline, no subagents. Owner merges, tags and publishes. +- Planning PR does not bump runtime/package versions. T2900 establishes + 1.29.0-alpha.1; stable 1.29.0 promotion is a later reviewed step. +- Live results identify terminal/version, OS, transport, runtime, commit and + payload sizes. Unavailable environments remain NotRun; do not invent coverage. + +## Implementation rulings + +- Reuse the existing control-sequence scanner incrementally; no second reader or + duplicate control grammar. Keep the generic scanner ceiling unchanged. +- An invalid/oversized correlated frame reports FormatException after bounded + terminator drain. If no terminator arrives, the caller deadline may expire first; + exhausted recovery or late ownership fails the input coordinator closed. +- Preserve existing wrong-selection/unsolicited decoding and tiny ordinary-buffer + behavior. Seven ordinary bytes accommodate selection-prefix recognition; once + correlated, larger reads do not depend on the ordinary buffer's payload capacity. +- Verify linear work with one scanner Feed call per input byte and deterministic + allocation bounds rather than production-only instrumentation counters. Record + measurements and outstanding live gates in the qualification document. + +## Authorities + +- [Existing clipboard write contract](T56-Semantic-Clipboard-Write-API.md) +- [Existing clipboard read contract](T57-Explicit-Clipboard-Read-API.md) +- [XTerm control sequences, OSC 52](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) +- [Architecture](Architecture.md) +- [Security and privacy](Security-and-Privacy.md) +- [Development roadmap](../Icod.Terminal-1.29.0-Development-Roadmap.md) diff --git a/docs/Clipboard-Transfer-Qualification-1.29.md b/docs/Clipboard-Transfer-Qualification-1.29.md new file mode 100644 index 000000000..af29199ca --- /dev/null +++ b/docs/Clipboard-Transfer-Qualification-1.29.md @@ -0,0 +1,120 @@ +# Clipboard transfer qualification — 1.29 + +Release preparation: **1.29.0**, implementation in +[PR #87](https://github.com/uniblab/Icod.Terminal/pull/87). +Recorded 2026-10-11 UTC. This is automated/component evidence, not live clipboard +acceptance. Merge, tagging and publication remain separate maintainer actions. + +## Environment and reproducibility + +Linux 6.18.44, x86_64; SDK 10.0.100; runtime 8.0.22, 9.0.11 and 10.0.0. +PowerShell 7.5.4 ran the existing package scripts. This container denies MSBuild +worker sockets, so local builds used `-m:1 -p:UseSharedCompilation=false` with +`DOTNET_EnableDiagnostics=0`. A scratch-only command adapter serializes builds +invoked by the unmodified package scripts. Repository build behavior is unchanged. +These results do not claim a Windows PowerShell 5.1 or live terminal execution. + +## Automated evidence + +| Check | Result | +| --- | --- | +| Release solution build, all targets | Passed; zero warnings/errors | +| Full unit suite | 2,925 passed per target on net8.0/net9.0/net10.0; zero skipped/failed | +| TermInfo integration suite | 15 passed per target; zero skipped/failed | +| Clipboard help and no-argument startup | Passed headlessly; no terminal opened | +| Compatibility sample fresh-package smoke | Passed on all targets; lists/describes the larger scenario headlessly and preserves historical evidence matrices | +| Header/license gate | Passed through package build | +| Cross-TFM API snapshots | Identical; only the new options type/session property added | +| Fresh package validation | Passed on all three targets, including exact larger write/read bytes through public APIs | +| Existing DCurses package acceptance | Passed on all targets with published DCurses 1.6.0/2.2.0 and Terminal-owned screen contracts | + +The additional simultaneous-session budget isolation test passed separately on +all three targets, bringing unique qualified unit cases to 2,926 per target. +Exact GitHub matrix status is authoritative on the PR; local results +do not substitute for unobserved Windows/macOS jobs. + +Read/write cases cover 65,537 bytes, 1 MiB and 16 MiB, strict text byte accounting, +same-length base64 blocks crossing a decoded limit, one-byte fragmentation, +BEL/ESC-ST/C1 framing, a 32-byte ordinary input buffer, wrong-selection isolation, +malformed replies, exact overflow boundaries, bounded recovery exhaustion, EOF, +timeout, cancellation, late ownership and disposal. Existing ordering, lifecycle, +ordinary input, paste and unrelated protocol regression tests remain in the suite. + +One-byte malformed framing exposed an empty-drain consumption bug and an already +consumed terminator edge case; regression fixtures now exercise both. The decoder +does not consume the next key while searching for a second terminator. + +API SHA-256: + +```text +3239c1682724f7a4df4b55cf1c29117d23d3bfd88e23c3c8c715a5d48cced517 +``` + +The baseline at `2c410cd80663fef30912079e2c0de7a550852b97` was regenerated and +matched its retained 1.28 fingerprint before reviewing the additive API diff. + +## Allocation and work + +`TerminalClipboardAllocationTests` measures the synchronous encoder and the +incremental accumulator plus final copy/decoding, excluding generated fixtures +and test assertions. The read measurement deliberately calls Feed once per byte; +it does not repeatedly reparse the accumulated prefix. It measures that component +at all sizes, including 64 KiB, rather than claiming the default session changed +to the opt-in reader. Session-path fragmentation tests separately exercise transport +integration. Elapsed times are observations, never unit-test pass thresholds. + +Representative .NET 10 Release run: + +| Decoded bytes | Write allocated | Write ms | Incremental read allocated | Read ms | Live response storage bound | +| ---: | ---: | ---: | ---: | ---: | ---: | +| 65,536 | 87,424 | 1.235 | 368,168 | 17.036 | 240,336 | +| 1,048,576 | 1,398,144 | 1.421 | 5,942,024 | 28.582 | 3,844,816 | +| 16,777,216 | 22,369,664 | 18.421 | 95,067,328 | 460.387 | 61,516,496 | + +All memory columns are bytes. Allocation totals include arrays that become +collectible during growth; the last column is an analytical bound, **not measured +process RSS**, excluding object headers, ordinary scratch storage and caller/ +transport buffers. With `F = 4 * ceil(N/3) + 16`, simultaneously reachable response +storage is bounded by `2F + N`: growth overlaps old/new arrays, or the accumulator +coexists with its exact frame copy and decoded result. Total allocations are bounded +by `4F + N` plus fixed overhead. Old accumulator arrays are cleared on growth and +disposal; completed results/caller buffers follow normal managed ownership. + +The write allocation assertion allows 32 KiB fixed CLR bookkeeping slack across +frameworks; it still rejects an additional payload-sized buffer or base64 string. +One .NET 10 full-suite run reported approximately 8 KiB additional accounting at +16 MiB, so a 1 KiB slack threshold was too narrow. Idle options/accumulator and +over-budget rejection tests assert no payload-sized allocation. + +A separate warmed encoder comparison used the same 65,536-byte input and exact +87,393-byte output on the original 1.28 source and this implementation: + +| Encoder | Allocated bytes | Elapsed ms | +| --- | ---: | ---: | +| 1.28 baseline | 262,216 | 1.943 | +| 1.29 candidate | 87,424 | 2.206 | + +The memory reduction is demonstrated; these single timing observations establish +no throughput ranking. Whole-session peak-memory profiling, OS-specific allocation +behavior and transport throughput remain follow-up qualification. Complete-frame +writes still have a non-cancellable post-commit interval; no streaming/zero-copy +claim is made. + +## Live and release gates + +| Environment | Clipboard scenario | Status | +| --- | --- | --- | +| Windows Terminal | Default, larger transfer, refusal/timeout | NotRun | +| Linux emulator / SSH / multiplexer | Default, larger transfer, refusal/timeout | NotRun | +| macOS emulator | Default, larger transfer, refusal/timeout | NotRun | + +No operator clipboard was queried or overwritten during implementation. The +compatibility sample provides consented `clipboard.osc52` and +`clipboard.osc52.large` scenarios; the latter is exactly 131,072 generated ASCII +bytes. A live record must state exact terminal/version, OS, runtime, transport, +source commit and sizes. A timeout is inconclusive, and output emission alone is +not terminal acceptance. Unavailable environments remain NotRun unless the +maintainer explicitly accepts a release exception. + +Final GitHub CI, whole-session profiling, any desired live checks and stable +promotion remain visible in the roadmap. Nothing here authorizes merge/tag/publish. diff --git a/docs/Clipboard-Transfers.md b/docs/Clipboard-Transfers.md new file mode 100644 index 000000000..8faecfe9c --- /dev/null +++ b/docs/Clipboard-Transfers.md @@ -0,0 +1,80 @@ +# Clipboard transfers + +`TerminalSession` reads and writes explicit OSC 52 selections. Session startup, +capability inspection and disposal never access clipboard contents automatically. + +Configure local resources when opening the session: + +```csharp +using Icod.Terminal; + +await using TerminalSession session = await TerminalSession.OpenAsync(new TerminalSessionOptions { + ClipboardOptions = new TerminalClipboardOptions { + MaximumReadBytes = 1_048_576, + MaximumWriteBytes = 1_048_576 + } +}); + +// Run these only in response to the application's explicit copy/paste commands. +await session.WriteClipboardAsync(TerminalClipboardSelection.Clipboard, "Copied text"); +byte[] bytes = await session.ReadClipboardAsync( + TerminalClipboardSelection.Clipboard, TimeSpan.FromSeconds(5)); +``` + +Read and write limits are independent, default to 65,536 bytes, and accept +1..16,777,216 bytes inclusive. Text counts strict UTF-8 bytes without a BOM, +not UTF-16 characters. An empty write explicitly clears the chosen selection. +No clipboard operation silently truncates. Binary oversize throws +`ArgumentOutOfRangeException`; oversized/invalid text throws `ArgumentException`. + +Increasing a limit authorizes allocation; it does not allocate the whole budget +at session open. Output preflight counts bytes before allocating one complete +encoded frame. Once emission commits, caller cancellation cannot deliberately +split that frame; output transport failure can still leave remote state uncertain. +Write completion means emission through the output abstraction, not remote acceptance. + +Larger reads retain only an exactly correlated reply in a separate accumulator, +grown as bytes arrive. Ordinary undecoded input keeps its 87,400-byte ceiling. +If customizing `InputDecoderOptions.MaximumBufferedBytes`, allow at least seven +bytes for the complete selection prefix. Existing ordinary-buffer behavior is +retained for read budgets at or below 64 KiB. Wrong-selection or unsolicited +frames retain existing ordinary handling and receive no larger allowance. + +The caller deadline includes queueing. Cancellation/timeout after emission leaves +bounded late ownership so a delayed reply cannot satisfy the next query. Malformed +or oversized replies are drained through a terminator before `FormatException`. +Recovery discards at most one additional frame budget, using bounded scratch space. +If that allowance or late ownership expires with no terminator, input fails closed. +The application should dispose/reopen the session before resuming input after such +a failure. A caller deadline may produce `TimeoutException` first. + +The frame budget is `4 * ceil(N / 3) + 16`, where N is the decoded read limit. +That is a framing allowance, not a total managed-memory limit: geometric growth, +the exact response copy and the returned byte array have additional costs. See +the [qualification record](Clipboard-Transfer-Qualification-1.29.md). + +OSC 52 does not negotiate maximum capacity. Terminal policy, multiplexers, SSH +paths or transport constraints may impose smaller limits or suppress replies. +There is no automatic probe, readback, retry, splitting into replacement writes, +native clipboard bridge or automatic fallback. A timeout establishes no support +status. Editors should retain their own clipboard, offer a clear external-copy +failure message, and ask the user to explicitly retry a smaller selection if needed. + +## Runnable sample + +From the repository root, help is headless and has no clipboard side effects: + +```text +dotnet run --project samples/Icod.Terminal.Clipboard.Sample -c Release -f net10.0 -- --help +``` + +The following explicitly overwrites the clipboard with 131,072 generated ASCII +bytes and explicitly queries it for a byte-for-byte round-trip check: + +```text +dotnet run --project samples/Icod.Terminal.Clipboard.Sample -c Release -f net10.0 -- --generated-bytes 131072 --maximum-write-bytes 131072 --read --maximum-read-bytes 131072 +``` + +Use `--read` alone for an explicit read, or `--write "text"` for an explicit write. +No arguments show help. The legacy positional-text invocation is an explicit +write only. Samples print counts and outcomes, never payloads. diff --git a/docs/Compatibility-and-Versioning.md b/docs/Compatibility-and-Versioning.md index b2386ee60..c361f7b15 100644 --- a/docs/Compatibility-and-Versioning.md +++ b/docs/Compatibility-and-Versioning.md @@ -263,6 +263,24 @@ Stable 1.x does not promise: Relative placement, lifecycle observation, virtual placeholders, and resource-owned animation are deliberately bounded additions and must not be interpreted as promises for these excluded features. +## 1.29 clipboard transfer compatibility + +The additive `TerminalClipboardOptions` contract preserves existing clipboard +overloads, selection values, strict UTF-8 behavior and default decoded-byte limits. +Independent init-only read/write budgets accept 1..16 MiB; no production dependency +changes. See [the API baseline](Public-API-Baseline-1.29.md). + +Budgets above 64 KiB opt into a separate correlated accumulator; budgets at or +below it retain ordinary-buffer framing. Ordinary input, paste and unrelated +query ceilings are unchanged. Customized ordinary buffers must still accommodate +the selection prefix (seven bytes covers both supported introducers). +Wrong-selection/unsolicited handling remains unchanged. Bounded recovery can +fail input closed if the peer never terminates a correlated reply. + +This contract authorizes local resources, with no remote negotiation or acceptance +inference. Headless package evidence and live emulator observations remain separate; +consult [the qualification record](Clipboard-Transfer-Qualification-1.29.md). + ## 11.1 1.28 animation transaction compatibility The published Kitty Graphics Protocol, not terminal branding or a version string, selects wire syntax and response policy. Required `a=f` operations succeed only with a correlated protocol acknowledgement. No-response `a=a` controls add `q=2` and report `OutputCommitted` after the complete serialized write and flush. Optional-response `a=c` composition reports `ProtocolAcknowledged` for correlated `OK`, a definitive failure for correlated error, and `OutputCommitted` for bounded committed silence. diff --git a/docs/Public-API-Baseline-1.29.md b/docs/Public-API-Baseline-1.29.md new file mode 100644 index 000000000..7c2a18dce --- /dev/null +++ b/docs/Public-API-Baseline-1.29.md @@ -0,0 +1,18 @@ +# Icod.Terminal 1.29 Public API Baseline + +The additive clipboard contract introduces `TerminalClipboardOptions`, its public +parameterless constructor, static `DefaultMaximumBytes` and `SupportedMaximumBytes`, +and independent init-only `MaximumReadBytes` / `MaximumWriteBytes` properties. +`TerminalSessionOptions.ClipboardOptions` is init-only and defaults to new options. +Existing method signatures, default limits and enum values are preserved. + +Snapshots generated from the alpha implementation are identical on net8.0, +net9.0 and net10.0. SHA-256: + +```text +3239c1682724f7a4df4b55cf1c29117d23d3bfd88e23c3c8c715a5d48cced517 +``` + +The machine-readable fingerprint is [Public-API-Baseline-1.29.sha256](Public-API-Baseline-1.29.sha256). +The [1.28 baseline](Public-API-Baseline-1.28.md) remains historical evidence. +This qualifies API shape, not terminal capacity or clipboard acceptance. diff --git a/docs/Public-API-Baseline-1.29.sha256 b/docs/Public-API-Baseline-1.29.sha256 new file mode 100644 index 000000000..1f6885b87 --- /dev/null +++ b/docs/Public-API-Baseline-1.29.sha256 @@ -0,0 +1 @@ +3239c1682724f7a4df4b55cf1c29117d23d3bfd88e23c3c8c715a5d48cced517 diff --git a/docs/Security-and-Privacy.md b/docs/Security-and-Privacy.md index 460067c9c..5cf17d3c3 100644 --- a/docs/Security-and-Privacy.md +++ b/docs/Security-and-Privacy.md @@ -308,6 +308,26 @@ Reporting is explicitly opt-in and independently owned. Appearance is a terminal Compatibility reporting scenarios require consent before acquisition and before operator-visible confirmation. Evidence contains exact manually supplied terminal/OS/transport versions, scenario revision, bounded typed outcomes and notes. It omits raw replies, raw keys, environment dumps and host identity. Review JSON before accepting it; preserve explicit Unavailable versus missing/deadline Inconclusive outcomes. +## Bounded clipboard resources (1.29) + +Clipboard reads and writes remain explicit application actions. Immutable session +budgets independently authorize 1..16 MiB decoded bytes; defaults remain 64 KiB. +They establish no remote capacity, read permission or write acknowledgement. +Default sessions reserve no maximum-sized clipboard storage. Larger responses +acquire dedicated storage only after exact selection-prefix correlation. + +Malformed/overflowed correlated responses use bounded terminator draining; when +recovery or late ownership expires unresolved, input fails closed. Applications +must dispose/reopen before resuming input after such a failure. Never display +partial content as a successful paste. Wrong-selection frames keep their existing +ordinary handling and receive no larger resource allowance. + +Library clipboard errors and samples omit payload contents. Generated fixtures +are used in tests and compatibility reports. Accumulator buffers are cleared when +replaced/disposed; this is not a secure-erasure guarantee for caller buffers, +returned arrays, transport copies or the managed heap. Applications own those +lifetimes and any internal clipboard fallback. See [the guide](Clipboard-Transfers.md). + ## Graphics transaction evidence (1.28) `OutputCommitted` is a local transport fact: Terminal completed the serialized write and flush. It is not proof that an untrusted terminal parsed, applied, retained, or rendered the command. `ProtocolAcknowledged` adds only a well-formed correlated success owned by the correct bounded transaction. It is not terminal authentication or a rendering guarantee. Existing/unclassified successful mutations remain `Unspecified` rather than being retroactively strengthened. diff --git a/docs/releases/1.29.0-alpha.1.md b/docs/releases/1.29.0-alpha.1.md new file mode 100644 index 000000000..97c8ead30 --- /dev/null +++ b/docs/releases/1.29.0-alpha.1.md @@ -0,0 +1,31 @@ +# Icod.Terminal 1.29.0-alpha.1 + +This development candidate adds bounded, opt-in larger OSC 52 clipboard transfers. + +- `TerminalSessionOptions.ClipboardOptions` supplies independent, immutable read + and write budgets. Defaults remain 65,536 decoded bytes; each budget accepts + 1 through 16,777,216 bytes, inclusive. +- Text is counted in strict UTF-8 bytes. Oversized writes fail before output; + direct base64 encoding avoids an intermediate encoded string. Existing write + ordering and post-commit cancellation semantics remain unchanged. +- Explicit larger reads use a lazily grown, query-owned accumulator after exact + selection-prefix correlation. Ordinary input and unrelated protocol limits stay + unchanged. Malformed and oversized replies never return partial results. +- Bounded terminator recovery preserves trailing input. Unresolved recovery or + expired late ownership fails the input coordinator closed instead of decoding + the rest of a correlated clipboard string as keys. +- Samples require explicit clipboard actions, support generated fixtures and + independent limits, and report counts rather than clipboard contents. The + compatibility sample adds a consented 131,072-byte round-trip scenario. +- Fresh-package smoke checks exercise defaults and separate 1 MiB read/write + budgets using public APIs on .NET 8, 9 and 10. Production dependencies are unchanged. + +These are local resource budgets. OSC 52 has no standard capacity negotiation or +write acknowledgement; emission does not establish remote acceptance. Query +timeout does not establish unsupported functionality. Applications own internal +clipboard fallback and user messaging. + +See the [usage guide](../Clipboard-Transfers.md), [API baseline](../Public-API-Baseline-1.29.md), +and [qualification record](../Clipboard-Transfer-Qualification-1.29.md). +Live clipboard environments remain NotRun until exact observations are supplied. +Stable promotion, merge, tagging and publication remain maintainer actions. diff --git a/docs/releases/1.29.0.md b/docs/releases/1.29.0.md new file mode 100644 index 000000000..006911630 --- /dev/null +++ b/docs/releases/1.29.0.md @@ -0,0 +1,33 @@ +# Icod.Terminal 1.29.0 + +This stable release adds bounded, opt-in larger OSC 52 clipboard transfers. + +- `TerminalSessionOptions.ClipboardOptions` supplies independent, immutable read + and write budgets. Defaults remain 65,536 decoded bytes; each budget accepts + 1 through 16,777,216 bytes, inclusive. +- Text is counted in strict UTF-8 bytes. Oversized writes fail before output; + direct base64 encoding avoids an intermediate encoded string. Existing write + ordering and post-commit cancellation semantics remain unchanged. +- Explicit larger reads use a lazily grown, query-owned accumulator after exact + selection-prefix correlation. Ordinary input and unrelated protocol limits stay + unchanged. Malformed and oversized replies never return partial results. +- Bounded terminator recovery preserves trailing input. Unresolved recovery or + expired late ownership fails the input coordinator closed instead of decoding + the rest of a correlated clipboard string as keys. +- Samples require explicit clipboard actions, support generated fixtures and + independent limits, and report counts rather than clipboard contents. The + compatibility sample adds a consented 131,072-byte round-trip scenario. +- Fresh-package smoke checks exercise defaults and separate 1 MiB read/write + budgets using public APIs on `net8.0`, `net9.0` and `net10.0`. Production + dependencies are unchanged. + +These are local resource budgets. OSC 52 has no standard capacity negotiation or +write acknowledgement; emission does not establish remote acceptance. Query +timeout does not establish unsupported functionality. Applications own internal +clipboard fallback and user messaging. + +See the [usage guide](../Clipboard-Transfers.md), [API baseline](../Public-API-Baseline-1.29.md), +[compatibility policy](../Compatibility-and-Versioning.md), and +[qualification record](../Clipboard-Transfer-Qualification-1.29.md). +Live clipboard environments remain NotRun until exact observations are supplied. +Merge, tagging and publication remain maintainer actions. diff --git a/packaging/VerifyEnvironmentAwarenessPackage.ps1 b/packaging/VerifyEnvironmentAwarenessPackage.ps1 index 9bfa8e33e..b43ced5dd 100644 --- a/packaging/VerifyEnvironmentAwarenessPackage.ps1 +++ b/packaging/VerifyEnvironmentAwarenessPackage.ps1 @@ -74,8 +74,14 @@ try { $reader = [System.IO.StreamReader]::new($archive.GetEntry('README.md').Open()) try { $readme = $reader.ReadToEnd() } finally { $reader.Dispose() } $notes = $nuspec.SelectSingleNode("//*[local-name()='metadata']/*[local-name()='releaseNotes']").InnerText - foreach ($token in @($ExpectedVersion, "docs/releases/$ExpectedVersion.md", 'Compatibility-and-Versioning.md')) { - if (-not $readme.Contains($token) -or -not $notes.Contains($token)) { throw "Packed release documentation is missing '$token'." } + $documentationRoot = 'https://github.com/uniblab/Icod.Terminal/blob/main/docs' + $releaseNotesUrl = "$documentationRoot/releases/$ExpectedVersion.md" + $compatibilityPolicyUrl = "$documentationRoot/Compatibility-and-Versioning.md" + foreach ($token in @($ExpectedVersion, $compatibilityPolicyUrl)) { + if (-not $readme.Contains($token)) { throw "Packed README is missing '$token'." } + } + foreach ($token in @($ExpectedVersion, $releaseNotesUrl, $compatibilityPolicyUrl)) { + if (-not $notes.Contains($token)) { throw "Packed package release notes are missing '$token'." } } foreach ($token in @('terminal appearance', 'in-band resize', 'native', 'QueryAppearanceAsync', 'AcquireAppearanceReportingAsync', 'AcquireInBandResizeReportingAsync')) { if (-not $readme.Contains($token)) { throw "Packed README is missing environment contract '$token'." } diff --git a/packaging/VerifyPackageArtifact.ps1 b/packaging/VerifyPackageArtifact.ps1 index 785ea674f..6f1a8391a 100644 --- a/packaging/VerifyPackageArtifact.ps1 +++ b/packaging/VerifyPackageArtifact.ps1 @@ -71,6 +71,12 @@ function Assert-ClipboardApiDocumentation { $requiredMembers = @( 'T:Icod.Terminal.TerminalClipboardSelection', + 'T:Icod.Terminal.TerminalClipboardOptions', + 'P:Icod.Terminal.TerminalClipboardOptions.DefaultMaximumBytes', + 'P:Icod.Terminal.TerminalClipboardOptions.SupportedMaximumBytes', + 'P:Icod.Terminal.TerminalClipboardOptions.MaximumReadBytes', + 'P:Icod.Terminal.TerminalClipboardOptions.MaximumWriteBytes', + 'P:Icod.Terminal.TerminalSessionOptions.ClipboardOptions', 'M:Icod.Terminal.TerminalSession.WriteClipboardAsync(Icod.Terminal.TerminalClipboardSelection,System.ReadOnlyMemory{System.Byte},System.Threading.CancellationToken)', 'M:Icod.Terminal.TerminalSession.WriteClipboardAsync(Icod.Terminal.TerminalClipboardSelection,System.String,System.Threading.CancellationToken)', 'M:Icod.Terminal.TerminalSession.ReadClipboardAsync(Icod.Terminal.TerminalClipboardSelection,System.TimeSpan,System.Threading.CancellationToken)' diff --git a/packaging/VerifyPublicApiBaseline.ps1 b/packaging/VerifyPublicApiBaseline.ps1 index 9f5b7888a..e16919576 100644 --- a/packaging/VerifyPublicApiBaseline.ps1 +++ b/packaging/VerifyPublicApiBaseline.ps1 @@ -1,7 +1,7 @@ param( [string]$Configuration = 'Staging', [string]$OutputDirectory = 'artifacts/public-api', - [string]$BaselinePath = 'docs/Public-API-Baseline-1.28.sha256' + [string]$BaselinePath = 'docs/Public-API-Baseline-1.29.sha256' ) Set-StrictMode -Version Latest diff --git a/packaging/VerifyReleaseLinePackage.ps1 b/packaging/VerifyReleaseLinePackage.ps1 index 084a1a692..8b215ea53 100644 --- a/packaging/VerifyReleaseLinePackage.ps1 +++ b/packaging/VerifyReleaseLinePackage.ps1 @@ -164,7 +164,7 @@ try { $statusMatches = [regex]::Matches( $readme, - '(?m)^Current stable release: `Icod\.Terminal (?[^`\r\n]+)`\.\r?$' + '(?m)^Stable release: \*\*(?[^*\r\n]+)\*\*\.' ) if ( 1 -ne $statusMatches.Count ) { throw "The packed README must contain exactly one current-stable-release line; found $($statusMatches.Count)." diff --git a/samples/Icod.Terminal.Clipboard.Sample/ClipboardCommandLine.cs b/samples/Icod.Terminal.Clipboard.Sample/ClipboardCommandLine.cs new file mode 100644 index 000000000..caa37d9a0 --- /dev/null +++ b/samples/Icod.Terminal.Clipboard.Sample/ClipboardCommandLine.cs @@ -0,0 +1,130 @@ +/* + Icod.Terminal.Clipboard.Sample + Sample application demonstrating Icod.Terminal Clipboard features. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +using System.Text; + +namespace Icod.Terminal.Clipboard.Sample; +using System.Globalization; +using System.Text; + +internal static class ClipboardCommandLine { + internal const string HelpText = """ +Usage: Icod.Terminal.Clipboard.Sample [operation] [limits] + --help Show help without opening a terminal. + --write TEXT Explicitly replace the clipboard (empty clears it). + --generated-bytes N Write N generated ASCII bytes (0..16777216). + --read Explicitly read the clipboard; report byte count only. + --maximum-read-bytes N Local read limit, default 65536; range 1..16777216. + --maximum-write-bytes N Local write limit, default 65536; range 1..16777216. +A positional TEXT argument is also an explicit write. Combine a write with --read +for a generated/text round-trip check. Writes overwrite existing clipboard contents. +Limits authorize local resources; they do not negotiate terminal capacity. +"""; + + internal static async Task RunAsync( + string[] args, TextWriter output, TextWriter error, + Func> open, + CancellationToken cancellationToken = default + ) { + if ( args.Length == 0 || args.SequenceEqual( new[] { "--help" } ) ) { + await output.WriteLineAsync( HelpText ); + return 0; + } + string? text = null; + int? generated = null; + bool read = false; + int maximumRead = TerminalClipboardOptions.DefaultMaximumBytes; + int maximumWrite = TerminalClipboardOptions.DefaultMaximumBytes; + byte[]? expected = null; + try { + for ( int i = 0; i < args.Length; ++i ) { + switch ( args[ i ] ) { + case "--read": read = true; break; + case "--write": text = Next( args, ref i ); break; + case "--generated-bytes": generated = Number( Next( args, ref i ), 0 ); break; + case "--maximum-read-bytes": maximumRead = Number( Next( args, ref i ), 1 ); break; + case "--maximum-write-bytes": maximumWrite = Number( Next( args, ref i ), 1 ); break; + default: + if ( args.Length == 1 && !args[ i ].StartsWith( "--", StringComparison.Ordinal ) ) text = args[ i ]; + else throw new ArgumentException( "Unknown or incomplete clipboard command." ); + break; + } + } + if ( text is not null && generated.HasValue ) + throw new ArgumentException( "Choose either text or generated bytes for the write." ); + if ( text is null && !generated.HasValue && !read ) + throw new ArgumentException( "Choose an explicit read or write operation." ); + int count = generated ?? ( text is null ? 0 : new UTF8Encoding( false, true ).GetByteCount( text ) ); + if ( count > maximumWrite ) + throw new ArgumentException( "The write exceeds the local write-byte limit; raise --maximum-write-bytes explicitly." ); + if ( text is not null ) expected = new UTF8Encoding( false, true ).GetBytes( text ); + else if ( generated.HasValue ) { + expected = new byte[ count ]; + for ( int i = 0; i < count; ++i ) expected[ i ] = (byte)( 'A' + i % 26 ); + } + } catch ( ArgumentException ) { + await error.WriteLineAsync( "Invalid clipboard command, text, or byte limit. Check --help; writes must fit the local write-byte limit." ); + return 2; + } + try { + await using TerminalSession session = await open( new TerminalSessionOptions { + InputMode = TerminalInputMode.CBreak, + EchoInput = false, + ClipboardOptions = new() { MaximumReadBytes = maximumRead, MaximumWriteBytes = maximumWrite } + }, cancellationToken ); + if ( expected is not null ) { + await session.WriteClipboardAsync( TerminalClipboardSelection.Clipboard, expected, cancellationToken ); + await output.WriteLineAsync( $"Emitted an OSC 52 write of {expected.Length} decoded bytes; terminal acceptance is unconfirmed." ); + } + if ( read ) { + byte[] actual = await session.ReadClipboardAsync( TerminalClipboardSelection.Clipboard, + TimeSpan.FromSeconds( 5 ), cancellationToken ); + await output.WriteLineAsync( $"Received {actual.Length} decoded clipboard bytes." ); + if ( expected is not null ) { + bool matches = expected.AsSpan().SequenceEqual( actual ); + await output.WriteLineAsync( matches ? "The explicit round trip matched." : "The explicit round trip did not match." ); + if ( !matches ) return 1; + } + } + return 0; + } catch ( TimeoutException ) { + await error.WriteLineAsync( "No reply arrived before the deadline. Clipboard policy or capacity remains unknown." ); + return 1; + } catch ( OperationCanceledException ) when ( cancellationToken.IsCancellationRequested ) { + await error.WriteLineAsync( "Cancelled; session cleanup completed." ); + return 130; + } catch ( Exception ) { + await error.WriteLineAsync( "Clipboard operation failed; no payload is included in this diagnostic." ); + return 1; + } + } + + private static string Next( string[] args, ref int index ) { + if ( ++index >= args.Length ) throw new ArgumentException( "Missing option value." ); + return args[ index ]; + } + + private static int Number( string value, int minimum ) { + if ( !int.TryParse( value, NumberStyles.None, CultureInfo.InvariantCulture, out int number ) + || number < minimum || number > TerminalClipboardOptions.SupportedMaximumBytes ) + throw new ArgumentException( "Byte count is outside the supported range." ); + return number; + } +} diff --git a/samples/Icod.Terminal.Clipboard.Sample/Icod.Terminal.Clipboard.Sample.csproj b/samples/Icod.Terminal.Clipboard.Sample/Icod.Terminal.Clipboard.Sample.csproj index 7739ba2ee..88a34830b 100644 --- a/samples/Icod.Terminal.Clipboard.Sample/Icod.Terminal.Clipboard.Sample.csproj +++ b/samples/Icod.Terminal.Clipboard.Sample/Icod.Terminal.Clipboard.Sample.csproj @@ -35,6 +35,7 @@ + diff --git a/samples/Icod.Terminal.Clipboard.Sample/Program.cs b/samples/Icod.Terminal.Clipboard.Sample/Program.cs index 4770b9f47..983c1de02 100644 --- a/samples/Icod.Terminal.Clipboard.Sample/Program.cs +++ b/samples/Icod.Terminal.Clipboard.Sample/Program.cs @@ -20,45 +20,12 @@ You should have received a copy of the GNU General Public License */ using System.Text; using Icod.Terminal; - -TerminalClipboardSelection selection = TerminalClipboardSelection.Clipboard; -string text = 1 <= args.Length - ? args[ 0 ] - : "Icod.Terminal OSC 52 clipboard sample" -; - -await using TerminalSession session = await TerminalSession.OpenAsync( - new TerminalSessionOptions { - InputMode = TerminalInputMode.CBreak, - EchoInput = false - } -); - -await session.WriteClipboardAsync( - selection, - text -); -await session.WriteTextAsync( - "Wrote explicit UTF-8 text to the terminal clipboard selection.\r\n" -); - -try { - byte[] payload = await session.ReadClipboardAsync( - selection, - TimeSpan.FromMilliseconds( 750 ) - ); - string value = Encoding.UTF8.GetString( payload ); - await session.WriteTextAsync( - $"Read {payload.Length} clipboard bytes: {value}\r\n" - ); -} catch ( TimeoutException ) { - await session.WriteTextAsync( - "The terminal did not return an OSC 52 clipboard response before the timeout.\r\n" - ); -} catch ( FormatException exception ) { - await session.WriteTextAsync( - $"The terminal returned an invalid OSC 52 clipboard response: {exception.Message}\r\n" - ); -} - -return 0; +using Icod.Terminal.Clipboard.Sample; + +using CancellationTokenSource cancellation = new(); +Console.CancelKeyPress += ( _, eventArgs ) => { + eventArgs.Cancel = true; + cancellation.Cancel(); +}; +return await ClipboardCommandLine.RunAsync( args, Console.Out, Console.Error, + static ( options, token ) => TerminalSession.OpenAsync( options, token ), cancellation.Token ); diff --git a/samples/Icod.Terminal.Clipboard.Sample/README.md b/samples/Icod.Terminal.Clipboard.Sample/README.md new file mode 100644 index 000000000..f405118c9 --- /dev/null +++ b/samples/Icod.Terminal.Clipboard.Sample/README.md @@ -0,0 +1,28 @@ +# Clipboard sample + +Run from the repository root: + +```text +dotnet run --project samples/Icod.Terminal.Clipboard.Sample -c Release -f net10.0 -- --help +``` + +No arguments or `--help` show help without opening a terminal or accessing its +clipboard. Choose `--write "text"`, `--generated-bytes N`, or `--read` explicitly. +Positional text remains an explicit write. Combining a write with `--read` requests +a round-trip comparison. Writes replace the clipboard; no automatic prior-content +read or restoration occurs. Reports contain counts and outcomes, never payloads. + +An explicit larger fixture: + +```text +dotnet run --project samples/Icod.Terminal.Clipboard.Sample -c Release -f net10.0 -- --generated-bytes 131072 --maximum-write-bytes 131072 --read --maximum-read-bytes 131072 +``` + +Each limit defaults to 65,536 decoded bytes and accepts 1..16,777,216 inclusive. +The limits are independent and authorize local resources only. A successful write +reports emission; a matching explicit reply establishes only that round trip. +A timeout leaves support and policy unknown. Exit codes: 0 success/help, 1 failed +operation or mismatch, 2 invalid local command/limit/text, 130 caller cancellation. + +See [the library guide](../../docs/Clipboard-Transfers.md) for allocation, +UTF-8 accounting, recovery and application fallback guidance. diff --git a/samples/Icod.Terminal.Compatibility.Sample/ClipboardCompatibilityScenarios.cs b/samples/Icod.Terminal.Compatibility.Sample/ClipboardCompatibilityScenarios.cs index 33339973e..eea217663 100644 --- a/samples/Icod.Terminal.Compatibility.Sample/ClipboardCompatibilityScenarios.cs +++ b/samples/Icod.Terminal.Compatibility.Sample/ClipboardCompatibilityScenarios.cs @@ -24,6 +24,20 @@ namespace Icod.Terminal.Compatibility.Sample; using Icod.Terminal; internal static class ClipboardCompatibilityScenarios { + internal static async ValueTask ExecuteLargeAsync( + TerminalSession session, CancellationToken cancellationToken + ) { + byte[] expected = new byte[ 131_072 ]; + for ( int i = 0; i < expected.Length; ++i ) expected[ i ] = (byte)( 'A' + i % 26 ); + await session.WriteClipboardAsync( TerminalClipboardSelection.Clipboard, expected, cancellationToken ); + byte[] actual = await session.ReadClipboardAsync( TerminalClipboardSelection.Clipboard, + TimeSpan.FromSeconds( 5 ), cancellationToken ); + bool matches = expected.AsSpan().SequenceEqual( actual ); + return new CompatibilityLiveExecution( matches, false, + matches ? "131072 generated bytes completed a byte-for-byte round trip." + : "The reply did not match the 131072 generated bytes.", ExplicitFailure: !matches ); + } + internal const string FixedTestValue = "Icod.Terminal compatibility clipboard value 1.26"; internal static async ValueTask ExecuteAsync( diff --git a/samples/Icod.Terminal.Compatibility.Sample/CompatibilityScenarioCatalog.cs b/samples/Icod.Terminal.Compatibility.Sample/CompatibilityScenarioCatalog.cs index 56c41988d..0457c97a7 100644 --- a/samples/Icod.Terminal.Compatibility.Sample/CompatibilityScenarioCatalog.cs +++ b/samples/Icod.Terminal.Compatibility.Sample/CompatibilityScenarioCatalog.cs @@ -58,6 +58,7 @@ private static IReadOnlyList CreateScenarios() { ( "query.appearance", "Terminal appearance", "The bounded appearance query returns a typed dark or light observation." ), ( "environment.appearance-reporting", "Appearance change reporting", "A consented operator appearance or palette change produces a typed appearance report." ), ( "environment.in-band-resize", "In-band resize reporting", "Consented reporting produces initial and changed typed text-area dimensions." ), + ( "clipboard.osc52.large", "Larger OSC 52 clipboard (131072 bytes)", "131072 generated bytes complete a consented byte-for-byte clipboard round trip; existing contents are overwritten." ), }; var scenarios = new List( definitions.Length ); diff --git a/samples/Icod.Terminal.Compatibility.Sample/InteractiveConfirmation.cs b/samples/Icod.Terminal.Compatibility.Sample/InteractiveConfirmation.cs index f3b029302..4cea30e68 100644 --- a/samples/Icod.Terminal.Compatibility.Sample/InteractiveConfirmation.cs +++ b/samples/Icod.Terminal.Compatibility.Sample/InteractiveConfirmation.cs @@ -252,6 +252,9 @@ CancellationToken cancellationToken "clipboard.osc52" => ClipboardCompatibilityScenarios.ExecuteAsync( this.session, cancellationToken ), + "clipboard.osc52.large" => ClipboardCompatibilityScenarios.ExecuteLargeAsync( + this.session, cancellationToken + ), "input.text-key" or "input.paste-focus-mouse" or "input.modern-keyboard" or diff --git a/samples/Icod.Terminal.Compatibility.Sample/Program.cs b/samples/Icod.Terminal.Compatibility.Sample/Program.cs index 2ec8bc20e..b9a444104 100644 --- a/samples/Icod.Terminal.Compatibility.Sample/Program.cs +++ b/samples/Icod.Terminal.Compatibility.Sample/Program.cs @@ -31,7 +31,13 @@ You should have received a copy of the GNU General Public License args, Console.Out, Console.Error, - static cancellationToken => TerminalSession.OpenAsync( + cancellationToken => TerminalSession.OpenAsync( + new TerminalSessionOptions { + ClipboardOptions = args.Contains( "clipboard.osc52.large", StringComparer.Ordinal ) + || args.Contains( "--run-all", StringComparer.Ordinal ) + ? new() { MaximumReadBytes = 131_072, MaximumWriteBytes = 131_072 } + : new() + }, cancellationToken: cancellationToken ), cancellation.Token diff --git a/samples/Icod.Terminal.Compatibility.Sample/README.md b/samples/Icod.Terminal.Compatibility.Sample/README.md index da5d9fe74..93b5fa4e9 100644 --- a/samples/Icod.Terminal.Compatibility.Sample/README.md +++ b/samples/Icod.Terminal.Compatibility.Sample/README.md @@ -1,5 +1,15 @@ # Icod.Terminal Compatibility Sample +The `clipboard.osc52.large` scenario requests consent before overwriting the +clipboard with 131,072 generated ASCII bytes and reading them back. Its standalone +`--run` invocation opens a session with matching read/write budgets; `--run-all` +also authorizes those budgets, while each scenario retains its own consent gate. +The existing `clipboard.osc52` scenario remains a small fixed-value round trip. +Use the same identity/output options described below and record the exact source +commit, terminal version and transport. Reports contain counts and match outcomes, +never clipboard contents. A timeout proves no support status. To try other sizes +and independent limits, use the [clipboard sample](../Icod.Terminal.Clipboard.Sample/README.md). + This executable qualifies existing nongraphics `Icod.Terminal` behavior without inferring support from terminal branding. It can list and describe scenarios without opening a terminal, run one or all scenarios in an interactive terminal, write bounded JSON evidence, and regenerate a deterministic Markdown matrix. The sample uses only public `Icod.Terminal` APIs. Graphics and raster protocols are deliberately outside the 1.26 matrix. diff --git a/src/Clipboard/TerminalClipboardLimits.cs b/src/Clipboard/TerminalClipboardLimits.cs new file mode 100644 index 000000000..3c49a7139 --- /dev/null +++ b/src/Clipboard/TerminalClipboardLimits.cs @@ -0,0 +1,34 @@ +/* + Icod.Terminal + Managed, cross-platform live-terminal session and terminal-control library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal; + +internal static class TerminalClipboardLimits { + internal static int GetEncodedLength( int decodedBytes ) { + if ( decodedBytes < 0 || decodedBytes > TerminalClipboardOptions.SupportedMaximumBytes ) + throw new ArgumentOutOfRangeException( nameof( decodedBytes ) ); + return checked( 4 * ( ( decodedBytes + 2 ) / 3 ) ); + } + + internal static int GetResponseFrameLimit( int maximumDecodedBytes ) { + TerminalClipboardOptions.ValidateLimit( maximumDecodedBytes, nameof( maximumDecodedBytes ) ); + return checked( GetEncodedLength( maximumDecodedBytes ) + 16 ); + } +} diff --git a/src/Clipboard/TerminalClipboardOptions.cs b/src/Clipboard/TerminalClipboardOptions.cs new file mode 100644 index 000000000..8f77512e6 --- /dev/null +++ b/src/Clipboard/TerminalClipboardOptions.cs @@ -0,0 +1,50 @@ +/* + Icod.Terminal + Managed, cross-platform live-terminal session and terminal-control library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal; + +/// Sets independent local resource budgets for explicit clipboard operations. +/// These limits do not negotiate or establish the terminal's clipboard capacity. +public sealed class TerminalClipboardOptions { + /// Gets the default decoded payload limit of 65,536 bytes. + public static int DefaultMaximumBytes => 65_536; + + /// Gets the largest supported local decoded payload budget, 16 MiB. + public static int SupportedMaximumBytes => 16_777_216; + + /// Gets or initializes the maximum decoded bytes returned by a clipboard read. + public int MaximumReadBytes { get; init; } = 65_536; + + /// Gets or initializes the maximum bytes sent by a clipboard write. + /// Text is counted as strict UTF-8 bytes without a byte-order mark. + public int MaximumWriteBytes { get; init; } = 65_536; + + internal void Validate() { + ValidateLimit( this.MaximumReadBytes, nameof( this.MaximumReadBytes ) ); + ValidateLimit( this.MaximumWriteBytes, nameof( this.MaximumWriteBytes ) ); + } + + internal static void ValidateLimit( int value, string name ) { + if ( value < 1 || value > SupportedMaximumBytes ) { + throw new ArgumentOutOfRangeException( name, value, + $"Clipboard capacity must be between 1 and {SupportedMaximumBytes} decoded bytes." ); + } + } +} diff --git a/src/Input/TerminalClipboardResponseAccumulator.cs b/src/Input/TerminalClipboardResponseAccumulator.cs new file mode 100644 index 000000000..895e1c2d1 --- /dev/null +++ b/src/Input/TerminalClipboardResponseAccumulator.cs @@ -0,0 +1,61 @@ +/* + Icod.Terminal + Managed, cross-platform live-terminal session and terminal-control library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal; + +/// Incrementally retains only an explicitly authorized correlated clipboard frame. +internal sealed class TerminalClipboardResponseAccumulator : IDisposable { + private readonly int maximumFrameBytes; + private readonly TerminalControlSequenceScanner scanner; + private byte[] buffer = []; + private int count; + + internal TerminalClipboardResponseAccumulator( int maximumBytes ) { + this.maximumFrameBytes = TerminalClipboardLimits.GetResponseFrameLimit( maximumBytes ); + this.scanner = new TerminalControlSequenceScanner( this.maximumFrameBytes, clipboardCapacity: true ); + } + + internal TerminalResponseFrameParseStatus Feed( byte value ) { + TerminalResponseFrameParseStatus status = this.scanner.Feed( value ); + if ( status == TerminalResponseFrameParseStatus.Invalid ) return status; + if ( this.count == this.buffer.Length ) { + int capacity = Math.Min( this.maximumFrameBytes, + this.buffer.Length == 0 ? 4096 : checked( this.buffer.Length * 2 ) ); + byte[] next = new byte[ capacity ]; + this.buffer.AsSpan( 0, this.count ).CopyTo( next ); + Array.Clear( this.buffer ); + this.buffer = next; + } + this.buffer[ this.count++ ] = value; + return status; + } + + internal TerminalResponseFrame ToFrame() { + if ( this.scanner.Status != TerminalResponseFrameParseStatus.Complete ) + throw new InvalidOperationException( "Clipboard response is incomplete." ); + return new TerminalResponseFrame( TerminalResponseFrameKind.Osc, this.buffer.AsSpan( 0, this.count ) ); + } + + public void Dispose() { + Array.Clear( this.buffer ); + this.buffer = []; + this.count = 0; + } +} diff --git a/src/Input/TerminalControlSequenceScanner.cs b/src/Input/TerminalControlSequenceScanner.cs index 69e12c318..ccf5c21c3 100644 --- a/src/Input/TerminalControlSequenceScanner.cs +++ b/src/Input/TerminalControlSequenceScanner.cs @@ -63,10 +63,14 @@ internal sealed class TerminalControlSequenceScanner { private int length; internal TerminalControlSequenceScanner( - int maximumFrameBytes + int maximumFrameBytes, + bool clipboardCapacity = false ) { + int hardMaximum = clipboardCapacity + ? TerminalClipboardLimits.GetResponseFrameLimit( TerminalClipboardOptions.SupportedMaximumBytes ) + : TerminalResponseFramer.HardMaximumFrameBytes; if ( 4 > maximumFrameBytes - || TerminalResponseFramer.HardMaximumFrameBytes < maximumFrameBytes ) { + || hardMaximum < maximumFrameBytes ) { throw new ArgumentOutOfRangeException( nameof( maximumFrameBytes ) ); } diff --git a/src/Input/TerminalInputDecoder.Clipboard.cs b/src/Input/TerminalInputDecoder.Clipboard.cs new file mode 100644 index 000000000..e7f90ffb2 --- /dev/null +++ b/src/Input/TerminalInputDecoder.Clipboard.cs @@ -0,0 +1,122 @@ +/* + Icod.Terminal + Managed, cross-platform live-terminal session and terminal-control library for .NET. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Lesser General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Lesser General Public License for more details. + + You should have received a copy of the GNU Lesser General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal; + +internal sealed partial class TerminalInputDecoder { + private async ValueTask ReadLargeClipboardResponseAsync( + TerminalResponseExpectation expectation, CancellationToken cancellationToken + ) { + using TerminalClipboardResponseAccumulator accumulator = new( + expectation.ResponsePlan.MaximumClipboardBytes ); + int frameLimit = TerminalClipboardLimits.GetResponseFrameLimit( + expectation.ResponsePlan.MaximumClipboardBytes ); + byte previous = 0; + try { + while ( true ) { + int consumed = 0; + TerminalResponseFrameParseStatus status = TerminalResponseFrameParseStatus.Incomplete; + byte last = 0; + bool terminated = false; + while ( consumed < this.bufferedBytes.Count ) { + last = this.bufferedBytes[ consumed++ ]; + terminated = last == 0x07 || last == 0x9C || ( previous == 0x1B && last == (byte)'\\' ); + previous = last; + status = accumulator.Feed( last ); + if ( status != TerminalResponseFrameParseStatus.Incomplete ) break; + } + this.Consume( consumed ); + if ( status == TerminalResponseFrameParseStatus.Complete ) { + TerminalResponseFrame frame = accumulator.ToFrame(); + this.DetachClipboardExpectation( expectation ); + if ( !expectation.ResponsePlan.TryMatch( frame, out var disposition ) ) + return TerminalInputDecodeResult.RoutedFailure( expectation, + new FormatException( "The correlated clipboard response is malformed." ) ); + return TerminalInputDecodeResult.RoutedResponse( expectation, frame, disposition ); + } + if ( status != TerminalResponseFrameParseStatus.Incomplete ) { + accumulator.Dispose(); + if ( !terminated ) { + await this.DrainLargeClipboardResponseAsync( + expectation, frameLimit, last, cancellationToken ).ConfigureAwait( false ); + } + this.DetachClipboardExpectation( expectation ); + return TerminalInputDecodeResult.RoutedFailure( expectation, + new FormatException( "The correlated clipboard response is malformed or exceeds its frame budget." ) ); + } + if ( !await this.ReadClipboardContinuationAsync( expectation, cancellationToken ).ConfigureAwait( false ) ) { + this.DetachClipboardExpectation( expectation ); + return TerminalInputDecodeResult.RoutedFailure( expectation, + new FormatException( "Terminal input ended within a clipboard response." ) ); + } + } + } catch ( Exception exception ) { + // An unresolved correlated string must never resume as ordinary keyboard input. + expectation.TrySetException( exception ); + lock ( this.responseExpectationGate ) { + this.responseExpectation?.TrySetException( exception ); + this.responseExpectation = null; + } + throw; + } + } + + private void DetachClipboardExpectation( TerminalResponseExpectation expectation ) { + lock ( this.responseExpectationGate ) { + if ( ReferenceEquals( this.responseExpectation, expectation ) ) this.responseExpectation = null; + } + } + + private async ValueTask ReadClipboardContinuationAsync( + TerminalResponseExpectation expectation, CancellationToken cancellationToken + ) { + if ( expectation.OwnershipEnded.IsCompleted ) + throw new InvalidOperationException( "Clipboard response ownership expired before its terminator." ); + if ( this.endOfInput ) return false; + // The pending transport read remains owned by the session, not the query caller. + Task read = this.EnsurePendingRead( cancellationToken ); + Task ready = await Task.WhenAny( read, expectation.OwnershipEnded ).ConfigureAwait( false ); + if ( ready == expectation.OwnershipEnded ) + throw new InvalidOperationException( "Clipboard response ownership expired before its terminator." ); + return 0 < await this.CompletePendingReadAsync( read ).ConfigureAwait( false ); + } + + private async ValueTask DrainLargeClipboardResponseAsync( + TerminalResponseExpectation expectation, int maximumDiscardBytes, byte previous, + CancellationToken cancellationToken + ) { + int discarded = 0; + while ( true ) { + int consumed = 0; + bool terminated = false; + while ( consumed < this.bufferedBytes.Count ) { + byte value = this.bufferedBytes[ consumed++ ]; + if ( ++discarded > maximumDiscardBytes ) + throw new InvalidOperationException( "Clipboard response exceeded its bounded recovery allowance." ); + terminated = value == 0x07 || value == 0x9C || ( previous == 0x1B && value == (byte)'\\' ); + previous = value; + if ( terminated ) break; + } + if ( consumed > 0 ) this.Consume( consumed ); + if ( terminated ) return; + if ( !await this.ReadClipboardContinuationAsync( expectation, cancellationToken ).ConfigureAwait( false ) ) return; + } + } +} diff --git a/src/Input/TerminalInputDecoder.Responses.cs b/src/Input/TerminalInputDecoder.Responses.cs index 118111ce0..03acc77b0 100644 --- a/src/Input/TerminalInputDecoder.Responses.cs +++ b/src/Input/TerminalInputDecoder.Responses.cs @@ -101,6 +101,7 @@ TerminalResponseExpectation expectation } expectation.TrySetCanceled(); + expectation.EndOwnership(); return true; } @@ -135,6 +136,11 @@ CancellationToken cancellationToken continue; } + if ( expectation.ResponsePlan.MaximumClipboardBytes > TerminalClipboardOptions.DefaultMaximumBytes + && expectation.ResponsePlan.IsCorrelatedPrefix( TerminalControlFamily.Osc, this.bufferedBytes ) ) { + return await this.ReadLargeClipboardResponseAsync( expectation, cancellationToken ).ConfigureAwait( false ); + } + TerminalResponseFrameParseResult parseResult = this.ParseExpectedResponse( expectation, out TerminalControlFamily? family, diff --git a/src/Input/TerminalResponseRouting.cs b/src/Input/TerminalResponseRouting.cs index 4bad3ddf6..557098935 100644 --- a/src/Input/TerminalResponseRouting.cs +++ b/src/Input/TerminalResponseRouting.cs @@ -117,6 +117,12 @@ internal sealed class TerminalResponseFrame { internal TerminalResponseFrame( TerminalResponseFrameKind kind, byte[] bytes + ) : this( kind, ( bytes ?? throw new ArgumentNullException( nameof( bytes ) ) ).AsSpan() ) { + } + + internal TerminalResponseFrame( + TerminalResponseFrameKind kind, + ReadOnlySpan bytes ) { if ( !Enum.IsDefined( kind ) ) { throw new ArgumentOutOfRangeException( @@ -125,7 +131,6 @@ byte[] bytes "The terminal response frame kind is not recognized." ); } - ArgumentNullException.ThrowIfNull( bytes ); if ( 0 == bytes.Length ) { throw new ArgumentException( "A terminal response frame cannot be empty.", @@ -168,6 +173,11 @@ internal bool UsesEightBitIntroducer { /// Owns one internal response expectation without exposing routing state publicly. /// internal sealed class TerminalResponseExpectation { + private readonly TaskCompletionSource ownershipEnded = new( + TaskCreationOptions.RunContinuationsAsynchronously ); + internal Task OwnershipEnded => this.ownershipEnded.Task; + internal void EndOwnership() => this.ownershipEnded.TrySetResult(); + private readonly TaskCompletionSource completion = new( TaskCreationOptions.RunContinuationsAsynchronously ); diff --git a/src/Output/OscWriter.Osc52.cs b/src/Output/OscWriter.Osc52.cs index 1b75c00b3..1baff8e38 100644 --- a/src/Output/OscWriter.Osc52.cs +++ b/src/Output/OscWriter.Osc52.cs @@ -20,7 +20,8 @@ You should have received a copy of the GNU Lesser General Public License */ namespace Icod.Terminal; -using System.Text; +using System.Buffers; +using System.Buffers.Text; internal static partial class OscWriter { private static readonly byte[] Osc52Prefix = [ @@ -36,21 +37,23 @@ internal static partial class OscWriter { /// internal static byte[] EncodeOsc52WriteFrame( TerminalOsc52Selection selection, - ReadOnlySpan payload + ReadOnlySpan payload, + int maximumBytes = TerminalOsc52PayloadCodec.MaximumDecodedPayloadBytes ) { byte selectionByte = TerminalOsc52SelectionEncoder.Encode( selection ); - int frameLength = TerminalOsc52PayloadCodec.GetWriteFrameLength( payload.Length ); - string encodedPayload = TerminalOsc52PayloadCodec.Encode( payload ); + TerminalClipboardOptions.ValidateLimit( maximumBytes, nameof( maximumBytes ) ); + if ( payload.Length > maximumBytes ) throw new ArgumentOutOfRangeException( nameof( payload ) ); + int encodedLength = TerminalClipboardLimits.GetEncodedLength( payload.Length ); + int frameLength = checked( encodedLength + 9 ); byte[] frame = new byte[ frameLength ]; Osc52Prefix.CopyTo( frame, 0 ); frame[ 5 ] = selectionByte; frame[ 6 ] = (byte)';'; - if ( 0 < encodedPayload.Length ) { - Encoding.ASCII.GetBytes( - encodedPayload.AsSpan(), - frame.AsSpan( 7, encodedPayload.Length ) - ); + if ( Base64.EncodeToUtf8( payload, frame.AsSpan( 7, encodedLength ), + out int consumed, out int written ) != OperationStatus.Done + || consumed != payload.Length || written != encodedLength ) { + throw new InvalidOperationException( "Clipboard encoding produced an invalid length." ); } frame[ ^2 ] = 0x1b; frame[ ^1 ] = (byte)'\\'; @@ -91,7 +94,8 @@ internal static ValueTask WriteOsc52Async( ITerminalOutput output, TerminalOsc52Selection selection, ReadOnlyMemory payload, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + int maximumBytes = TerminalOsc52PayloadCodec.MaximumDecodedPayloadBytes ) { ArgumentNullException.ThrowIfNull( output ); _ = TerminalOsc52SelectionEncoder.Encode( selection ); @@ -99,7 +103,8 @@ internal static ValueTask WriteOsc52Async( byte[] frame = EncodeOsc52WriteFrame( selection, - payload.Span + payload.Span, + maximumBytes ); cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/Output/TerminalOsc52PayloadCodec.cs b/src/Output/TerminalOsc52PayloadCodec.cs index 95d02a4e4..1442518b4 100644 --- a/src/Output/TerminalOsc52PayloadCodec.cs +++ b/src/Output/TerminalOsc52PayloadCodec.cs @@ -114,9 +114,11 @@ ReadOnlySpan payload } internal static int GetDecodedLength( - ReadOnlySpan encodedPayload + ReadOnlySpan encodedPayload, + int maximumBytes = MaximumDecodedPayloadBytes ) { - ValidateEncodedPayloadLength( encodedPayload.Length ); + TerminalClipboardOptions.ValidateLimit( maximumBytes, nameof( maximumBytes ) ); + ValidateEncodedPayloadLength( encodedPayload.Length, maximumBytes ); if ( encodedPayload.IsEmpty ) { return 0; } @@ -131,11 +133,11 @@ ReadOnlySpan encodedPayload int decodedLength = checked( ( encodedPayload.Length / 4 ) * 3 - padding ); - if ( MaximumDecodedPayloadBytes < decodedLength ) { + if ( maximumBytes < decodedLength ) { throw new ArgumentOutOfRangeException( nameof( encodedPayload ), encodedPayload.Length, - $"An OSC 52 decoded payload cannot exceed {MaximumDecodedPayloadBytes} bytes." + $"An OSC 52 decoded payload cannot exceed {maximumBytes} bytes." ); } @@ -143,9 +145,10 @@ ReadOnlySpan encodedPayload } internal static byte[] Decode( - ReadOnlySpan encodedPayload + ReadOnlySpan encodedPayload, + int maximumBytes = MaximumDecodedPayloadBytes ) { - int decodedLength = GetDecodedLength( encodedPayload ); + int decodedLength = GetDecodedLength( encodedPayload, maximumBytes ); if ( 0 == decodedLength ) { return []; } @@ -190,10 +193,11 @@ ReadOnlySpan encodedPayload } private static void ValidateEncodedPayloadLength( - int encodedByteCount + int encodedByteCount, + int maximumBytes ) { if ( 0 > encodedByteCount - || MaximumEncodedPayloadBytes < encodedByteCount ) { + || TerminalClipboardLimits.GetEncodedLength( maximumBytes ) < encodedByteCount ) { throw new ArgumentOutOfRangeException( nameof( encodedByteCount ) ); } if ( 0 != encodedByteCount % 4 ) { @@ -254,7 +258,7 @@ byte value int decoded = DecodeSextet( value ); if ( 0 > decoded ) { throw new FormatException( - $"OSC 52 payload contains invalid base64 byte 0x{value:X2}." + "OSC 52 payload contains an invalid base64 byte." ); } diff --git a/src/Query/TerminalOsc52Protocol.cs b/src/Query/TerminalOsc52Protocol.cs index 499da96bd..637832130 100644 --- a/src/Query/TerminalOsc52Protocol.cs +++ b/src/Query/TerminalOsc52Protocol.cs @@ -32,7 +32,8 @@ TerminalOsc52Selection selection internal static byte[] ParsePayload( TerminalResponseFrame frame, - TerminalOsc52Selection selection + TerminalOsc52Selection selection, + int maximumBytes = TerminalOsc52PayloadCodec.MaximumDecodedPayloadBytes ) { ArgumentNullException.ThrowIfNull( frame ); if ( TerminalResponseFrameKind.Osc != frame.Kind ) { @@ -51,7 +52,7 @@ out ReadOnlySpan payload } try { - return TerminalOsc52PayloadCodec.Decode( payload ); + return TerminalOsc52PayloadCodec.Decode( payload, maximumBytes ); } catch ( ArgumentOutOfRangeException exception ) { throw new FormatException( "The correlated OSC 52 response payload exceeds the supported bound.", diff --git a/src/Query/TerminalQueryResponsePlan.cs b/src/Query/TerminalQueryResponsePlan.cs index a0b39ae2c..40838874f 100644 --- a/src/Query/TerminalQueryResponsePlan.cs +++ b/src/Query/TerminalQueryResponsePlan.cs @@ -82,6 +82,21 @@ internal TerminalControlFamily Family { /// Defines the bounded set of response families which may complete one logical terminal query. /// internal sealed class TerminalQueryResponsePlan { + // A separate accumulator is enabled only by explicit larger clipboard reads. + internal int MaximumClipboardBytes { get; private init; } + + internal static TerminalQueryResponsePlan ForClipboard( + ITerminalResponseMatcher matcher, int maximumBytes + ) { + TerminalClipboardOptions.ValidateLimit( maximumBytes, nameof( maximumBytes ) ); + return new TerminalQueryResponsePlan( new TerminalQueryResponseRule( + matcher, TerminalQueryResponseDisposition.Completion, + Math.Min( TerminalClipboardLimits.GetResponseFrameLimit( maximumBytes ), + TerminalOsc52PayloadCodec.MaximumFrameBytes ) ) ) { + MaximumClipboardBytes = maximumBytes + }; + } + internal const int MaximumRules = 6; private readonly TerminalQueryResponseRule[] rules; diff --git a/src/Session/TerminalSession.Clipboard.cs b/src/Session/TerminalSession.Clipboard.cs index b59810fde..9750447e1 100644 --- a/src/Session/TerminalSession.Clipboard.cs +++ b/src/Session/TerminalSession.Clipboard.cs @@ -45,7 +45,7 @@ public sealed partial class TerminalSession { /// accepted, retained, or exposed the selection content. /// /// - /// The payload is limited to 65,536 decoded bytes. An empty payload explicitly + /// The payload uses the session write budget (65,536 bytes by default). An empty payload explicitly /// replaces the selected terminal selection with empty content. /// /// @@ -60,11 +60,12 @@ public async ValueTask WriteClipboardAsync( CancellationToken cancellationToken = default ) { TerminalOsc52Selection protocolSelection = ToOsc52Selection( selection ); - if ( TerminalOsc52PayloadCodec.MaximumDecodedPayloadBytes < payload.Length ) { + int maximumBytes = this.Options.ClipboardOptions.MaximumWriteBytes; + if ( maximumBytes < payload.Length ) { throw new ArgumentOutOfRangeException( nameof( payload ), payload.Length, - $"A terminal clipboard payload cannot exceed {TerminalOsc52PayloadCodec.MaximumDecodedPayloadBytes} bytes." + $"A terminal clipboard payload cannot exceed {maximumBytes} bytes." ); } cancellationToken.ThrowIfCancellationRequested(); @@ -82,7 +83,8 @@ await OscWriter.WriteOsc52Async( this.Output, protocolSelection, payload, - cancellationToken + cancellationToken, + maximumBytes ).ConfigureAwait( false ); } @@ -117,9 +119,10 @@ public ValueTask WriteClipboardAsync( exception ); } - if ( TerminalOsc52PayloadCodec.MaximumDecodedPayloadBytes < payloadLength ) { + int maximumBytes = this.Options.ClipboardOptions.MaximumWriteBytes; + if ( maximumBytes < payloadLength ) { throw new ArgumentException( - $"Terminal clipboard text may not exceed {TerminalOsc52PayloadCodec.MaximumDecodedPayloadBytes} encoded UTF-8 bytes.", + $"Terminal clipboard text may not exceed {maximumBytes} encoded UTF-8 bytes.", nameof( value ) ); } diff --git a/src/Session/TerminalSession.ClipboardQueries.cs b/src/Session/TerminalSession.ClipboardQueries.cs index 15305c560..1291631ae 100644 --- a/src/Session/TerminalSession.ClipboardQueries.cs +++ b/src/Session/TerminalSession.ClipboardQueries.cs @@ -47,10 +47,17 @@ public sealed partial class TerminalSession { /// /// After an emitted request times out or the caller cancels, the transaction retains /// bounded late-response ownership so a delayed reply cannot satisfy a later query or - /// leak into ordinary input. A structurally correlated OSC 52 response which reaches - /// the complete-frame ceiling without terminating fails deterministically with - /// and is drained through its OSC terminator before - /// ordinary input decoding resumes. + /// leak into ordinary input. Larger correlated replies use a separate bounded + /// accumulator. Malformed or oversized replies are drained through their terminator + /// before reporting . If bounded recovery or late + /// ownership expires before termination, the input coordinator fails closed. + /// A caller deadline may expire first. No partial payload is returned. + /// + /// + /// The decoded-byte limit is . + /// Ordinary decoder settings still govern prefix recognition; a seven-byte buffer + /// accommodates both supported OSC 52 introducers. Budgets at or below 64 KiB retain + /// the existing ordinary-buffer framing behavior. /// /// /// A successful result means a syntactically correlated OSC 52 response was @@ -73,16 +80,19 @@ public async ValueTask ReadClipboardAsync( cancellationToken.ThrowIfCancellationRequested(); byte[] request = OscWriter.EncodeOsc52QueryFrame( protocolSelection ); - TerminalResponseFrame frame = await this.ExecuteQueryAsync( + int maximumBytes = this.Options.ClipboardOptions.MaximumReadBytes; + TerminalQueryResponseResult result = await this.ExecuteQueryTransactionAsync( request, - TerminalOsc52Protocol.CreateResponseMatcher( protocolSelection ), + TerminalQueryResponsePlan.ForClipboard( + TerminalOsc52Protocol.CreateResponseMatcher( protocolSelection ), maximumBytes ), timeout, cancellationToken ).ConfigureAwait( false ); return TerminalOsc52Protocol.ParsePayload( - frame, - protocolSelection + result.Frame, + protocolSelection, + maximumBytes ); } diff --git a/src/Session/TerminalSession.Input.cs b/src/Session/TerminalSession.Input.cs index e156d66b3..d67ad448c 100644 --- a/src/Session/TerminalSession.Input.cs +++ b/src/Session/TerminalSession.Input.cs @@ -43,11 +43,11 @@ public static TimeSpan DefaultEscapeSequenceTimeout { } /// - /// Gets the maximum number of undecoded bytes retained by the input decoder. + /// Gets the maximum number of ordinary undecoded bytes retained by the input decoder. /// /// - /// The ceiling is 87,400 bytes, matching the independently enforced maximum - /// complete OSC 52 frame size. + /// The ceiling remains 87,400 bytes. An explicitly larger clipboard read budget + /// uses separate bounded storage only after a correlated OSC 52 prefix is recognized. /// public static int MaximumBufferedInputBytes { get { diff --git a/src/Session/TerminalSessionOptions.cs b/src/Session/TerminalSessionOptions.cs index b21e75e90..296eda6ae 100644 --- a/src/Session/TerminalSessionOptions.cs +++ b/src/Session/TerminalSessionOptions.cs @@ -29,6 +29,10 @@ namespace Icod.Terminal; /// and application text policy applied when opening a . /// public sealed class TerminalSessionOptions { + /// Gets or initializes immutable local clipboard read/write budgets. + /// Defaults remain 64 KiB. Larger budgets do not establish remote support. + public TerminalClipboardOptions ClipboardOptions { get; init; } = new(); + /// /// Gets or initializes the semantic input discipline entered by the session. /// @@ -184,6 +188,8 @@ internal ITerminalLifecycleSource? LifecycleSource { } internal void Validate() { + ArgumentNullException.ThrowIfNull( this.ClipboardOptions ); + this.ClipboardOptions.Validate(); if ( !Enum.IsDefined( this.InputMode ) ) { throw new ArgumentOutOfRangeException( nameof( this.InputMode ), diff --git a/tests/Icod.Terminal.Tests/Icod.Terminal.Tests.csproj b/tests/Icod.Terminal.Tests/Icod.Terminal.Tests.csproj index 24eaa5a4d..b486f2c62 100644 --- a/tests/Icod.Terminal.Tests/Icod.Terminal.Tests.csproj +++ b/tests/Icod.Terminal.Tests/Icod.Terminal.Tests.csproj @@ -38,6 +38,7 @@ + diff --git a/tests/Icod.Terminal.Tests/src/Input/TerminalClipboardAllocationTests.cs b/tests/Icod.Terminal.Tests/src/Input/TerminalClipboardAllocationTests.cs new file mode 100644 index 000000000..6273a456f --- /dev/null +++ b/tests/Icod.Terminal.Tests/src/Input/TerminalClipboardAllocationTests.cs @@ -0,0 +1,107 @@ +/* + Icod.Terminal.Tests + Automated test suite for the Icod.Terminal library. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +using System.Text; + +namespace Icod.Terminal.Tests.Input; +using System.Diagnostics; +using System.Text; +using Xunit; +using Xunit.Abstractions; + +public sealed class TerminalClipboardAllocationTests( ITestOutputHelper output ) { + [Theory] + [InlineData( 0, 0 )] + [InlineData( 1, 4 )] + [InlineData( 2, 4 )] + [InlineData( 3, 4 )] + [InlineData( 65535, 87380 )] + [InlineData( 65536, 87384 )] + [InlineData( 65537, 87384 )] + [InlineData( 1048576, 1398104 )] + [InlineData( 16777216, 22369624 )] + public void GeometryIsExact( int decoded, int encoded ) { + Assert.Equal( encoded, TerminalClipboardLimits.GetEncodedLength( decoded ) ); + if ( decoded > 0 ) Assert.Equal( encoded + 16, TerminalClipboardLimits.GetResponseFrameLimit( decoded ) ); + } + + [Theory] + [InlineData( -1 )] + [InlineData( 16777217 )] + [InlineData( int.MaxValue )] + public void UnsupportedGeometryIsRejectedBeforeOverflow( int decoded ) { + Assert.Throws( () => TerminalClipboardLimits.GetEncodedLength( decoded ) ); + } + + [Fact] + public void IdleOptionsAndAccumulatorDoNotReserveTheCeiling() { + using ( var warmup = new TerminalClipboardResponseAccumulator( 16777216 ) ) { } + long before = GC.GetAllocatedBytesForCurrentThread(); + var options = new TerminalClipboardOptions { MaximumReadBytes = 16777216 }; + using var accumulator = new TerminalClipboardResponseAccumulator( options.MaximumReadBytes ); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.InRange( allocated, 1, 4096 ); + } + + [Theory] + [InlineData( 65536 )] + [InlineData( 1048576 )] + [InlineData( 16777216 )] + public void EncodingAndIncrementalReadHaveBoundedAllocations( int size ) { + byte[] payload = new byte[ size ]; + byte[] input = Encoding.ASCII.GetBytes( "\u001b]52;c;" + Convert.ToBase64String( payload ) + "\u001b\\" ); + _ = OscWriter.EncodeOsc52WriteFrame( TerminalOsc52Selection.Clipboard, [], size ); + var clock = Stopwatch.StartNew(); + long before = GC.GetAllocatedBytesForCurrentThread(); + byte[] frame = OscWriter.EncodeOsc52WriteFrame( TerminalOsc52Selection.Clipboard, payload, size ); + long writeAllocated = GC.GetAllocatedBytesForCurrentThread() - before; + double writeMs = clock.Elapsed.TotalMilliseconds; + Assert.Equal( input, frame ); + // Fixed slack accommodates CLR allocation accounting/JIT bookkeeping across TFMs. + // It still rejects a second payload-sized array or an intermediate base64 string. + Assert.InRange( writeAllocated, frame.Length, frame.Length + 32768L ); + + clock.Restart(); + before = GC.GetAllocatedBytesForCurrentThread(); + byte[] decoded; + using ( var accumulator = new TerminalClipboardResponseAccumulator( size ) ) { + // One Feed call per byte, regardless of transport fragment sizes. + foreach ( byte value in input ) accumulator.Feed( value ); + TerminalResponseFrame response = accumulator.ToFrame(); + decoded = TerminalOsc52Protocol.ParsePayload( response, TerminalOsc52Selection.Clipboard, size ); + } + long readAllocated = GC.GetAllocatedBytesForCurrentThread() - before; + double readMs = clock.Elapsed.TotalMilliseconds; + Assert.Equal( payload, decoded ); + int limit = TerminalClipboardLimits.GetResponseFrameLimit( size ); + // Geometric growth sum < 3F; one exact frame copy F; one decoded result N. + Assert.InRange( readAllocated, size, 4L * limit + size + 8192 ); + output.WriteLine( $"Bytes={size}; write allocated={writeAllocated}, ms={writeMs:F3}; incremental read allocated={readAllocated}, ms={readMs:F3}; live response storage upper bound={2L * limit + size}" ); + } + + [Fact] + public void RejectedWriteDoesNotAllocateAFrame() { + byte[] payload = new byte[ 1_048_576 ]; + Assert.Throws( () => OscWriter.EncodeOsc52WriteFrame( TerminalOsc52Selection.Clipboard, payload ) ); + long before = GC.GetAllocatedBytesForCurrentThread(); + Assert.Throws( () => OscWriter.EncodeOsc52WriteFrame( TerminalOsc52Selection.Clipboard, payload ) ); + Assert.InRange( GC.GetAllocatedBytesForCurrentThread() - before, 0, 8192 ); + } +} diff --git a/tests/Icod.Terminal.Tests/src/Input/TerminalOsc52QueryTests.cs b/tests/Icod.Terminal.Tests/src/Input/TerminalOsc52QueryTests.cs index 600b4213c..7558c68a9 100644 --- a/tests/Icod.Terminal.Tests/src/Input/TerminalOsc52QueryTests.cs +++ b/tests/Icod.Terminal.Tests/src/Input/TerminalOsc52QueryTests.cs @@ -30,6 +30,215 @@ namespace Icod.Terminal.Tests.Input; /// Verifies the T57 explicit OSC 52 clipboard-read API on the shared query substrate. /// public sealed class TerminalOsc52QueryTests { + [Fact] + public async Task ConcurrentSessionsKeepReadBudgetsIndependent() { + Osc52Transport smallTransport = new(); + Osc52Transport largeTransport = new(); + await using TerminalSession small = await OpenSessionAsync( smallTransport ); + await using TerminalSession large = await OpenSessionAsync( largeTransport, options: LargeReadOptions() ); + Task smallRead = small.ReadClipboardAsync( TerminalClipboardSelection.Clipboard, TimeSpan.FromSeconds( 30 ) ).AsTask(); + Task largeRead = large.ReadClipboardAsync( TerminalClipboardSelection.Clipboard, TimeSpan.FromSeconds( 30 ) ).AsTask(); + await Task.WhenAll( WaitForWriteCountAsync( smallTransport, 1 ), WaitForWriteCountAsync( largeTransport, 1 ) ); + byte[] payload = new byte[ 100_000 ]; + byte[] frame = Encoding.ASCII.GetBytes( "\u001b]52;c;" + Convert.ToBase64String( payload ) + "\u001b\\" ); + smallTransport.Publish( frame ); + await Assert.ThrowsAsync( () => smallRead ); + largeTransport.Publish( frame ); + Assert.Equal( payload, await largeRead ); + } + + [Theory] + [InlineData( "\u001b]52;c;", "\u0007" )] + [InlineData( "\u001b]52;c;", "\u001b\\" )] + [InlineData( "\u009d52;c;", "\u009c" )] + public async Task OptInOverflowOnTerminatorPreservesNextKey( string prefix, string terminator ) { + Osc52Transport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport, options: LargeReadOptions() ); + Task query = session.ReadClipboardAsync( + TerminalClipboardSelection.Clipboard, TimeSpan.FromSeconds( 2 ) ).AsTask(); + await WaitForWriteCountAsync( transport, 1 ); + int limit = TerminalClipboardLimits.GetResponseFrameLimit( 131_072 ); + transport.Publish( Encoding.Latin1.GetBytes( prefix + + new string( 'A', limit + 1 - prefix.Length - terminator.Length ) + terminator + "z" ) ); + await Assert.ThrowsAsync( () => query ); + Assert.Equal( new Rune( 'z' ), ( await session.ReadEventAsync( TimeSpan.FromSeconds( 1 ) ) ).Input?.Character ); + } + + [Fact] + public async Task OptInRecoveryExhaustionFailsClosed() { + Osc52Transport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport, options: LargeReadOptions() ); + Task query = session.ReadClipboardAsync( + TerminalClipboardSelection.Clipboard, TimeSpan.FromSeconds( 5 ) ).AsTask(); + await WaitForWriteCountAsync( transport, 1 ); + transport.Publish( Encoding.ASCII.GetBytes( "\u001b]52;c;" + new string( 'A', 360_000 ) ) ); + await Assert.ThrowsAsync( () => query ); + await Assert.ThrowsAnyAsync( async () => await session.ReadEventAsync( TimeSpan.FromSeconds( 1 ) ) ); + } + + [Fact] + public async Task OptInReadWithSmallOrdinaryBufferIgnoresWrongSelection() { + Osc52Transport transport = new() { MaximumReadChunk = 1 }; + await using TerminalSession session = await OpenSessionAsync( transport, options: new() { + TerminalOverride = TerminalProfiles.Dumb, + InputDecoderOptions = new() { MaximumBufferedBytes = 32 }, + ClipboardOptions = new() { MaximumReadBytes = 131_072 } + } ); + Task query = session.ReadClipboardAsync( + TerminalClipboardSelection.Clipboard, TimeSpan.FromSeconds( 5 ) ).AsTask(); + await WaitForWriteCountAsync( transport, 1 ); + byte[] payload = new byte[ 100_000 ]; + transport.Publish( Encoding.ASCII.GetBytes( "\u001b]52;p;eA==\u001b\\" + + "\u001b]52;c;" + Convert.ToBase64String( payload ) + "\u001b\\z" ) ); + Assert.Equal( payload, await query ); + // Wrong-selection frames retain ordinary decoding; they gain no clipboard allowance. + TerminalEvent trailing; + int events = 0; + do { + trailing = await session.ReadEventAsync( TimeSpan.FromSeconds( 1 ) ); + Assert.Equal( TerminalEventKind.Input, trailing.Kind ); + Assert.True( ++events <= 17 ); + } while ( trailing.Input?.Character != new Rune( 'z' ) ); + Assert.Equal( 32, session.Options.InputDecoderOptions.MaximumBufferedBytes ); + } + + private static TerminalSessionOptions LargeReadOptions() => new() { + TerminalOverride = TerminalProfiles.Dumb, + ClipboardOptions = new() { MaximumReadBytes = 131_072 } + }; + + [Theory] + [InlineData( "\u001b]52;c;Zh==\u001b\\" )] + [InlineData( "\u001b]52;c;SGVsbG8_\u0007" )] + [InlineData( "\u001b]52;c;\u001b\u0007" )] + [InlineData( "\u001b]52;c;\u001bXAAAA\u0007" )] + public async Task OptInMalformedReadIsRejectedWithoutPayloadLeak( string response ) { + Osc52Transport transport = new() { MaximumReadChunk = 1 }; + await using TerminalSession session = await OpenSessionAsync( transport, options: LargeReadOptions() ); + Task query = session.ReadClipboardAsync( + TerminalClipboardSelection.Clipboard, TimeSpan.FromSeconds( 5 ) ).AsTask(); + await WaitForWriteCountAsync( transport, 1 ); + transport.Publish( Encoding.ASCII.GetBytes( response + "z" ) ); + FormatException failure = await Assert.ThrowsAsync( () => query ); + Assert.DoesNotContain( response, failure.Message ); + Assert.Equal( new Rune( 'z' ), ( await session.ReadEventAsync( TimeSpan.FromSeconds( 1 ) ) ).Input?.Character ); + } + + [Theory] + [InlineData( "\u001b]52;c;", "\u001b\\" )] + [InlineData( "\u001b]52;c;", "\u0007" )] + [InlineData( "\u009d52;c;", "\u009c" )] + public async Task OptInFrameOverflowDrainsOnlyThroughTerminator( string prefix, string terminator ) { + Osc52Transport transport = new() { MaximumReadChunk = 127 }; + await using TerminalSession session = await OpenSessionAsync( transport, options: LargeReadOptions() ); + Task query = session.ReadClipboardAsync( + TerminalClipboardSelection.Clipboard, TimeSpan.FromSeconds( 5 ) ).AsTask(); + await WaitForWriteCountAsync( transport, 1 ); + transport.Publish( Encoding.Latin1.GetBytes( + prefix + new string( 'A', 180_000 ) + terminator + "z" ) ); + await Assert.ThrowsAsync( () => query ); + Assert.Equal( new Rune( 'z' ), ( await session.ReadEventAsync( TimeSpan.FromSeconds( 1 ) ) ).Input?.Character ); + } + + [Fact] + public async Task OptInReadCancellationRetainsLateResponseOwnership() { + Osc52Transport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport, options: LargeReadOptions() ); + using CancellationTokenSource stop = new(); + Task first = session.ReadClipboardAsync( + TerminalClipboardSelection.Clipboard, TimeSpan.FromSeconds( 5 ), stop.Token ).AsTask(); + await WaitForWriteCountAsync( transport, 1 ); + transport.Publish( Encoding.ASCII.GetBytes( "\u001b]52;c;b2xk" ) ); + stop.Cancel(); + await Assert.ThrowsAnyAsync( () => first ); + Task second = session.ReadClipboardAsync( + TerminalClipboardSelection.Clipboard, TimeSpan.FromSeconds( 5 ) ).AsTask(); + transport.Publish( Encoding.ASCII.GetBytes( "\u001b\\" ) ); + await WaitForWriteCountAsync( transport, 2 ); + transport.Publish( Encoding.ASCII.GetBytes( "\u001b]52;c;bmV3\u001b\\" ) ); + Assert.Equal( Encoding.ASCII.GetBytes( "new" ), await second ); + } + + [Fact] + public async Task OptInReadEofFailsRatherThanReturningPartialData() { + Osc52Transport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport, options: LargeReadOptions() ); + Task query = session.ReadClipboardAsync( + TerminalClipboardSelection.Clipboard, TimeSpan.FromSeconds( 5 ) ).AsTask(); + await WaitForWriteCountAsync( transport, 1 ); + transport.Publish( Encoding.ASCII.GetBytes( "\u001b]52;c;YWJj" ) ); + transport.Publish( [] ); + await Assert.ThrowsAsync( () => query ); + Assert.Equal( TerminalInputEventKind.EndOfInput, + ( await session.ReadEventAsync( TimeSpan.FromSeconds( 1 ) ) ).Input?.Kind ); + } + + [Fact] + public async Task OptInReadDisposalInterruptsUnterminatedResponse() { + Osc52Transport transport = new(); + TerminalSession session = await OpenSessionAsync( transport, options: LargeReadOptions() ); + Task query = session.ReadClipboardAsync( + TerminalClipboardSelection.Clipboard, TimeSpan.FromSeconds( 30 ) ).AsTask(); + await WaitForWriteCountAsync( transport, 1 ); + transport.Publish( Encoding.ASCII.GetBytes( "\u001b]52;c;" + new string( 'A', 90_000 ) ) ); + await session.DisposeAsync().AsTask().WaitAsync( TimeSpan.FromSeconds( 3 ) ); + await Assert.ThrowsAsync( () => query ); + } + + [Fact] + public async Task OptInReadTimeoutDoesNotReturnAnUnterminatedFrameAsKeys() { + Osc52Transport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport, options: LargeReadOptions() ); + Task query = session.ReadClipboardAsync( + TerminalClipboardSelection.Clipboard, TimeSpan.FromMilliseconds( 200 ) ).AsTask(); + await WaitForWriteCountAsync( transport, 1 ); + transport.Publish( Encoding.ASCII.GetBytes( "\u001b]52;c;YWJj" ) ); + await Assert.ThrowsAsync( () => query ); + await Assert.ThrowsAnyAsync( async () => + await session.ReadEventAsync( TimeSpan.FromSeconds( 3 ) ) ); + } + + [Theory] + [InlineData( 65_537, 256 )] + [InlineData( 1_048_576, 256 )] + [InlineData( 16_777_216, 256 )] + [InlineData( 65_537, 1 )] + public async Task OptInReadBudgetReturnsLargerPayloadAndPreservesTrailingInput( int size, int chunk ) { + Osc52Transport transport = new() { MaximumReadChunk = chunk }; + await using TerminalSession session = await OpenSessionAsync( transport, + options: new TerminalSessionOptions { + TerminalOverride = TerminalProfiles.Dumb, + ClipboardOptions = new() { MaximumReadBytes = size } + } ); + byte[] payload = new byte[ size ]; + for ( int i = 0; i < payload.Length; i++ ) payload[ i ] = (byte)i; + Task query = session.ReadClipboardAsync( + TerminalClipboardSelection.Clipboard, TimeSpan.FromSeconds( 30 ) ).AsTask(); + await WaitForWriteCountAsync( transport, 1 ); + transport.Publish( Encoding.ASCII.GetBytes( + "\u001b]52;c;" + Convert.ToBase64String( payload ) + "\u001b\\z" ) ); + Assert.Equal( payload, await query.WaitAsync( TimeSpan.FromSeconds( 35 ) ) ); + TerminalEvent trailing = await session.ReadEventAsync( TimeSpan.FromSeconds( 1 ) ); + Assert.Equal( new Rune( 'z' ), trailing.Input?.Character ); + Assert.Equal( 87_400, session.Options.InputDecoderOptions.MaximumBufferedBytes ); + Assert.Equal( 65_536, session.Options.ClipboardOptions.MaximumWriteBytes ); + } + + [Fact] + public async Task SmallReadBudgetRejectsDecodedOverflowInSameBase64Block() { + Osc52Transport transport = new(); + await using TerminalSession session = await OpenSessionAsync( transport, + options: new TerminalSessionOptions { + TerminalOverride = TerminalProfiles.Dumb, + ClipboardOptions = new() { MaximumReadBytes = 1 } + } ); + Task query = session.ReadClipboardAsync( + TerminalClipboardSelection.Clipboard, TimeSpan.FromSeconds( 5 ) ).AsTask(); + await WaitForWriteCountAsync( transport, 1 ); + transport.Publish( Encoding.ASCII.GetBytes( "\u001b]52;c;YWI=\u001b\\" ) ); + await Assert.ThrowsAsync( () => query ); + } + [Theory] [InlineData( TerminalClipboardSelection.Clipboard, 'c' )] [InlineData( TerminalClipboardSelection.Primary, 'p' )] @@ -282,6 +491,7 @@ int count } private sealed class Osc52Transport : ITerminalInput, ITerminalOutput { + internal int MaximumReadChunk { get; init; } = int.MaxValue; private readonly Channel input = Channel.CreateUnbounded(); private readonly List writes = []; private byte[]? pending; @@ -333,7 +543,7 @@ public async ValueTask ReadAsync( } int count = Math.Min( - buffer.Length, + Math.Min( buffer.Length, this.MaximumReadChunk ), this.pending.Length - this.pendingOffset ); this.pending.AsSpan( this.pendingOffset, count ).CopyTo( buffer.Span ); diff --git a/tests/Icod.Terminal.Tests/src/Packaging/StableReleaseIdentityTests.cs b/tests/Icod.Terminal.Tests/src/Packaging/StableReleaseIdentityTests.cs index 960ef2348..6293e7028 100644 --- a/tests/Icod.Terminal.Tests/src/Packaging/StableReleaseIdentityTests.cs +++ b/tests/Icod.Terminal.Tests/src/Packaging/StableReleaseIdentityTests.cs @@ -24,11 +24,11 @@ namespace Icod.Terminal.Tests.Packaging; using Xunit; /// -/// Prevents the stable distribution workflow from accepting a prerelease identity. +/// Keeps the distribution workflow aligned with the reviewed development identity. /// public sealed class StableReleaseIdentityTests { [Fact] - public void MainDistributionPinsStableOneTwentyEightIdentity() { + public void MainDistributionPinsOneTwentyNineStableIdentity() { string root = FindRepositoryRoot(); XDocument properties = XDocument.Load( Path.Combine( root, "Directory.Build.props" ) @@ -38,22 +38,69 @@ public void MainDistributionPinsStableOneTwentyEightIdentity() { .Single( element => "VersionPrefix" == element.Name.LocalName ) .Value .Trim(); - string versionSuffix = properties + XElement? versionSuffix = properties .Descendants() - .Single( element => "VersionSuffix" == element.Name.LocalName ) - .Value - .Trim(); + .SingleOrDefault( element => "VersionSuffix" == element.Name.LocalName ); string workflow = File.ReadAllText( Path.Combine( root, ".github", "workflows", "main.yaml" ) ); + string readme = File.ReadAllText( + Path.Combine( root, "README.md" ) + ); + string changelog = File.ReadAllText( + Path.Combine( root, "CHANGELOG.md" ) + ); + string releaseNotes = File.ReadAllText( + Path.Combine( root, "docs", "releases", "1.29.0.md" ) + ); - Assert.Equal( "1.28.0", versionPrefix ); - Assert.Equal( string.Empty, versionSuffix ); + Assert.Equal( "1.29.0", versionPrefix ); + Assert.True( + null == versionSuffix + || String.IsNullOrWhiteSpace( versionSuffix.Value ) + ); Assert.Contains( - "EXPECTED_VERSION: 1.28.0", + "EXPECTED_VERSION: 1.29.0", workflow, StringComparison.Ordinal ); + Assert.Contains( + "Stable release: **1.29.0**.", + readme, + StringComparison.Ordinal + ); + Assert.Contains( + "dotnet add package Icod.Terminal --version 1.29.0", + readme, + StringComparison.Ordinal + ); + Assert.Contains( + "https://github.com/uniblab/Icod.Terminal/blob/main/docs/releases/1.29.0.md", + readme, + StringComparison.Ordinal + ); + Assert.Contains( + "## 1.29.0", + changelog, + StringComparison.Ordinal + ); + Assert.True( + File.Exists( + Path.Combine( root, "docs", "releases", "1.29.0.md" ) + ) + ); + foreach ( string requiredReleaseNoteText in new[] { + "net8.0", + "net9.0", + "net10.0", + "Compatibility-and-Versioning.md" + } ) { + Assert.Contains( + requiredReleaseNoteText, + releaseNotes, + StringComparison.Ordinal + ); + } Assert.Equal( 2, CountOccurrences( diff --git a/tests/Icod.Terminal.Tests/src/Samples/ClipboardCommandLineTests.cs b/tests/Icod.Terminal.Tests/src/Samples/ClipboardCommandLineTests.cs new file mode 100644 index 000000000..e31ded64a --- /dev/null +++ b/tests/Icod.Terminal.Tests/src/Samples/ClipboardCommandLineTests.cs @@ -0,0 +1,63 @@ +/* + Icod.Terminal.Tests + Automated test suite for the Icod.Terminal library. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +using System.Text; + +namespace Icod.Terminal.Tests.Samples; +using Icod.Terminal.Clipboard.Sample; +using Xunit; + +public sealed class ClipboardCommandLineTests { + [Theory] + [InlineData( "" )] + [InlineData( "--help" )] + [InlineData( "--read --maximum-read-bytes 0" )] + [InlineData( "--write secret --maximum-write-bytes 1" )] + [InlineData( "--generated-bytes 65537" )] + [InlineData( "--read --maximum-write-bytes 16777217" )] + public async Task HeadlessOrInvalidCommandNeverOpensSession( string command ) { + bool opened = false; + using StringWriter output = new(); + using StringWriter error = new(); + int result = await ClipboardCommandLine.RunAsync( + command.Split( ' ', StringSplitOptions.RemoveEmptyEntries ), output, error, + ( _, _ ) => { opened = true; throw new InvalidOperationException(); } ); + Assert.False( opened ); + Assert.Equal( command is "" or "--help" ? 0 : 2, result ); + Assert.DoesNotContain( "secret", error.ToString() ); + } + + [Fact] + public async Task IndependentLimitsReachSessionFactory() { + TerminalSessionOptions? captured = null; + using StringWriter output = new(); + using StringWriter error = new(); + int result = await ClipboardCommandLine.RunAsync( + ["--generated-bytes", "100000", "--maximum-write-bytes", "1048576"], + output, error, ( options, _ ) => { + captured = options; + throw new InvalidOperationException( "Synthetic open failure." ); + } ); + Assert.Equal( 1, result ); + Assert.NotNull( captured ); + Assert.Equal( 65_536, captured.ClipboardOptions.MaximumReadBytes ); + Assert.Equal( 1_048_576, captured.ClipboardOptions.MaximumWriteBytes ); + } +} diff --git a/tests/Icod.Terminal.Tests/src/Samples/TerminalCompatibilityCommandLineTests.cs b/tests/Icod.Terminal.Tests/src/Samples/TerminalCompatibilityCommandLineTests.cs index 091eefd78..9a039070f 100644 --- a/tests/Icod.Terminal.Tests/src/Samples/TerminalCompatibilityCommandLineTests.cs +++ b/tests/Icod.Terminal.Tests/src/Samples/TerminalCompatibilityCommandLineTests.cs @@ -47,12 +47,13 @@ public async Task ScenarioListingIsStableAndHeadless() { Environment.NewLine, StringSplitOptions.RemoveEmptyEntries ); - Assert.Equal( 24, lines.Length ); + Assert.Equal( 25, lines.Length ); Assert.StartsWith( "identity.session/v1", lines[ 0 ], StringComparison.Ordinal ); Assert.StartsWith( "presentation.pointer-colors/v1", lines[ 20 ], StringComparison.Ordinal ); Assert.StartsWith( "query.appearance/v1", lines[ 21 ], StringComparison.Ordinal ); Assert.StartsWith( "environment.appearance-reporting/v1", lines[ 22 ], StringComparison.Ordinal ); Assert.StartsWith( "environment.in-band-resize/v1", lines[ 23 ], StringComparison.Ordinal ); + Assert.StartsWith( "clipboard.osc52.large/v1", lines[ 24 ], StringComparison.Ordinal ); Assert.Equal( 0, invocation.SessionFactoryCalls ); } diff --git a/tests/Icod.Terminal.Tests/src/Session/TerminalClipboardOptionsTests.cs b/tests/Icod.Terminal.Tests/src/Session/TerminalClipboardOptionsTests.cs new file mode 100644 index 000000000..558808617 --- /dev/null +++ b/tests/Icod.Terminal.Tests/src/Session/TerminalClipboardOptionsTests.cs @@ -0,0 +1,57 @@ +/* + Icod.Terminal.Tests + Automated test suite for the Icod.Terminal library. + Copyright (C) 2026 Timothy J. Bruce +*/ + +/* + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ +namespace Icod.Terminal.Tests.Session; + +using Xunit; + +public sealed class TerminalClipboardOptionsTests { + [Theory] + [InlineData( 0 )] + [InlineData( -1 )] + [InlineData( 16_777_217 )] + public void InvalidBudgetsAreRejected( int value ) { + Assert.Throws( () => new TerminalSessionOptions { + ClipboardOptions = new() { MaximumReadBytes = value } + }.Validate() ); + Assert.Throws( () => new TerminalSessionOptions { + ClipboardOptions = new() { MaximumWriteBytes = value } + }.Validate() ); + } + + [Fact] + public void NullClipboardOptionsAreRejected() { + Assert.Throws( () => new TerminalSessionOptions { + ClipboardOptions = null! + }.Validate() ); + } + + [Fact] + public void PublicSessionContractDefaultsToIndependent64KiBBudgets() { + var property = typeof( TerminalSessionOptions ).GetProperty( "ClipboardOptions" ); + Assert.NotNull( property ); + object? options = property.GetValue( new TerminalSessionOptions() ); + Assert.NotNull( options ); + Assert.Equal( 65_536, options.GetType().GetProperty( "MaximumReadBytes" )!.GetValue( options ) ); + Assert.Equal( 65_536, options.GetType().GetProperty( "MaximumWriteBytes" )!.GetValue( options ) ); + Assert.Equal( 16_777_216, options.GetType().GetProperty( "SupportedMaximumBytes" )!.GetValue( null ) ); + Assert.Equal( 87_400, TerminalSession.MaximumBufferedInputBytes ); + } +} diff --git a/tests/Icod.Terminal.Tests/src/Session/TerminalSessionClipboardTests.cs b/tests/Icod.Terminal.Tests/src/Session/TerminalSessionClipboardTests.cs index 3afab7704..85ddbaec0 100644 --- a/tests/Icod.Terminal.Tests/src/Session/TerminalSessionClipboardTests.cs +++ b/tests/Icod.Terminal.Tests/src/Session/TerminalSessionClipboardTests.cs @@ -29,6 +29,46 @@ namespace Icod.Terminal.Tests.Session; /// Verifies the T56 semantic OSC 52 clipboard-write API without touching the host terminal. /// public sealed class TerminalSessionClipboardTests { + [Theory] + [InlineData( 65_537 )] + [InlineData( 1_048_576 )] + [InlineData( 16_777_216 )] + public async Task OptInWriteBudgetEmitsExactLargerPayload( int size ) { + RecordingTerminalOutput output = new(); + await using TerminalSession session = await OpenSessionAsync( + new RecordingTerminalControlProvider(), output, + new TerminalSessionOptions { + TerminalOverride = TerminalProfiles.Dumb, + ClipboardOptions = new() { MaximumWriteBytes = size } + } ); + byte[] payload = new byte[ size ]; + for ( int i = 0; i < payload.Length; i++ ) payload[ i ] = (byte)i; + await session.WriteClipboardAsync( TerminalClipboardSelection.Clipboard, payload ); + Assert.Equal( 1, output.WriteCount ); + byte[] frame = output.Bytes.ToArray(); + Assert.Equal( payload, Convert.FromBase64String( + Encoding.ASCII.GetString( frame, 7, frame.Length - 9 ) ) ); + Assert.Equal( 65_536, session.Options.ClipboardOptions.MaximumReadBytes ); + } + + [Fact] + public async Task CustomWriteBudgetRejectsUtf8BytesBeforeOutput() { + RecordingTerminalOutput output = new(); + await using TerminalSession session = await OpenSessionAsync( + new RecordingTerminalControlProvider(), output, + new TerminalSessionOptions { + TerminalOverride = TerminalProfiles.Dumb, + ClipboardOptions = new() { MaximumWriteBytes = 3 } + } ); + await Assert.ThrowsAsync( () => session.WriteClipboardAsync( + TerminalClipboardSelection.Clipboard, "éé" ).AsTask() ); + await Assert.ThrowsAsync( () => session.WriteClipboardAsync( + TerminalClipboardSelection.Clipboard, new byte[ 4 ] ).AsTask() ); + Assert.Equal( 0, output.WriteCount ); + await session.WriteClipboardAsync( TerminalClipboardSelection.Clipboard, "é" ); + Assert.Equal( 1, output.WriteCount ); + } + [Theory] [InlineData( 0, "hello", "1B5D35323B633B614756736247383D1B5C" )] [InlineData( 1, "hello", "1B5D35323B703B614756736247383D1B5C" )] diff --git a/tools/package-clipboard-smoke/Program.cs b/tools/package-clipboard-smoke/Program.cs index fc496eaa0..4d994130b 100644 --- a/tools/package-clipboard-smoke/Program.cs +++ b/tools/package-clipboard-smoke/Program.cs @@ -88,7 +88,35 @@ await transport.WaitForWriteCountAsync( "The package-only OSC 52 query returned unexpected decoded bytes." ); -Console.WriteLine( "Icod.Terminal package clipboard smoke test passed." ); +Require( session.Options.ClipboardOptions.MaximumReadBytes == 65_536 + && session.Options.ClipboardOptions.MaximumWriteBytes == 65_536, + "Default package clipboard budgets changed." ); + +ClipboardTransport largeTransport = new(); +await using TerminalSession largeSession = await TerminalSession.OpenAsync( + new PackageTerminalControlProvider(), TerminalEndpoint.StandardInput, TerminalEndpoint.StandardOutput, + largeTransport, largeTransport, new TerminalSessionOptions { + ConfigureOutput = false, ObserveLifecycleEvents = false, TerminalOverride = TerminalProfiles.Dumb, + InputDecoderOptions = new() { MaximumBufferedBytes = 32 }, + ClipboardOptions = new() { MaximumReadBytes = 1_048_576, MaximumWriteBytes = 1_048_576 } + } ); +byte[] largePayload = new byte[ 1_048_576 ]; +for ( int i = 0; i < largePayload.Length; ++i ) largePayload[ i ] = (byte)i; +byte[] largeFrame = Encoding.ASCII.GetBytes( "\u001b]52;c;" + Convert.ToBase64String( largePayload ) + "\u001b\\" ); +await largeSession.WriteClipboardAsync( TerminalClipboardSelection.Clipboard, largePayload ); +Require( largeFrame.SequenceEqual( largeTransport.GetWrite( 0 ) ), "Custom-budget package write differed." ); +Task largeQuery = largeSession.ReadClipboardAsync( TerminalClipboardSelection.Clipboard, + TimeSpan.FromSeconds( 30 ) ).AsTask(); +using CancellationTokenSource largeTimeout = new( TimeSpan.FromSeconds( 30 ) ); +await largeTransport.WaitForWriteCountAsync( 2, largeTimeout.Token ); +largeTransport.Publish( largeFrame ); +Require( largePayload.SequenceEqual( await largeQuery ), "Custom-budget package read differed." ); +bool rejected = false; +try { + await session.WriteClipboardAsync( TerminalClipboardSelection.Clipboard, largePayload ); +} catch ( ArgumentOutOfRangeException ) { rejected = true; } +Require( rejected, "Another session inherited an opt-in budget." ); +Console.WriteLine( "Icod.Terminal package clipboard smoke passed: default and independent 1 MiB budgets." ); internal sealed class ClipboardTransport : ITerminalInput, ITerminalOutput { private readonly Channel input = Channel.CreateUnbounded(); diff --git a/tools/package-compatibility-smoke/Program.cs b/tools/package-compatibility-smoke/Program.cs index 4c9d5ecab..5f4c5554e 100644 --- a/tools/package-compatibility-smoke/Program.cs +++ b/tools/package-compatibility-smoke/Program.cs @@ -24,9 +24,11 @@ You should have received a copy of the GNU General Public License await VerifyAsync( [ "--help" ], output => output == CompatibilityCommandLine.HelpText + Environment.NewLine ); await VerifyAsync( [ "--list-scenarios" ], output => - 24 == output.Split( Environment.NewLine, StringSplitOptions.RemoveEmptyEntries ).Length ); + 25 == output.Split( Environment.NewLine, StringSplitOptions.RemoveEmptyEntries ).Length ); await VerifyAsync( [ "--describe", "clipboard.osc52" ], output => output.Contains( "clipboard.osc52/v1", StringComparison.Ordinal ) ); +await VerifyAsync( [ "--describe", "clipboard.osc52.large" ], output => + output.Contains( "131072", StringComparison.Ordinal ) ); string fixtureDirectory = Path.Combine( AppContext.BaseDirectory, "fixtures" ); string matrixPath = Path.Combine( Path.GetTempPath(), $"icod-compatibility-{Guid.NewGuid():N}.md" );