diff --git a/DEBUG.md b/DEBUG.md index 74acbd69..bcf5362d 100644 --- a/DEBUG.md +++ b/DEBUG.md @@ -145,3 +145,12 @@ The Home bell regression was caused by replacing frequent summary polling with e ## Fix - 2026-05-26 Home Bell And ela.city Balance Regression Home now refreshes shell summary for wallet/inbox events even when they arrive in the first long-poll payload after SSE fallback. Wallet EVM default updates now also set a `browser_connect` default, and Browser uses that default before generic transaction defaults. + +## External Provider Deploy Invariant + +When code inside a standalone external provider changes, deployed Home or app +assets are not enough. Rebuild the provider binary, install it under the active +`XDG_DATA_HOME/elastos/bin`, update the provider entry in `components.json` with +the new sha256 and size, and restart the gateway so the provider process is +respawned. Otherwise Home/Library can serve current JavaScript while the running +provider still returns stale roots, operations, or schemas. diff --git a/ROADMAP.md b/ROADMAP.md index 12d51185..340fe8ed 100755 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -159,6 +159,33 @@ Runtime principals, scoped capabilities, provider-owned effects, and signed audit. The current translation is tracked in [docs/PC2_CONVERGENCE.md](docs/PC2_CONVERGENCE.md). +The first PC2 migration slices should stay product-useful and boundary-small: + +1. **Explorer / Library / WebSpace**: browse, upload, download, open, + publish, share, and inspect files/objects through Home/Library, + principal-root storage, persisted WebSpace mount/object-head metadata, + WebSpace lifecycle/health receipts, + `elastos://content/*`, recipient share-grant records, recipient-scoped + shared-access checks, and availability receipts with honest + peer-selection/quota/repair-worker metadata. + Preserve PC2's file-manager UX where it helps users, but translate every + operation onto typed Runtime object/provider contracts instead of PC2's older + filesystem, Puter, or direct IPFS assumptions. The implementation gate is + [docs/FILE_MANAGER_MIGRATION.md](docs/FILE_MANAGER_MIGRATION.md). +2. **AI Chat**: bring the chat UX over as a provider-backed app capsule where + inference, hosted-model credentials, embeddings, and document context + expansion stay inside `ai-provider`, `llama-provider`, or an operator-pinned + hosted provider. +3. **dDRM + Elacity Marketplace foundation**: wire protected-content provider + contracts before Marketplace/Creator/Player/Viewer UX. The sequence is + content status/fetch, rights check, key release, decrypt/render session, + receipt, Wallet/Inbox approval where needed, and audit. + +Those slices are intentionally ordered so the user can first manage and publish +ordinary objects, then use provider-backed AI over those objects, then add +protected-content economics without giving apps raw keys, wallets, chain RPC, +Kubo/IPFS, Elacity SDKs, or provider credentials. + COMO is a separate runtime-framework research input, not a planned dependency. Its C++ component model, runtime reflection, MetaClass packaging idea, Android aarch64 history, and safety/redundancy lessons may inform the capsule-kernel ABI @@ -220,6 +247,9 @@ The first implementation should be deliberately layered: - expose `elastos://content/*` as the capsule-facing product contract - keep `elastos://ipfs/*` only as the current low-level system/provider backend around Kubo, then retire it from the normal capsule-facing namespace once `elastos://content/*` exists - model published objects, signed heads, provenance, and availability receipts with IPLD-compatible JSON/CBOR shapes +- keep local-only availability receipts honest by carrying explicit + peer-selection/quota/repair-worker metadata instead of implying live + multi-peer replication - use Elacity/supernodes as the first remote availability target - add volunteer replication and repair loops behind provider policy - add payment/storage incentives only after receipts, quotas, health checks, and abuse controls exist diff --git a/TASKS.md b/TASKS.md index a1a2201a..f2f5f2dc 100644 --- a/TASKS.md +++ b/TASKS.md @@ -156,18 +156,21 @@ section if a higher section is incoherent, unverified, or too large to review. ### Runtime primitives missing for the PC2 world-computer model - [ ] Replace hardcoded `Users/self` assumptions with first-class principals: passkey-owned user roots, user DID, device DID, personas, agents, active session, and capability tokens bound to principal + capsule + session. - [ ] Add authenticated Carrier envelopes as the default application contract: sender DID, object identity, signature, capability context, replay protection, and verified delivery status. Keep raw gossip/transport as an explicit unsafe/provider-level lane. -- [ ] Add a real WebSpace mount/object model: mount table, resolver selection, object heads, local cache, access policy, sync cursors, and typed viewer resolution. +- [ ] Keep the `object-provider` / `content-provider` ontology stable while completing the remaining WCI object/content work: `object-provider` owns mutable principal-root objects, and `content-provider` owns published content identity, availability, and Carrier-backed delivery authority. +- [ ] Extract pure object-provider core out of `elastos-server::library` into a smaller provider-core crate when modularity becomes the release bar: preserve the existing `object-provider` capsule/API boundary, move principal-root object request handling, path rules, archive/event helpers, and tests without changing Library behavior, and keep publish/share/availability authority separated through `content-provider` and Runtime coordination. +- [ ] Keep Public placement and Published content separate in every Library/Home/Spaces surface: `Public` is a user-facing placement/projection under the active principal root, while `published_cid`/`elastos://` is the only public content-link truth. Do not add hidden auto-publish side effects for rename/move/copy/upload into Public; if auto-publish is desired later, make it an explicit user policy prompt backed by content-provider receipts. - [ ] Add signed package identity for every installable capsule: manifest hash, full bundle hash/Merkle root, publisher DID, signature chain, interface descriptors, and install/update receipts. - [ ] Add an interface registry primitive: signed interface descriptors, semantic versions, required/provided capability schema, compatibility resolution, and fail-closed launch when required interfaces are missing. - [ ] Complete wallet/EID/chain providers behind the runtime boundary. The runtime should expose capability-gated signing, approval, credential, node-read, proof, broadcast, and provenance operations; it should not embed chain business logic. - [ ] Keep network-drive/provider operating systems outside the trusted core. The runtime owns verification, capability routing, and audit; provider capsules/services own Telegram/Nostr/Matrix/Facebook/IPFS/Carrier-specific behavior. ### WebSpace / WCI contract -- [ ] Expand the current `webspace-provider` slice into fuller resolver outputs and deeper typed traversal. - [ ] Clarify the relationship between rooted localhost paths, `elastos://...`, and mounted WebSpace views without freezing syntax too early. +- [ ] Make the Spaces UX model explicit before expanding Library roots: `Home` is the friendly alias for the active principal's local `localhost://Users/` space; a future `Localhost`/`This Device` Space may expose the same authorized principal tree and selected system roots, but never raw all-host data or other principals. `elastos://` should remain the global content/capability namespace, not a writable file path. A future `elastos://vault` (name TBD) should be an encrypted, DID-anchored, provider-backed replicated object space that can fork/sync selected local objects; quota/accounting applies there and to published/federated storage, not to ordinary local-only `localhost://` bytes. - [ ] Define the CAS object model so paths stay the comfort layer rather than the real identity model. - [ ] Keep capsule execution substrate (`type`), product role (`shell`/`app`/`viewer`/`provider`/`content`), and launch exposure as separate runtime concepts instead of letting one field imply the others. - [ ] Document and enforce the object/capsule/space split consistently across UI copy, manifests, runtime docs, and shell/catalog surfaces. +- [ ] BLOCKER - production multi-peer availability/storage markets require real external infrastructure before this can close: production independent provider-network quota-ledger federation beyond the configured bounded endpoint quorum, production network-wide abuse throttles/banlists/abuse ledgers beyond the configured bounded abuse-control endpoint quorum, production federated operator fleet dashboards/UI/peer-health subscriptions beyond the current provider-local dashboard plus configured alert-exchange endpoint, production cross-runtime peer reputation trust policy, third-party attestations, revocation, and fleet-wide reputation exchange beyond the configured Carrier peer-attestation endpoint quorum, production storage-market offer/pricing/SLA execution beyond the configured storage-market endpoint-quorum admission gate, repair-fleet worker attestation/SLA/settlement beyond configured dispatch quorum, and live settlement/escrow execution. ### Collaboration and messaging - [ ] Earn IRC only as an explicit packaged path with honest runtime prerequisites and proof. @@ -175,6 +178,7 @@ section if a higher section is incoherent, unverified, or too large to review. ### Documents and Library - [ ] Add import/fork flows for immutable `elastos://` document revisions through the same provider contract. +- [ ] Future generic archive dependency approval: only after a format-specific review passes, enable an extra non-tar/non-zip family through the existing provider-owned archive list/preview/selective-extract/WebSpace policy contract. Current branch support for ZIP/tar/tar.gz/tgz browsing, preview, selected import/extract, WebSpace archive policy, and Archive UX is complete; unsupported generic families remain policy-gated by design. - [ ] Unify the markdown packaging model so local documents, viewer/editor content, and `elastos share` do not keep using three different markdown stories. - [ ] Decide the first collaborative document core intentionally; prefer a Rust/WASM CRDT evaluation (`Yrs` first, `Automerge` second) over ad hoc editor glue or a direct port of external JS products. - [ ] Keep keystroke-level local editing local-first and low-latency; Carrier should carry remote sync/share/collaboration updates, not gate every same-runtime write. diff --git a/TODAY.md b/TODAY.md new file mode 100644 index 00000000..c18fd6c5 --- /dev/null +++ b/TODAY.md @@ -0,0 +1,1302 @@ +# Today - Library / Explorer Release State + +Date: 2026-06-06 + +Goal: keep Library as a stable, PC2-familiar Explorer running on ElastOS +Runtime provider rails. Library must be testable by a human in Home and by an +operator through route/provider/UI checks. + +## Non-Negotiables + +- Library is an app capsule. It owns UI only. +- `object-provider` is the canonical mutable principal object provider. It owns + folders, files, Desktop/Documents, revisions, Trash, encrypted + principal-root storage, and Library object events. Runtime registers only the + `object` provider scheme, and browser calls use `/api/provider/object/*`. +- `content-provider` is the Carrier-backed published-content authority. It owns + immutable content identity, CIDs, publish/fetch, status, repair, replication, + and availability receipts. It should not own Explorer UI, folder names, + Desktop, Trash, or local rename/move semantics. +- `ipfs-provider`/Kubo is only the current low-level local content backend for + CID creation, pinning, and fetch. It is not the final capsule-facing content + delivery model and must stay system-only behind `content-provider`. +- The fully aligned target is a Carrier/provider content-delivery plane: + `content-provider` owns content policy and receipts, Carrier coordinates peer + discovery/transport, availability providers handle replication/repair, and + apps see one access surface regardless of whether bytes are local, cached, or + fetched from the network. +- `webspace-provider` exposes mounted/discoverable spaces. It is a resolver + surface over object/content spaces, not the principal-root object graph; mutable + mounts/forks may also materialize local WebSpace objects in provider-owned + object/head tables until external resolver sync workers take over. +- Library **Spaces** is a user-facing index, not a new storage authority. + `Localhost` opens the signed principal root under `localhost://Users/`; + `Elastos` opens the built-in resolver view under + `localhost://WebSpaces/Elastos`; connected mounts continue under + `localhost://WebSpaces//...`. Provider targets such as + `google://drive/...` are resolver-private, and `elastos://content/*` is the + provider-independent published/shared content identity after import, publish, + or fork. +- Runtime injects the signed principal and mediates Library operations. +- Library must not call raw host filesystem, Kubo/IPFS, Elacity APIs, wallet, + chain, network, Carrier peer, provider SDK, or broad `localhost://Users/*`. +- PC2 is the UX reference, not the authority model. +- Every visible menu/action must work now or be hidden. + +## Canonical Remaining Product Plan + +This is the single source of truth for the repeated gap lists. The old internal +track labels have been retired. The branch should now describe work by product +area and authority boundary, not by numbered release buckets. + +### First-Principles Audit Rules + +- Runtime owns identity injection, capability routing, provider invocation, and + audit. Apps never receive raw provider SDKs, host paths, Carrier tickets, + Kubo/IPFS handles, keys, wallet authority, or foreign principal roots. +- `object-provider` owns mutable principal-root objects; `content-provider` + owns published immutable content identity, CIDs, availability, and + Carrier-backed delivery receipts; `webspace-provider` owns mounted resolver + views and metadata/fork heads. +- Paths such as `localhost://WebSpaces//...` are user-comfort views. + Provider targets and CIDs remain resolver/content identities behind Runtime + and provider mediation. +- A product area closes only when the user-visible behavior and the + provider/runtime authority boundary both have tests. Documentation or receipt + metadata alone is not enough. + +### Complete In This Branch + +- Object-provider migration is complete. `object-provider` is the canonical + package, manifest, Runtime `object` scheme, and `/api/provider/object/*` + browser route. Retired mutable-object provider compatibility fallback paths + should not return. +- Provider-to-provider invocation is foundation-complete. Runtime can invoke + service providers locally and over Carrier provider transport without exposing + raw transport/backend authority to apps. +- Provider streaming is complete for this branch. `ProviderTransfer::Stream` is + a validated JSON/base64 chunk envelope, Runtime stream sessions provide + read/cancel/progress flow control, `content-provider` fetch drains provider + chunks through that path, and Library downloads return chunked HTTP body + streams with backpressure/cancel receipt metadata. +- Recipient-scoped sharing and protected-content receipt-chain proof are + complete for this branch. Library has public-link and recipient-scoped share + UX, `shared_access`, `Check My Access`, Runtime recipient-proof injection, + readiness receipts, and receipt-bound drm/rights/key/decrypt provider + contracts. This does not claim production encrypted payload generation, + production dDRM policy reads, or production dKMS. +- Spaces/WebSpace foundation and live byte sync are complete for this branch. + Runtime has persistent mounts, resolver adapter registry/health, object heads, + cache/sync/fork receipts, local materialization, adapter + `metadata_index`/`read_bytes`/`write_bytes` contracts, provider-to-provider + adapter invocation, durable byte-cache status, Documents viewer handoff, + resolver availability hints, installed `operator-drive-adapter`, redacted + endpoint status/receipts, deterministic local adapter storage, and a + filesystem-backed operator endpoint proof. +- Multi-peer availability proof foundation is complete for this branch. Carrier + provider invocation can prove remote replicas for supported exact/manifest + paths; receipts include peer-selection, quota, accounting, abuse-control, + repair-worker, storage-market posture, and capped remote proof metadata. +- Storage accounting and quota admission are complete for this branch. Signed + availability receipts project into a durable per-principal ledger; publish, + exact import, and manifest-object import can enforce principal storage quota + before bytes enter the local content backend. +- Cross-provider admission and repair policy are complete for this branch. + `content-provider` exposes signed provider-only admission receipts, Carrier + verifies remote admission before moving bytes or DAG repair data, and + arbitrary DAG repair uses the Runtime-only block-graph provider path instead + of unsafe exact-byte fallback. +- Operator availability/status surfaces are complete for this branch. Operators + can inspect provider-wide or per-CID content status, storage accounting, + quota, repair graph, repair workers, peer proof, storage-market admission + posture, settlement posture, external repair-fleet posture, alerting posture, + peer reputation posture, and configured endpoint-quorum exchanges through + Runtime provider invocation. +- Archive support is complete for enabled families in this branch. ZIP, tar, + tar.gz, and tgz download/list/preview/selective extract are provider-owned; + generic non-tar/non-zip families are detected and policy-gated; Archive + exposes safe browse/import/extract UX, direct Library journeys for opening + existing archives or creating new ZIP archives, and WebSpace archive policy + without viewer-side extraction or raw provider access. + +### Remaining Product Tracks + +- Production multi-peer availability and storage markets require real external + production infrastructure and remain outside this branch scope. Required proof + includes production provider-network quota federation, production abuse/ban + ledgers, production peer reputation and revocation exchange, production + storage-market pricing/SLA/settlement/escrow, repair-fleet SLA/settlement, + and production operator dashboard/subscription infrastructure. Do not close + this with another local endpoint shim, receipt-only schema, policy/status + surface, or documentation-only claim. +- Future generic archive work should be a format-specific dependency and + release-policy approval. Do not reopen the completed ZIP/tar behavior unless + a real regression is found. +- Production encrypted-content backends remain a Trusted content/access-rights + follow-up: real encrypted payload generation, production dDRM policy reads, + production dKMS/key release, and production decrypt/render backends. + +### Release Decision + +- This Library release can be considered feature-complete for the current branch + scope: Library Explorer UX, the object-provider capsule/API boundary, + provider invocation and streaming, recipient-scoped sharing proof, + Spaces/WebSpace foundation, + Archive for enabled families, and branch-local multi-peer + availability proof/status surfaces. +- Remaining work before publishing is release validation: human Chrome-profile + testing, live signed Home/Library smoke with a real session, release notes, + version selection, and operator-key publication. +- Do not keep renaming the gaps. Future deferrals should map to production + multi-peer/storage-market infrastructure, format-specific archive dependency + approval, or production Trusted content/access-rights backends. + +### Completion Audit 2026-06-06 + +- Objective checked: complete the protected-recipient and availability/storage + proof work in this file and run a full entropy/alignment pass. +- Branch-local protected-recipient and availability/storage proof work is + verified complete for this branch: + protected recipient receipt-chain tests, content status/dashboard tests, + repair-worker tests, Carrier replication/admission/block-graph tests, + storage-accounting/quota/admission tests, standalone `availability-provider` + metadata/fanout tests, configured storage-market endpoint-quorum admission tests, + configured federated quota-ledger endpoint-quorum exchange tests, + configured federated abuse-control endpoint-quorum exchange tests, + configured external repair-fleet endpoint-quorum dispatch tests, operator alert receipt/sink + tests, configured federated operator alert-exchange tests, configured Carrier peer-attestation endpoint-quorum exchange tests, + `cargo check`, `cargo clippy`, WCI alignment, Home entropy, and whitespace + checks pass. +- Full entropy sweep on 2026-06-06 verified the active protected-recipient and + availability/storage truth surfaces: + `git diff --check`, `node scripts/home-entropy-check.mjs`, + `bash scripts/check-wci-alignment.sh`, and stale-marker searches for retired + mutable-object provider package naming plus old numbered gap phrases pass. Remaining + `WebSpaces` strings are intentional internal URI namespace strings behind the + user-facing `Spaces` label; unrelated `legacy`/`fallback` hits are migration, + protocol-mode, generated emulator, or explicit fail-closed repair-path + contexts outside the current completion claim. +- Production multi-peer availability and storage markets are now explicitly + marked as a `TASKS.md` blocker, not branch-local release work. + It still requires real external federation/market/repair/alerting + infrastructure: production independent provider-network quota-ledger + federation beyond the configured bounded endpoint quorum, storage-market + pricing/SLA/settlement beyond the configured admission gate, repair-fleet + worker attestation/SLA/settlement beyond configured dispatch quorum, network + abuse ledgers/banlists beyond the configured bounded abuse-control endpoint + quorum, and production federated operator dashboards/UI beyond the configured + alert-exchange endpoint plus production peer reputation trust policy beyond + the configured Carrier peer-attestation endpoint quorum. +- No more local-only status schema should be added to close that gap. Resume it + only when real external production infrastructure is available for testable + execution. + +### Archive UX Audit 2026-06-07 + +- Archive is a viewer/assembly capsule, not a storage authority. Library remains + the object selector and object-operation surface; Home mediates explicit + capsule-to-capsule handoff; Runtime and `object-provider` keep byte access, + compression, extraction, and destination writes provider-owned. +- The user-facing Archive app now starts from two journeys: open an existing + archive from Library, or create a new ZIP from Library selection. Opened + archives use a Library-like file list, preview, destination picker, and + extract actions without exposing raw provider diagnostics. +- Removed noisy Archive copy and technical panels from the default + Archive UI. Safety, support status, and provider errors remain enforced by the + provider/runtime path and tests, but are not presented as primary user tasks. + +## Current State + +- `library` launches from Home and uses `/api/provider/object/:op` to reach the + Runtime object provider. +- `object-provider` is currently the provider capsule for principal-root + storage, object lifecycle, object events, WebSpaces resolver roots, and + share/status projection. A Runtime provider-to-provider invocation envelope + now exists and `content-provider` uses it for internal provider effects; the + provider plane now has explicit local and Carrier transports plus a bounded + provider stream envelope, so + Library content-backed operations remain explicitly Runtime/provider mediated. +- Target provider split is: `library` app -> Runtime -> mutable object provider + (`object-provider` package on the canonical `object` Runtime scheme) -> + Carrier-backed `content-provider` for published content and availability. +- Ontology priority: keep `object-provider` as the principal-root object + authority and `content-provider` as the published-content identity, + availability, and Carrier-backed delivery authority. The provider naming work + is closed for this branch: the object provider has one package, one manifest, + one Runtime scheme, and one browser provider route. +- `content-provider` now treats `carrier_announced` as an auditable + availability state. When no external availability provider is configured, + Runtime registers a built-in Carrier availability provider that signs and + announces published CIDs on deterministic Carrier topics instead of exposing + raw Kubo/IPFS/peer authority to apps. +- `content-provider` now owns the fetch decision too: it tries the local + CID backend first, then asks the internal availability provider for the same + CID/path if the local cache misses. Apps still use one `elastos://content/*` + surface. +- Carrier now has an internal `content_fetch` byte operation on its file ALPN: + connected Runtime peers can request CID/path bytes, and the serving Runtime + reads from its local `ipfs-provider` through the provider registry. This is + Runtime infrastructure, not app-visible Carrier or Kubo authority, and is now + the narrow compatibility/bootstrap path rather than the availability fetch + policy path. +- Carrier availability announcements now carry signed internal fetch + descriptors. On local cache miss, `content-provider` can ask the built-in + availability provider to verify matching announcements and fetch CID/path + bytes through generic Carrier `provider_invoke` to the remote `content` + service provider without exposing tickets, peer handles, or Kubo/IPFS to the + app. That Carrier availability fetch now requests `transfer: "stream"` and + decodes the validated provider stream envelope back into bytes for the + availability response. +- Availability receipts now carry explicit `peer_selection`, `quota`, and + `repair_worker` metadata. Local-only publishes honestly report + `single_local` with no live multi-peer proof; configured availability + providers may pass through richer Carrier/supernode policy metadata. +- Built-in Carrier availability now records an enforced replica-count quota + verdict in availability receipts: effective max replicas, used replicas, + `within_quota`/`at_quota`/`requirements_exceed_quota`, and explicit + impossible-requirement state. Signed content receipts now also carry + `elastos.content.accounting/v1` local accounting metadata with observed + file/byte counts and replica-byte estimates when the provider operation + exposes them. Signed receipts also carry + `elastos.content.abuse-controls/v1`; the Carrier path records candidate + limits, attempted remote provider invocations, failure counts, and whether + the local attempt cap throttled candidates. This is bounded provider-plane + enforcement and local receipt accounting/guardrails, not a full external + storage-accounting market. +- Built-in Carrier availability also scores signed candidate announcements + deterministically from local metadata plus bounded local success/failure + history before provider invocation. Runtime startup loads and persists that + local peer reputation under system content state. Remote peer-selection + receipts include redacted score, selection reason, and local reputation + reason, while federated peer reputation remains a production policy slice. +- `content-provider` now persists an auditable + `elastos.content.repair-task/v1` ledger under Runtime system content state. + Publish/ensure/repair/unpublish record local-only, queued, healthy, and + retired task states, `status` includes the latest task, and provider-only + `repair_worker` now requires a Runtime provider invocation envelope, so app + capsules cannot invoke autonomous repair directly. It can retry queued CIDs + through the same local pin plus availability-provider ensure path and returns + explicit local quota/abuse-control guardrail receipts for run limits, attempt + budgets, and failure throttling. Operators can trigger it with + `elastos content repair-worker`, which routes through Runtime + provider-to-provider invocation instead of raw provider JSON. Servers can + also enable the same bounded loop with + `ELASTOS_CONTENT_REPAIR_SCHEDULER=true`; it is opt-in, minimum-interval + guarded, and uses the same run limit, attempt budget, and failure budget + controls as the manual worker. With the built-in Carrier availability + provider, that retry path now uses signed Carrier availability announcements + to select remote peers, keeps at least one remote provider invocation in the + candidate budget when a live multi-peer proof is required and quota permits + it, preflights the remote peer's `content/admission` contract before moving + bytes, invokes the remote peer's `content/ensure`, verifies the same CID with + remote `content/status`, falls back first to bounded + `content/import_object` reconstruction for manifest-backed objects and then + to bounded `content/import_exact` byte push for file-like CIDs when remote + pin cannot fetch the object, records `network_available` only when an + independent remote provider proves a live pinned replica, verifies and + summarizes the remote content provider's signed availability receipt when + present, including safe peer-selection replica counts plus capped redacted + score/reason/local-reputation rows with explicit cap/truncation metadata, + quota, repair-worker, and accounting plus abuse-control posture, and emits + explicit peer-selection/quota/repair-worker metadata without leaking Carrier + connect tickets. This is the first autonomous cross-peer repair/replication + proof path; production peer markets, production arbitrary-DAG repair + scheduling/fleet execution beyond the current block-graph proof path, + production scheduling policy beyond the current opt-in bounded loop, network + abuse policy beyond local provider-invocation guardrails, federated peer + scoring beyond durable local reputation, richer remote/multi-peer dashboards + beyond the capped provider status rows, production storage-market admission + across independent provider networks, and production independent + provider-network quota-ledger federation beyond the configured bounded + endpoint quorum still remain separate product + slices. The + provider-owned no-CID `content/status` dashboard already summarizes the + current signed receipt, storage-accounting, and repair-task ledgers, + including quota verdict, per-principal files/bytes/replica estimates, local + accounting, abuse controls, live proof, remote replica, verified remote + receipt counters, and capped recent remote-replica proof rows with redacted + peer-selection score/reason and local-runtime reputation fields. +- Runtime provider-to-provider bounded byte transfers now apply requested + ranges to provider `data.data` base64 payloads and validate progress + `expected_bytes` when supplied. Provider-to-provider calls now inject a typed + `_runtime_invocation` envelope (`elastos.provider.invocation/v1`) into the + target provider request and mirror its source/target/op capability string in + transfer receipts, so target providers can audit Runtime-mediated local + provider-plane authority. `content-provider` fetch now propagates requested + `range`/`progress` contracts to both local IPFS and availability-provider + fallback reads and returns the provider transfer receipt with the fetch + response. Carrier provider invocation now routes through an explicit + `carrier-provider-plane` transport: the Runtime registry requires a registered + Carrier invoker, attaches the same invocation envelope, suppresses raw connect + tickets from app-visible receipts, and the remote Carrier ALPN accepts a + generic `provider_invoke` operation only for service-provider targets + (`content`, `availability`, `rights`, `key`, `decrypt`, `drm`) instead of raw + backends like `ipfs` or `localhost`. `ProviderTransfer::Stream` now uses a + validated `elastos.provider.stream/v1` base64-chunk envelope with range and + expected-byte validation. Runtime can open that transfer as a + `ProviderStreamSession` with read-next backpressure, progress events, and + cancel; `content-provider` fetch uses that session path for both local IPFS + and availability-provider fallback reads, and Library object downloads return + chunked HTTP body streams with explicit backpressure/cancel receipts. Source + providers cannot spoof + Runtime-only invocation/transfer metadata; requests that predeclare + `_runtime_invocation` or `_runtime_transfer` fail closed before reaching the + target provider. +- Principal roots, list, read/download, write/upload, mkdir, rename, move, + copy, trash, restore, permanent delete, publish, unpublish, repair, share, + status, provider-owned `.tar.gz`/`.zip` folder archive download, + provider-owned `.tar.gz`/`.zip` selected-object archive download, + provider-owned same-folder `Compress to ZIP` object creation for files, + folders, and same-folder selections, + `.tar`/`.tar.gz`/`.tgz`/`.zip` extraction, and typed events exist in the provider/gateway + path. +- Share now records typed policy metadata. Library exposes an in-app share + policy dialog for public-link and recipient-scoped sharing; supplied + recipients create `elastos.library.share-grant/v1` records on the published + object. The provider now has a recipient-scoped `shared_access` gate that + fails closed for recipients without an active grant, records allowed and + denied access decisions, validates optional Runtime recipient-proof context + (`elastos.library.recipient-proof/v1`) for recipient-scoped opens, and + returns explicit shared-open/key-release receipts for authorized recipients + without exposing raw content-provider, Carrier, Kubo/IPFS, or host authority + to Library. Gateway requests strip app-supplied recipient proof and inject a + Runtime launch-grant proof only when the requested recipient equals the signed + session principal and the session carries an active passkey proof binding. + Library now exposes a `Check My Access` action for shared published objects + that asks `object-provider` for the signed principal's `shared_access` + receipt and renders the access decision, open contract, and key-release + posture instead of making users infer remote access from raw share metadata. +- Library UI supports places/sidebar, breadcrumbs, grid/list, upload, new + folder, text document, inline rename, drag/drop upload/move/copy, + preview/open, file download, folder archive download, `Download as ZIP`, + selected-object archive download, `Download Selected as ZIP`, + provider-backed `Compress to ZIP` and `Compress Selected to ZIP`, + `Extract Here` for `.tar`/`.tar.gz`/`.tgz`/`.zip` archives, + publish/unpublish/share, public-link share receipt, signed-principal access + check, status/repair detail, trash/restore/delete, + properties, sort, show hidden, SSE refresh, and browser Back/Forward takeover. +- Home projects `localhost://Users//Desktop` through + `object-provider`; Home owns desktop placement/opening only and delegates + Desktop file/folder `Properties` and `Download` back into Library with a + signed `objectUri/action` launch instead of duplicating object-provider + authority in the shell. +- Documents can open and save a concrete Library object through + `/api/viewers/documents/library-object`. +- Legacy plaintext objects in the protected principal root are auto-protected + by `object-provider` on first access. +- Browser-native `prompt`, `alert`, and `confirm` are not used for Library + object actions. +- Context menus now use PC2-style first-level groups: `Open With` for installed + viewers, `Sort By`, `View`, and `New`. Byte-bearing files expose + `Copy Content CID`; published objects additionally expose `Copy Published + Link` instead of IPFS-branded UI copy. +- Sidebar right-click follows PC2 Explorer: sidebar chrome/title/blank space + suppresses the browser-native context menu, while sidebar place items expose + a small Library menu with `Open` and `Open in New Window`. +- Home now explicitly authorizes signed `library -> library` open-target + messages, so Library sidebar `Open in New Window` can open another Library + window without weakening the source-gated Home message policy. +- Folder item context menus now also expose `Open in New Window` for active + directories, matching PC2 Explorer while still routing through Home's signed + `library -> library` open-target policy. +- Sidebar right-click suppression now covers the whole sidebar, including the + Favorites title and empty sidebar area, so the browser-native context menu is + not exposed there. +- Sidebar active state now chooses the most specific matching root, so Home + does not remain selected while Desktop/Documents/Public/Spaces are active. +- Sidebar places now support PC2-style drag reorder as a local user preference: + provider roots remain provider-owned, while `library.sidebarOrder` persists + the visible order by root ID and survives reload. +- Published/blocked/trash badges are static Explorer layout elements, not icon + overlays. Published state appears below the filename in icon view and inside + the name column in list view. +- Empty folder states are centered in the content pane, and Spaces empty + state explains that mounted provider-backed spaces appear only when a + WebSpace resolver is available. Read-only resolver mounts stay explicitly + read-only; mutable WebSpace mounts expose provider-backed write/mkdir/delete + affordances only when the WebSpace provider marks the current handle writable. +- Library now treats Spaces as mounted/indexed resolver views in the UI: + `localhost://WebSpaces` lists mounts such as `Elastos` and indexed external + spaces such as `Google`, and traversal like + `localhost://WebSpaces/Google/Drive/Project X/file.pdf` remains read-only + while exposing resolver metadata and raw Runtime download/open affordances. +- Library now also honors writable WebSpace metadata: mutable mounts/forks can + create folders, upload/write files, read materialized bytes, and permanently + delete local materialized objects through Runtime -> `webspace-provider` + without exposing raw resolver targets or host paths. +- `webspace-provider` now has a persistent mount table under Runtime data, + typed mount/unmount/list/index operations, a persistent resolver index table, + a persistent local object table for mutable materialized WebSpace objects, + provider-owned refresh/cache/sync lifecycle receipts for resolver metadata + and fork heads, metadata health reports for mounted-no-index, + metadata-ready, and dirty-head states, provider-backed `write`/`mkdir`/`delete` + for non-readonly user mounts, and CLI support for + `elastos webspace mounts|mount|unmount|index|health|refresh|head|cache|cache-status|sync|sync-status|fork`. Built-in + `Elastos` remains reserved; custom mounts such as + `localhost://WebSpaces/Google/...` map indexed local handles to resolver + targets such as `google://drive/...` without exposing provider credentials or + transport handles to Library. +- Async SVG hydration has been removed from folder rendering. Icons are stable, + the sidebar stays mounted, visible/sorted objects are cached, and hot-path + renders are measured. +- Library caches successful folder listings, prefetches root folders after + initial load, and uses cached listings for navigation/back/forward while + refreshing through the provider. Mutating provider operations clear the cache + so authority remains provider-owned. +- Large-folder rendering now uses PC2-style keyed item reuse plus chunked + first paint: unchanged item DOM nodes are reused by URI/signature, the first + visible chunk paints immediately, and remaining rows append across animation + frames. +- Upload progress rendering is frame-coalesced, so local file read progress does + not repaint the upload panel for every browser `FileReader` progress event. +- `capsules/library/index.html` is a static shell. Active Library modules are + `library.css`, `src/app.js`, `src/actions.js`, `src/api.js`, + `src/dialog.js`, `src/editor.js`, `src/events.js`, `src/menu.js`, + `src/model.js`, `src/navigation.js`, `src/preview.js`, + `src/realtime.js`, `src/render.js`, `src/selection.js`, `src/state.js`, + and `src/uploads.js`. +- `src/editor.js` owns inline create/rename behavior: draft object insertion, + rename input lifecycle, Enter/Escape/blur handling, and provider-backed + commit callbacks injected from `src/app.js`. It does not own provider + routing, raw storage, content, Carrier, Kubo, network, wallet, chain, or host + filesystem authority. +- `src/events.js` owns Library UI event binding for places, breadcrumbs, + toolbar buttons, content click/double-click/context menu, drag/drop, + keyboard shortcuts, browser history popstate, and unload cleanup. It receives + all Runtime/provider actions by injection and has no direct provider/backend + authority. +- `src/state.js` owns in-memory Library state initialization, perf counters, + visible-object filtering/sorting cache, folder-listing signatures, folder + cache writes, and the mutating-provider op set used to clear stale local + caches. It has no provider, storage backend, Carrier, Kubo, network, or host + filesystem authority. +- `src/render.js` owns the content render hot path: empty state, grid/list row + construction, list headers, footer counts, view toggles, keyed item DOM reuse, + chunked large-folder rendering, and first-paint telemetry. `src/app.js` keeps + provider orchestration and event wiring. +- `src/preview.js` owns preview reads and blob URL lifecycle for text, image, + video, audio, and PDF previews. Preview bytes still come through the Runtime + object provider; the preview module has no direct storage authority. +- `src/realtime.js` owns Library SSE/EventSource lifecycle, reconnect timers, + current-folder event matching, and debounced refresh scheduling. It only calls + the injected `loadCurrentFolder` path and has no raw provider/backend + authority. +- `src/actions.js` owns provider-backed user/object actions: open/viewer + handoff, upload/download, publish/share/status/repair, trash/restore/delete, + clipboard paste/move/copy, and text/folder creation. It receives Runtime + provider and Home/viewer helpers by injection and does not gain raw storage, + content, network, Carrier, Kubo, or host filesystem authority. +- Home service-worker registration is intentionally disabled for now, and the + deployed service worker self-unregisters while clearing old `elastos-home-*` + caches. This prevents stale browser-profile cache state from masking Runtime + shell/module deployments during active development. + +## PC2 Code Comparison + +- Checked `Elacity/pc2.net` `main` at + `a0a910158bd67666a6d3ea2a775ce09005ba7ae7`, matching the recorded PC2 + baseline in `docs/FILE_MANAGER_MIGRATION.md`. +- PC2 UI reference files inspected: `src/gui/src/UI/UIItem.js`, + `src/gui/src/UI/UIDesktop.js`, `src/gui/src/helpers/open_item.js`, + `src/gui/src/helpers/refresh_item_container.js`, + `pc2-node/src/api/filesystem.ts`, and `pc2-node/src/api/file.ts`. +- Runtime Library now matches the core PC2 Explorer affordances: item anatomy + with icon/name/details/badges, inline name editor, double-click open, + Desktop as an item container, context menu/taphold shape, sort/view controls, + Shift-click range selection, Enter-open of selected objects, keyboard context + menu open, upload, drag/drop move/copy, Trash/restore/delete, + publish/share/status, properties, folder/sidebar `Open in New Window`, + selected-object archive download, `.tar`/`.tar.gz`/`.tgz`/`.zip` extraction, + and viewer handoff. +- Runtime Library intentionally rejects PC2 authority shortcuts found in the + code: username-to-wallet path rewriting, `/null` path fallbacks, + signed file URL shortcuts, broad global socket/session assumptions, direct + GUI access to filesystem APIs, app-visible IPFS/gateway paths, and wallet + address roots as filesystem truth. +- Current Runtime alignment is therefore product-level PC2 UX on ElastOS rails, + not a PC2 backend transplant: `library` stays UI-only; Runtime injects the + principal; `object-provider` owns mutable principal-root object state; + `content-provider` owns published content; Kubo/IPFS remains behind + `content-provider`; Home only projects Desktop through provider summary. + +## Verified Locally + +- `node scripts/library-menu-smoke.mjs` now covers PC2-style nested + `Sort By`, `View`, `New`, and `Open With` context-menu groups, suppressed + sidebar right-click, single active sidebar place, centered empty states, and + mounted/indexed Spaces traversal/read-only behavior through + `Google/Drive/Project X/file.pdf` and `Elastos/content/` handles. It + also asserts Published badge placement in + icon/list views and proves framed Library sidebar and folder item + `Open in New Window` emit signed Home `open-target` requests for `library`. + Grid, list, and framed selected-name double-clicks are covered so open wins; + rename remains explicit through context menu or F2. Active rename-editor + double-clicks now follow PC2's guard and cannot also open/read the item. + Shift-click range selection, Enter-to-open every selected object, and + Shift-F10/ContextMenu-key selected-object menus are covered in list view so + keyboard and range-selection affordances stay PC2-familiar without bypassing + Runtime provider rails. + Multi-select `Download Selected` is covered through the raw Runtime download + route with repeated selected object URIs, and folder/selection + `Download as ZIP` actions are covered through the same raw route with + `archive=zip`. Provider-backed `Compress to ZIP` and + `Compress Selected to ZIP` are covered through the `compress_archive` + operation and create normal protected Library ZIP objects. `Extract Here` is covered for + `.tar`/`.tar.gz`/`.zip` archives through the provider-owned + `extract_archive` operation. The smoke also covers public-link and + recipient-scoped share dialog flows without browser-native prompts. +- `cargo test --manifest-path elastos/Cargo.toml -p elastos-server gateway_tests::library` + now also covers raw ZIP archive downloads for folders/selections, unsupported + archive-format rejection, provider-created ZIP objects for folders/selections, + WebSpace resolver metadata projection, + recipient-scoped Library share-grant records, and GBA Emulator viewer + discovery for ROM objects. +- `cargo test --manifest-path capsules/webspace-provider/Cargo.toml` covers + WebSpace provider resolver metadata in list/stat responses. +- `node scripts/library-performance-smoke.mjs` +- `env -u ELASTOS_HOME_TOKEN -u ELASTOS_HOME_COOKIE -u ELASTOS_COOKIE -u ELASTOS_HOME_COOKIE_JAR -u ELASTOS_COOKIE_JAR scripts/library-live-smoke.sh` + verifies public Library shell/module deployment and skips the signed provider + path cleanly when no signed browser session is available. +- `ELASTOS_HOME_COOKIE= scripts/library-live-smoke.sh` passed + against the live gateway after the realtime split deployment: Home launch + minted a Library token, then roots, Public write, publish, status, share, + trash, and cleanup all succeeded. Latest smoke CID: + `QmZP7gu9fTs1XVtr3unHK9CzPgMBTyc9sJy24xnqkdCX3W`. +- `node scripts/home-entropy-check.mjs` +- `git diff --check -- capsules/library elastos/crates/elastos-server/src/library.rs elastos/crates/elastos-server/src/api/gateway_tests/library.rs scripts/home-entropy-check.mjs scripts/library-menu-smoke.mjs scripts/library-performance-smoke.mjs TODAY.md TASKS.md docs/FILE_MANAGER_MIGRATION.md` +- `cargo test --manifest-path elastos/Cargo.toml -p elastos-server gateway_tests::library` +- `cargo test --manifest-path elastos/Cargo.toml -p elastos-server test_home_summary_reports_identity_and_launch_targets` +- `cargo test --manifest-path elastos/Cargo.toml -p elastos-server content::tests::content_publish_accepts_carrier_announced_availability` +- `cargo test --manifest-path elastos/Cargo.toml -p elastos-server content_admission -- --nocapture` +- `cargo test --manifest-path elastos/Cargo.toml -p elastos-server federated_abuse_control -- --nocapture` +- `cargo test --manifest-path elastos/Cargo.toml -p elastos-server content_repair_worker -- --nocapture` +- `cargo test --manifest-path elastos/Cargo.toml -p elastos-server content_command_ -- --nocapture` +- `cargo test --manifest-path elastos/Cargo.toml -p elastos-server content_repair_scheduler -- --nocapture` +- `cargo test --manifest-path elastos/Cargo.toml -p elastos-server content_status_ -- --nocapture` +- `cargo test --manifest-path elastos/Cargo.toml -p elastos-server carrier::tests::test_` +- `cargo check --manifest-path elastos/Cargo.toml -p elastos-server` +- `cargo clippy --manifest-path elastos/Cargo.toml -p elastos-server --tests -- -D warnings` +- `bash scripts/check-wci-alignment.sh` +- Live `/apps/library/` serves the split Library shell, `library.css`, and + `src/app.js` from the current branch assets. +- Live gateway restarted from + `/home/wau/.local/share/elastos-public-gateway-live/elastos`; the previous + live pass verified the installed `object-provider` checksum. Rerun live setup + after publishing the canonical `object-provider` asset. +- Live `https://elastos.elacitylabs.com/apps/home/` and + `https://elastos.elacitylabs.com/apps/library/` return 200 from the + installed gateway. The deployed Library shell is the split HTML shell and the + deployed dialog module contains the public-link share receipt path. +- Live `https://elastos.elacitylabs.com/apps/library/library.css`, + `/apps/library/src/app.js`, `/apps/library/src/api.js`, and + `/apps/library/src/menu.js` return 200 from the installed gateway. +- Live `/apps/library/src/app.js` now serves the cached/prefetch build + containing `folderCache`, `folderCacheHits`, and `scheduleRootPrefetch`. +- Live `/apps/library/src/render.js` now serves the native-feel render build + containing `objectNodeCache`, `renderContentChunks`, `LARGE_RENDER_THRESHOLD`, + `INITIAL_RENDER_LIMIT`, and `initialRenderedCount`. +- Live `/apps/library/src/actions.js` now serves the action-boundary build + containing `createLibraryActions`, `publishObject`, `pasteClipboardTo`, + `uploadFiles`, and `copyText`. +- Live `/apps/library/src/editor.js` now serves the editor-boundary build + containing `createLibraryEditor`, `startCreateObject`, `startRename`, and + `startNameEdit`. +- Live `/apps/library/src/events.js` now serves the event-boundary build + containing `bindLibraryEvents`, content/places event handlers, and + drag/drop type detection. +- Live `/apps/library/src/realtime.js` now serves the realtime-boundary build + containing `createLibraryRealtime`, `EventSource` lifecycle, current-folder + event matching, and unload cleanup through an injected stop hook. +- Live `/apps/library/src/state.js` now serves the state/cache-boundary build + containing `createLibraryState`, `visibleObjectsForState`, + `cacheFolderListing`, `MUTATING_PROVIDER_OPS`, and upload render telemetry. +- Live `/apps/library/src/uploads.js` now serves the frame-coalesced upload + progress build containing `scheduleUploadRender`, `uploadRenderCount`, and + `uploadRenderScheduledCount`. +- Live `/apps/library/src/menu.js` and `library.css` now serve the nested + submenu build containing `menu-submenu`, parent-scoped submenu open state, + and left/right viewport flipping. +- Live smoke now checks served `src/app.js`, `src/events.js`, and + `src/render.js` for the active-root, sidebar right-click suppression, and + explicit Spaces empty-state fixes before running signed provider checks. +- Live Library publish backend is restored: the live runtime data root now has + Kubo v0.40.1 at `xdg-data/elastos/bin/kubo`, `ipfs-provider` finds it on + startup, direct IPFS add returns a CID, and live content publish/fetch returns + the published smoke file. +- Exact live Object provider route smoke passes with a synthetic operator + principal: write, publish, status, share, and delete cleanup all succeeded. +- Artificial-delay performance measurement with 1000 files and 80 ms provider + list delay shows prefetched Desktop navigation around 75 ms, cached root + navigation hitting twice, and the remaining large-folder cost dominated by + roughly 60 ms DOM render time. +- `node scripts/library-performance-smoke.mjs` now asserts large folders render + in chunks, root navigation hits the prefetched folder cache, and returning to + a large folder reuses existing item DOM nodes. It also covers upload progress + render coalescing during a provider-backed write. +- `scripts/home-live-smoke.sh` verifies live Home asset versioning, disabled + service-worker registration, cleanup-worker behavior, module availability, + and optional signed summary when a browser session cookie is supplied. + +## Release Plan For Today + +Release objective: publish a clean Library/Explorer-focused release candidate +after the remaining human/operator gates pass. Default version direction is the +next patch/minor release after 0.3.1; choose the exact version only after the +release branch scope is fixed. + +### In Scope + +- `library` app capsule: PC2-familiar Explorer UI, split static shell/modules, + nested context menus, sidebar behavior, grid/list, inline create/rename, + drag/drop, preview/open, upload/download, publish/share/status/repair, + trash/restore/delete, browser Back/Forward takeover, and performance caching. +- `object-provider`: principal-root object authority for files, folders, + Desktop/Documents/Public, revisions, Trash, encrypted protected-root storage, + object events, legacy plaintext auto-protection, and WebSpace resolver + routing/bridge support. +- Home/desktop projection: Home reads Desktop through `object-provider`; Home + owns placement/opening only, uses object-aware keyboard open/context-menu + handling for Desktop files and folders, delegates object actions back into + Library, and service-worker registration remains disabled while the cleanup + worker removes stale development caches. +- Documents viewer handoff: double-click/open for text/markdown-like objects + launches Documents with the concrete Library object, not just the app shell. +- Content availability first slice: publish/share/status uses + `content-provider` and `elastos://content/*`; Kubo/IPFS remains a local + system backend; Carrier announcements and internal fetch descriptors are + infrastructure, not app-visible peer or Kubo authority. Availability receipts + include peer-selection/quota/repair-worker metadata. Availability-provider + `network_available` and `carrier_announced` claims are now validated against + requested replica/quota/live-proof requirements before they can become signed + availability receipts; under-proven claims are recorded as `repair_needed`. + The built-in Carrier availability provider can now turn signed remote + announcements into live remote replica proof by invoking remote + `content/ensure` and `content/status` over the Carrier provider plane, with + a fail-closed `content/import_object` fallback for manifest-backed objects + and `content/import_exact` fallback for file-like exact-CID byte push when + remote pin cannot fetch the object. Repair-only announcements do not + advertise fetch routes and cannot become replication candidates. + Local-only receipts explicitly state that live multi-peer proof is not + present. +- Spaces/WebSpace contract: Library shows `localhost://WebSpaces//...` as a + local mounted resolver view; provider targets such as `google://drive/...` + remain resolver-private; `elastos://content/*` remains the + provider-independent content identity. Read-only resolver mounts expose + open/list/read/download/properties only. Mutable mounts/forks can materialize + local WebSpace objects through provider `write`/`mkdir`/`delete`, persist them + in `objects.json`, and expose `owner-writable` access-policy metadata without + making mounted WebSpace views ordinary principal-root folders. Library object summaries + now carry typed WebSpace resolver metadata + (`elastos.library.webspace-object/v1`) with mount, provider, resolver state, + read-only/access-policy state, and resolved target URI when the resolver + provides one. `webspace-provider` list/stat responses now emit that resolver + metadata directly; persistent mount, index, head, and object tables now allow + resolver-discovered and locally materialized children to survive provider + restarts. +- Viewer handoff now covers installed Documents for text/markdown/PDF and the + installed GBA Emulator for `.gba`, `.gb`, and `.gbc` objects. Library still + does not invent viewer authority; it only lists installed viewer capsules. +- Library Properties, availability status, and share receipts now lead with a + SmartWeb object identity plus provider-owned availability summary instead of + raw backend diagnostics; content IDs remain available as technical/copyable + details. +- Library object identity is split deliberately: every readable local file has + a current immutable raw-byte `content_cid` for its mutable object head, while + public `elastos://` links use `published_cid` only after `content-provider` + publish creates a signed published-content record and availability receipt. + This keeps all files CID-based without treating private local objects as + already-published content. +- Library now has one explicit Public-vs-Published rule: `Public` is placement + metadata under the principal's Library root, while `published_cid` is the + only public content-link truth. Moving or copying into `Public` does not + silently publish; publishing creates the content-provider receipt and public + `elastos://...` link. Published objects appear in `Public` only when the user + also places/projects them there. +- Local mutable storage is still standard Runtime/provider-owned object + storage, not "everything is IPFS." `object-provider` owns private mutable + file/folder bytes and object heads; `content_cid` is the current byte identity + for those private objects; `published_cid` is the separate public + content-provider identity once availability receipts exist. Private files are + SmartWeb object heads, while published files become globally addressable + SmartWeb content records. +- `webspace-provider` now has a persistent adapter registry + (`adapters.json`) plus `adapters`, `register_adapter`, and + `unregister_adapter` provider ops and matching `elastos webspace` CLI + commands. Health now reports configured/connected adapter counts, redacted + adapter endpoint metadata, and per-mount adapter state (`not_registered`, + `configured`, `connected`, `unavailable`, or `disabled`) instead of implying + all external resolvers are anonymous unavailable mounts. +- `webspace-provider` now also has a safe adapter liveness receipt path: + `check_adapter` records `ok`/`failed`/`skipped`/`unknown` checks with + redacted health summaries, stale-check metadata, checked-adapter counts, and + matching `elastos webspace check-adapter` CLI support. This is resolver + readiness and operator health state only; it does not expose credentials or + claim live external byte traversal. +- Library share/status/properties dialogs now surface a remote-access policy + summary: public-link versus recipient-scoped, Runtime recipient-proof + requirement, key-release status, and the current provider gate + (`object-provider shared_access`). Backend share receipts now include + `elastos.library.remote-access-policy/v1` so the UI can explain what is + enforced now and what still needs drm/rights/key/decrypt providers. +- Protected-content provider contracts are explicit and fail closed: + `drm-provider` advertises protected-content open orchestration, + `rights-provider` advertises typed rights-decision authority only, + `key-provider` advertises key-release receipts without raw CEK exposure, and + `decrypt-provider` advertises viewer-scoped decrypt/render sessions without + broad plaintext/filesystem authority. The key-release request contract now + requires an allowed `elastos.rights.decision.receipt/v1` bound to the same + principal/session/object/action, and the decrypt-session contract now requires + a typed `elastos.release.receipt/v1` from `key-provider` bound to that same + principal/session/object/action. Object-provider share/open receipts now + carry `elastos.library.protected-content-provider-requirements/v1` so + recipient-scoped sharing clearly names the drm/rights/key/decrypt chain required + for future encrypted published payloads. +- Object-provider status/share/shared-access responses now also carry + `elastos.library.protected-content-provider-status/v1` provider-chain + readiness. Library status/share dialogs surface that readiness, and the share + dialog shows encrypted-recipient sharing as a disabled fail-closed option + until drm/rights/key/decrypt providers are configured and encrypted publish mode + exists. +- Provider transfer receipts now include `elastos.provider.transfer-abi/v1` + metadata. `ProviderTransfer::Stream` advertises Runtime stream-session mode, + read-next backpressure, live progress events, and cancel support. +- Carrier availability receipts now include `elastos.content.storage-market/v1` + policy metadata. The current mode is `carrier_provider_receipts` with + `settlement: not_configured`, making live multi-peer proof distinct from + production storage-market settlement. +- Library Properties now includes an archive support matrix for archive-like + objects, showing implemented ZIP/tar/tar.gz download/extract behavior and the + generic archive families still dependency/policy gated. +- Object metadata now recognizes generic non-tar/non-zip archive families such + as `.7z`, `.rar`, `.tar.xz`, `.tar.bz2`, `.tar.zst`, `.xz`, `.bz2`, `.zst`, + `.lz4`, and plain `.gz` as policy-gated archives. Library shows them with + archive icon/properties context, advertises the installed `archive-manager` + viewer, and exposes `Archive Support` so users can open safe policy + inspection instead of unsafe extraction. Extraction remains disabled until + dependency and release-policy review is complete. + +### Not In Scope For This Release + +- Frozen Hey Social work. Current branch history contains Hey work, but the + Library release branch should exclude new Hey changes unless explicitly + re-scoped. +- Full PC2 desktop/window/taskbar/socket behavior, app suggestions, thumbnails, + and taskbar integrations. This release ports the Explorer file-management + subset, not the whole PC2 shell. +- Production multi-peer availability and storage markets are not complete in + this release because they require real external production infrastructure. + The release ships Library Explorer UX, the object-provider capsule/API + boundary, provider invocation and streaming, recipient-scoped sharing proof, + Spaces/WebSpace foundation, Archive for enabled families, and + branch-local availability proof/status surfaces. Production dDRM/dKMS remains + a Trusted content/access-rights follow-up track. +- AI Chat, dDRM, Elacity Marketplace, Mac VZ, and new Browser provider work. + +### Release Blockers + +- Worktree/commit hygiene: the Library-related release slices must stay + isolated from frozen Hey files and unrelated dirty work. Do not tag until the + current uncommitted Library/WebSpace/content-provider release changes are + committed as coherent reviewable slices on `review/library-release`. +- Operator gate: rerun and keep passing `node scripts/home-entropy-check.mjs`, + `node scripts/library-menu-smoke.mjs`, `node scripts/library-performance-smoke.mjs`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server gateway_tests::library`, + targeted content/Carrier tests, `cargo check`, `cargo clippy`, `cargo fmt`, + `bash scripts/check-wci-alignment.sh`, and `git diff --check`. +- Live gate: live Home and Library routes return 200, served Library assets + contain the current split-module/WebSpace markers, public + `scripts/library-live-smoke.sh` passes, and signed + `ELASTOS_HOME_COOKIE= scripts/library-live-smoke.sh` passes + when a human session cookie is available. +- Live upload gate: passed on 2026-06-06. Public Library `src/api.js` now calls + only `/api/provider/object/*`, includes chunked large-file upload sessions + (`/api/provider/object/upload/start`, + `/api/provider/object/upload/:upload_id/chunk`, and + `/api/provider/object/upload/:upload_id/finish`), and no longer serves the + retired `/api/provider/library/upload` path. Remaining operator-sensitive + check: the public edge proxy must allow the bounded chunk body size for + `/api/provider/object/upload/:upload_id/chunk`; if a chunk is rejected before + Runtime accepts it, Library must show the explicit + "public gateway body-size limit" message, not raw nginx HTML. +- Human gate: normal Chrome profile signs in after the Home service-worker + cleanup; Library feels native enough in that profile; no folder/view switch + shows stale loading flicker or icon flashing. +- Release notes gate: changelog/release notes must describe the Library, + content availability, Spaces/WebSpace, Home desktop, Documents handoff, and known + non-goals without claiming production third-party WebSpace adapter ecosystem, + production dDRM/dKMS, or production multi-peer storage-market readiness. + +### Release File Scope + +- Include Library/UI assets: `capsules/library/**`, `capsules/object-provider/**`, + Library icons/CSS/modules, `capsules/library/capsule.json`, and the split + shell `capsules/library/index.html`. +- Include Runtime/provider rails directly required by Library: + `elastos/crates/elastos-server/src/library.rs`, + `elastos/crates/elastos-server/src/api/gateway_tests/library.rs`, + Library route wiring in gateway/viewer/provider-proxy files, provider + registry wiring, support-provider test fixtures, and `components.json` + entries for `library` and canonical `object-provider`. +- Include related user-facing integration: Documents viewer object handoff, + Home Desktop projection, Home service-worker cleanup, Library live/perf/menu + smoke scripts, `scripts/home-entropy-check.mjs`, WCI alignment updates, + `scripts/publish-release.sh` support for canonical `object-provider`, and + the Library/WebSpace/content availability docs. +- Include content availability only where it is required for Library + publish/share/status: `content-provider` receipts, internal availability + provider fallback, Carrier content-fetch/announcement support, and tests + proving no app-visible Kubo/IPFS/peer authority. +- Exclude frozen Hey/social files unless explicitly re-scoped: + `capsules/hey-social-rust/**`, `docs/HEY_CAPSULE_MIGRATION.md`, + Hey-specific build tooling from the frozen branch, + `elastos/crates/elastos-common/src/social_protocol.rs`, DID social-discovery + additions, and social-only Carrier/gateway changes. +- Exclude debug/session artifacts unless they are intentionally turned into + durable docs: `.gitignore` Hey WASM wildcard changes and `DEBUG.md` are not + Library release material by default. + +### Latest Gate Status + +- Release worktree exists at + `/home/wau/elastos-runtime-library-release` on branch + `review/library-release`, based on local `main` at `6d4c385`. +- Post Archive closeout gate passed on 2026-06-06 19:48 UTC: + `node scripts/library-menu-smoke.mjs`, `node + scripts/library-performance-smoke.mjs`, `node + scripts/home-entropy-check.mjs`, `bash scripts/check-wci-alignment.sh`, + `git diff --check`, `cargo fmt --manifest-path elastos/Cargo.toml --all + -- --check`, `cargo test --manifest-path elastos/Cargo.toml -p + elastos-server gateway_tests::library -- --nocapture`, `cargo test + --manifest-path elastos/Cargo.toml -p elastos-server archive_entries -- + --nocapture`, `cargo test --manifest-path elastos/Cargo.toml -p + elastos-server selective -- --nocapture`, `cargo test --manifest-path + elastos/Cargo.toml -p elastos-server webspace_archive -- --nocapture`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + content::tests::content_publish_accepts_carrier_announced_availability`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + content_admission -- --nocapture`, `cargo test --manifest-path + elastos/Cargo.toml -p elastos-server federated_abuse_control -- + --nocapture`, `cargo test --manifest-path elastos/Cargo.toml -p + elastos-server content_repair_worker -- --nocapture`, `cargo test + --manifest-path elastos/Cargo.toml -p elastos-server content_command_ -- + --nocapture`, `cargo test --manifest-path elastos/Cargo.toml -p + elastos-server content_repair_scheduler -- --nocapture`, `cargo test + --manifest-path elastos/Cargo.toml -p elastos-server content_status_ -- + --nocapture`, `cargo test --manifest-path elastos/Cargo.toml -p + elastos-server carrier::tests::test_`, `cargo check --manifest-path + elastos/Cargo.toml -p elastos-server`, and `cargo clippy --manifest-path + elastos/Cargo.toml -p elastos-server --tests -- -D warnings`. The + performance smoke was corrected to use the canonical `/api/provider/object/*` + fixture route with no legacy fallback, and Archive UI copy was made + provider-neutral for WCI alignment. +- Post object-provider no-fallback gate passed on 2026-06-05 15:17 UTC in this + worktree after freeing disk space: `cargo fmt --manifest-path + elastos/Cargo.toml --all`, `cargo test --manifest-path elastos/Cargo.toml -p + elastos-server gateway_tests::library`, `cargo test --manifest-path + elastos/Cargo.toml -p elastos-server content_admission -- --nocapture`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + federated_abuse_control -- --nocapture`, `cargo test --manifest-path + elastos/Cargo.toml -p elastos-server content_repair_worker -- --nocapture`, + `cargo test --manifest-path elastos/Cargo.toml -p + elastos-server content_command_ -- --nocapture`, `cargo test + --manifest-path elastos/Cargo.toml -p elastos-server + content_repair_scheduler -- --nocapture`, `cargo test --manifest-path + elastos/Cargo.toml -p elastos-server content_status_ -- --nocapture`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + carrier::tests::test_`, `cargo check --manifest-path elastos/Cargo.toml -p + elastos-server`, and `cargo clippy --manifest-path elastos/Cargo.toml -p + elastos-server --tests -- -D warnings`. +- Latest lightweight gates remain green: `node scripts/home-entropy-check.mjs`, + `bash scripts/check-wci-alignment.sh`, `git diff --check`, JSON/metadata + checks for Runtime and `capsules/object-provider`, and the hard stale-marker + sweep for retired object-provider fallback strings. +- Post signed remote admission receipt gate passed on 2026-06-06 UTC: + `cargo fmt --manifest-path elastos/Cargo.toml -p elastos-server`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + content_admission_ -- --nocapture`, `cargo test --manifest-path + elastos/Cargo.toml -p elastos-server carrier_replication -- --nocapture`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + test_carrier_availability_ensure_proves_remote_replica_via_provider_plane + -- --nocapture`, and `cargo test --manifest-path elastos/Cargo.toml -p + elastos-server + test_carrier_availability_requires_remote_attempt_for_live_proof_when_min_met + -- --nocapture`. +- Post signed-admission policy-surface regression gate passed on 2026-06-06 UTC: + `cargo fmt --manifest-path elastos/Cargo.toml -p elastos-server`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + content_status_without_cid_returns_availability_dashboard -- --nocapture`, and + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + test_carrier_availability_ensure_proves_remote_replica_via_provider_plane -- + --nocapture`. +- Post configured storage-market endpoint-quorum admission gate passed on 2026-06-06 UTC: + `cargo fmt --manifest-path elastos/Cargo.toml --all`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + storage_market_admission -- --nocapture`, `cargo test --manifest-path + elastos/Cargo.toml -p elastos-server content_admission -- --nocapture`, + including + `content_admission_records_configured_storage_market_acceptance`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + content_admission_rejects_when_configured_storage_market_rejects`, + `content_storage_market_admission_accepts_endpoint_quorum`, + `content_storage_market_admission_rejects_endpoint_quorum_failure`, and + `content_storage_market_admission_config`, `cargo test --manifest-path + elastos/Cargo.toml -p elastos-server + content_status_without_cid_returns_availability_dashboard -- --nocapture`, + `cargo check --manifest-path elastos/Cargo.toml -p elastos-server`, + `cargo clippy --manifest-path elastos/Cargo.toml -p elastos-server --tests + -- -D warnings`, `git diff --check`, + `node scripts/home-entropy-check.mjs`, and + `bash scripts/check-wci-alignment.sh`. +- Post configured federated quota-ledger endpoint-quorum exchange gate passed on 2026-06-06 UTC: + `cargo fmt --manifest-path elastos/Cargo.toml --all`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + federated_quota_ledger -- --nocapture`, `cargo test --manifest-path + elastos/Cargo.toml -p elastos-server content_admission -- --nocapture`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + content_status_without_cid_returns_availability_dashboard -- --nocapture`, + `cargo check --manifest-path elastos/Cargo.toml -p elastos-server`, + `cargo clippy --manifest-path elastos/Cargo.toml -p elastos-server --tests + -- -D warnings`, `git diff --check`, `node scripts/home-entropy-check.mjs`, + and `bash scripts/check-wci-alignment.sh`. +- Post configured external repair-fleet dispatch gate passed on 2026-06-06 UTC: + `cargo fmt --manifest-path elastos/Cargo.toml -p elastos-server`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + content_repair_worker_dispatches_configured_external_repair_fleet -- --nocapture`, + and `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + content_external_repair_fleet_config -- --nocapture`. +- Post provider-local operator-alert sink gate passed on 2026-06-06 UTC: + `cargo fmt --manifest-path elastos/Cargo.toml -p elastos-server`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + content_status_delivers_operator_alert_to_configured_loopback_sink -- --nocapture`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + content_status_without_cid_returns_availability_dashboard -- --nocapture`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + content_status_can_emit_operator_alert_receipt_without_sink -- --nocapture`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + content_operator_alert_sink_config -- --nocapture`, `cargo check + --manifest-path elastos/Cargo.toml -p elastos-server`, and `cargo clippy + --manifest-path elastos/Cargo.toml -p elastos-server --tests -- + -D warnings`. +- Post bounded-product-slice gate passed on 2026-06-05 15:46 UTC: + `cargo test --manifest-path capsules/webspace-provider/Cargo.toml`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-runtime + provider::registry::tests::test_provider_invocation_stream_normalizes_range_progress_transfer_receipt`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + content_fetch_stream_returns_provider_stream_payload`, `cargo test + --manifest-path elastos/Cargo.toml -p elastos-server + test_carrier_availability_ensure_proves_remote_replica_via_provider_plane`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + test_library_provider_records_recipient_scoped_share_grants`, `cargo test + --manifest-path elastos/Cargo.toml -p elastos-server + test_library_provider_rejects_key_release_policy_until_provider_exists`, + `cargo check --manifest-path elastos/Cargo.toml -p elastos-server`, + `cargo clippy --manifest-path elastos/Cargo.toml -p elastos-server --tests + -- -D warnings`, `node --check capsules/library/src/dialog.js`, `node + scripts/home-entropy-check.mjs`, `bash scripts/check-wci-alignment.sh`, and + `git diff --check`. +- Post protected-content/archive-policy gate passed on 2026-06-05 16:09 UTC: + `cargo test --manifest-path capsules/rights-provider/Cargo.toml`, + `cargo test --manifest-path capsules/key-provider/Cargo.toml`, + `cargo test --manifest-path capsules/decrypt-provider/Cargo.toml`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + test_library_provider_records_recipient_scoped_share_grants`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + test_library_provider_marks_generic_archive_families_policy_gated`, + `node --check capsules/library/src/dialog.js`, + `node --check capsules/library/src/model.js`, + `cargo check --manifest-path elastos/Cargo.toml -p elastos-server`, and + `cargo clippy --manifest-path elastos/Cargo.toml -p elastos-server --tests + -- -D warnings`. +- Post protected-content provider-readiness UX gate passed on 2026-06-05 + 17:10 UTC: `cargo fmt --manifest-path elastos/Cargo.toml --all`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + test_library_provider_records_recipient_scoped_share_grants`, `node --check + capsules/library/src/dialog.js`, `node scripts/home-entropy-check.mjs`, + `bash scripts/check-wci-alignment.sh`, `git diff --check`, + `cargo check --manifest-path elastos/Cargo.toml -p elastos-server`, and + `cargo clippy --manifest-path elastos/Cargo.toml -p elastos-server --tests + -- -D warnings`. +- Post protected-content DRM-chain correction gate passed on 2026-06-05 + 17:10 UTC: `cargo fmt --manifest-path elastos/Cargo.toml --all`, + `cargo test --manifest-path capsules/drm-provider/Cargo.toml`, + `bash scripts/protected-content-provider-contract-smoke.sh`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + test_library_provider_records_recipient_scoped_share_grants`, `cargo test + --manifest-path elastos/Cargo.toml -p elastos-server + test_library_provider_rejects_key_release_policy_until_provider_exists`, + `node --check capsules/library/src/dialog.js`, + `node scripts/home-entropy-check.mjs`, + `bash scripts/check-wci-alignment.sh`, `git diff --check`, + `cargo check --manifest-path elastos/Cargo.toml -p elastos-server`, and + `cargo clippy --manifest-path elastos/Cargo.toml -p elastos-server --tests + -- -D warnings`. +- Post protected recipient receipt-chain gate passed on 2026-06-05 + 21:55 UTC: `cargo fmt --manifest-path elastos/Cargo.toml --all`, + `cargo test --manifest-path capsules/drm-provider/Cargo.toml`, + `cargo test --manifest-path capsules/rights-provider/Cargo.toml`, + `cargo test --manifest-path capsules/key-provider/Cargo.toml`, + `cargo test --manifest-path capsules/decrypt-provider/Cargo.toml`, + `bash scripts/protected-content-provider-contract-smoke.sh`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + test_library_provider_runs_protected_content_receipt_chain_for_recipient + -- --nocapture`, `cargo test --manifest-path elastos/Cargo.toml -p + elastos-server + test_library_protected_shared_access_fails_closed_without_providers + -- --nocapture`, and `cargo test --manifest-path elastos/Cargo.toml -p + elastos-server gateway_tests::library -- --nocapture`, `cargo check + --manifest-path elastos/Cargo.toml -p elastos-server`, `cargo clippy + --manifest-path elastos/Cargo.toml -p elastos-server --tests -- -D + warnings`, `node scripts/home-entropy-check.mjs`, `bash + scripts/check-wci-alignment.sh`, and `git diff --check` over the touched + Library/protected-content/docs files. +- Post WebSpace adapter-health gate passed on 2026-06-05 17:10 UTC: + `cargo fmt --manifest-path capsules/webspace-provider/Cargo.toml`, + `cargo test --manifest-path capsules/webspace-provider/Cargo.toml`, + `cargo check --manifest-path elastos/Cargo.toml -p elastos-server`, and + `cargo clippy --manifest-path elastos/Cargo.toml -p elastos-server --tests + -- -D warnings`. +- Post archive-support UX gate passed on 2026-06-05 17:10 UTC: + `node --check capsules/library/src/app.js`, `node --check + capsules/library/src/dialog.js`, plus stale protected-content wording and + archive-support scans. +- Post WebSpace mutable resolver-sync gate passed on 2026-06-05 19:41 UTC: + `cargo fmt --manifest-path elastos/Cargo.toml --all`, `cargo test + --manifest-path elastos/Cargo.toml -p elastos-server + test_library_gateway_syncs_operator_mutable_webspace_file_to_resolver`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + test_library_gateway_webspace_sync`, `cargo test --manifest-path + elastos/Cargo.toml -p elastos-server + test_library_gateway_mutates_writable_webspace_through_runtime_provider`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + gateway_tests::library`, `cargo check --manifest-path elastos/Cargo.toml -p + elastos-server`, `node scripts/home-entropy-check.mjs`, and `bash + scripts/check-wci-alignment.sh`. +- Post WebSpace resolver availability-hint gate passed on 2026-06-05 20:08 + UTC: `cargo fmt --manifest-path elastos/Cargo.toml --all`, `cargo test + --manifest-path elastos/Cargo.toml -p elastos-server + test_library_gateway_webspace_sync`, `cargo test --manifest-path + elastos/Cargo.toml -p elastos-server + test_library_gateway_syncs_operator_mutable_webspace_file_to_resolver`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + gateway_tests::library`, `cargo check --manifest-path elastos/Cargo.toml -p + elastos-server`, `node scripts/home-entropy-check.mjs`, and `bash + scripts/check-wci-alignment.sh`. +- Post installed operator WebSpace adapter gate passed on 2026-06-05 20:16 + UTC: `cargo fmt --manifest-path capsules/operator-drive-adapter/Cargo.toml`, + `cargo test --manifest-path capsules/operator-drive-adapter/Cargo.toml`, + `cargo clippy --manifest-path capsules/operator-drive-adapter/Cargo.toml + --tests -- -D warnings`, + `cargo check --manifest-path elastos/Cargo.toml -p elastos-server`, `cargo + test --manifest-path elastos/Cargo.toml -p elastos-server + test_library_gateway_webspace_sync`, `cargo test --manifest-path + elastos/Cargo.toml -p elastos-server + test_library_gateway_syncs_operator_mutable_webspace_file_to_resolver`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + gateway_tests::library`, `node scripts/home-entropy-check.mjs`, `bash + scripts/check-wci-alignment.sh`, and `git diff --check`. +- Post operator WebSpace endpoint-backend gate passed on 2026-06-05 20:24 UTC: + `cargo fmt --manifest-path capsules/operator-drive-adapter/Cargo.toml`, + `cargo test --manifest-path capsules/operator-drive-adapter/Cargo.toml`, + `cargo clippy --manifest-path capsules/operator-drive-adapter/Cargo.toml + --tests -- -D warnings`, `cargo fmt --manifest-path elastos/Cargo.toml + --all`, `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + operator_drive_adapter_config_prefers_explicit_json`, `cargo test + --manifest-path elastos/Cargo.toml -p elastos-server gateway_tests::library`, + `cargo check --manifest-path elastos/Cargo.toml -p elastos-server`, `cargo + clippy --manifest-path elastos/Cargo.toml -p elastos-server --tests -- -D + warnings`, `node scripts/home-entropy-check.mjs`, `bash + scripts/check-wci-alignment.sh`, and `git diff --check`. +- Post WebSpace and provider-streaming closure gate passed on 2026-06-05 21:03 UTC: + `cargo fmt --manifest-path capsules/operator-drive-adapter/Cargo.toml`, + `cargo fmt --manifest-path elastos/Cargo.toml --all`, `cargo test + --manifest-path capsules/operator-drive-adapter/Cargo.toml`, `cargo clippy + --manifest-path capsules/operator-drive-adapter/Cargo.toml --tests -- + -D warnings`, `cargo test --manifest-path elastos/Cargo.toml -p + elastos-server gateway_tests::library`, `cargo test --manifest-path + elastos/Cargo.toml -p elastos-runtime provider::registry::tests::test_provider_`, + `cargo test --manifest-path elastos/Cargo.toml -p elastos-server + operator_drive_adapter_config_prefers_explicit_json`, `cargo test + --manifest-path elastos/Cargo.toml -p elastos-server + content_fetch_stream_returns_provider_stream_payload`, `cargo test + --manifest-path elastos/Cargo.toml -p elastos-server + content_fetch_stream_ranges_availability_provider_when_local_backend_misses`, + `cargo check --manifest-path elastos/Cargo.toml -p elastos-runtime`, `cargo + check --manifest-path elastos/Cargo.toml -p elastos-server`, `cargo clippy + --manifest-path elastos/Cargo.toml -p elastos-runtime --tests -- -D + warnings`, `cargo clippy --manifest-path elastos/Cargo.toml -p + elastos-server --tests -- -D warnings`, `node + scripts/home-entropy-check.mjs`, `bash scripts/check-wci-alignment.sh`, and + `git diff --check`. +- Release-scope entropy check: no Hey/social implementation files or symbols + remain in the clean release branch's modified runtime/test surface. Hey is + mentioned only as an explicit frozen/excluded scope note. +- Latest bounded deferrals closed: selected-object archive download, + provider-owned `.tar.gz`/`.zip` folder and selected-object archive downloads, + provider-owned same-folder `.zip` object creation for single files, folders, + and selected objects, + provider-owned `.tar.gz`/`.tgz` extraction, provider-owned plain `.tar` + extraction, provider-owned `.zip` extraction, and archive MIME classification. + ZIP extraction dependency review is scoped to stable non-yanked `zip 2.4.2` + with default features disabled and only the flate2-backed deflate path. + Generic non-tar/non-zip archive extraction and import policy beyond current + safe `.tar`/`.zip` extraction remain deferred. Archive now presents a + simplified Archive flow: browse/search entries, preview safe files, + extract selected/all files into Library, and keep release-policy/dDRM details + collapsed behind secondary safety copy. Direct Archive launch routes users to + Library for the two real journeys: open an existing archive or create a new + ZIP archive from Library objects. WebSpace mutable resolver sync is now + fixture-proven with adapter + write-back, no-adapter fail-closed receipts, conflict receipts, and + resolver-scope availability hints. The fixture contract is now also promoted + into an installed `operator-drive-adapter` provider package with Runtime + startup registration, release/build metadata, Runtime-only invocation + enforcement, deterministic provider-owned local bytes, read-only/conflict + policy, operator-private endpoint backend traversal/read/write, Runtime + config loading, redacted endpoint status/receipts, and no + credential/raw-backend exposure to apps. Unsupported archive families are now + detected and labeled as policy-gated archives instead of being hidden as + ordinary files. +- Latest product-deferral foundations closed: persistent WebSpace mount table + and CLI, persistent WebSpace adapter registry and CLI, provider-owned + WebSpace object heads/health/refresh/cache/sync/fork receipts, fake-adapter + WebSpace `metadata_index`/`read_bytes` invocation contracts, clean + non-dirty adapter byte-cache materialization in `webspace-provider`, Library + Runtime reads that invoke connected resolver adapters through + provider-to-provider `ProviderInvocation`, Runtime provider-to-provider + invocation envelope with bounded byte range/progress handling, + target-visible capability metadata, transfer ABI receipts, and + transport-bearing transfer receipts including Carrier `provider_invoke`, + `content-provider` fetch propagation of provider range/progress/stream + transfer receipts, + recipient-scoped Library `shared_access` with + access-decision/shared-open/remote-access-policy receipts and denied-access + audit, ZIP folder/selection archive download, + ZIP extraction plus unsafe-entry fail-closed coverage, and availability + receipt metadata plus requirements enforcement for peer-selection/quota/ + repair-worker/storage-market claims plus durable content repair-task + ledger/worker pass. + Network availability claims now also fail closed unless peer-selection + metadata names a concrete mode or strategy. +- Post WebSpace adapter-byte cache gate passed on 2026-06-05: `cargo test + --manifest-path capsules/webspace-provider/Cargo.toml + cache_handle_materializes_adapter_bytes_without_dirty_sync_debt` and `cargo + test --manifest-path elastos/Cargo.toml -p elastos-server + test_library_gateway_reads_external_webspace_file_through_adapter_cache`. +- Post operator WebSpace fixture/viewer gate passed on 2026-06-05: `cargo test + --manifest-path elastos/Cargo.toml -p elastos-server + test_library_gateway_operator_webspace_adapter_caches_bytes_and_viewer`. +- Post WebSpace sync byte-cache gate passed on 2026-06-05: `cargo test + --manifest-path elastos/Cargo.toml -p elastos-server + test_library_gateway_webspace_sync_caches_adapter_bytes_without_foreground_read`. +- Live public Library assets pass `scripts/library-live-smoke.sh`. +- Post live object-provider deployment gate passed on 2026-06-06: + `https://elastos.elacitylabs.com/api/provider/object/roots` now returns + `403 missing home launch token` instead of `404 Gateway provider not found`, + proving the public gateway has the canonical object-provider route. Public + Library assets now serve the current chunked upload client + (`CHUNKED_UPLOAD_TRANSPORT = "http-chunk-session"`, + `/api/provider/object/upload/start`) and no longer serve the retired + `/api/provider/library/upload` path. Public Properties assets now include the + PC2-style `window-item-properties`, `item-props-tabview`, `Content ID`, and + `Published CID` markers. The live process tree now has `object-provider` and + no `library-provider`. +- Signed Home live smoke passes with a current Home session: live Home shell, + module graph, cleanup service worker, and signed summary verified. Latest + Home asset version: `home-20260607c`. +- Signed live Library smoke passes with a current Home session: Home launch + minted a Library token, then roots, Public write/upload, provider-owned plain + `.tar` extraction, publish, status, share, and cleanup all succeeded. Latest + smoke CID: `QmW8h4rLgBvCMwxMGuVrEUagjJRkiny98h9Rk7YK6cbGCT`. +- Archive now uses a real Library selector handoff instead of duplicate + generic "open Library" buttons. Archive opens Library in `archive-open` or + `archive-create` mode, Library performs the authorized object-provider + select/compress operation, and Home allows Library to deliver only the + selected/created archive object back to Archive or published attachments back + to Chat Room. Archive remains a viewer capsule and does not claim direct + storage authority. +- Still needs final human proof before release: normal Chrome profile retest and + perceived Library speed/native-feel pass on + `https://elastos.elacitylabs.com/apps/home/`. This is intentionally left for + the final manual testing pass. +- Current git hygiene status: branch `review/library-release` has the coherent + Library release commit stack from `43a0b77` through the current branch head, + but the worktree still contains uncommitted Library/WebSpace/content-provider + release changes. Tag/release should happen only after those changes are + committed, the same gates rerun, and the final human pass succeeds. + +### Human Test Checklist + +- Sign into `https://elastos.elacitylabs.com/apps/home/` with the existing + passkey in a normal Chrome profile. +- Open Library from Home; verify Home, Desktop, Documents, Public, and + Spaces sidebar selection is correct and no browser-native context menu + appears on blank sidebar chrome. +- Create a folder on Desktop from Library and confirm it appears immediately in + both Library Desktop and Home Desktop. +- Double-click folders and files, including already-selected names. Folders + navigate, and text/markdown files open Documents with the concrete object + instead of entering inline rename or only opening the Documents app shell. + While rename is active, double-clicking the editor must not open the object. +- Right-click files/folders/sidebar places and verify only working menu items + are visible; `Open`, `Open in New Window`, `Open With`, `Sort By`, `View`, + `New`, Properties, publish/share/status/repair, trash/restore/delete, and + rename behave as expected for the selected object/root. +- In list view, Shift-click selects a visible range, Enter opens every selected + object through the same viewer/provider path as double-click, and Shift-F10 or + the ContextMenu key opens the selected-object menu. +- Upload, download, folder/selection `Download as ZIP`, selected-object archive download, + provider-backed `Compress to ZIP`, provider-backed `Compress Selected to ZIP`, + `.tar`/`.tar.gz`/`.zip` extraction, + rename, move, copy, drag/drop, create text document, move to Trash, restore, + and permanent delete from the appropriate roots. +- Open Archive from Home with no object selected. Verify "Open archive from + Library" opens Library with an `Open in Archive` picker action, and + double-clicking a supported archive returns the same Archive window to the + archive file list. Verify "Create ZIP from Library" opens Library with a + `Create ZIP` picker action, creates the ZIP through object-provider, and + returns the new archive to Archive. +- Upload a large video-sized file. Library must use Runtime chunked upload + sessions and commit through object-provider at `finish`; it must not send the + whole file as one public `PUT`. If the operator keeps the public gateway chunk + limit below the Runtime chunk size, Library must show the explicit + `public gateway body-size limit` error instead of raw nginx HTML. +- Publish from Public, copy/share the published link, check status, repair, and + unpublish. Published badges must appear in grid and list views without + awkward icon overlays. +- Visit Spaces. Read-only mounts should be useful as mounted resolver + surfaces with clear copy, no mutable actions, no raw `google://`, Kubo/IPFS, + Carrier peer, or host authority exposed to the app. Mutable mounts/forks + should expose New/Upload/Delete only when `metadata.readonly === false`. +- Use browser Back/Forward inside Library and confirm it navigates Library + history rather than leaving Home or fighting the shell. + +### Deferrals To Keep In TASKS.md + +- Keep only canonical remaining product-track work in TASKS.md: production + multi-peer/storage-market infrastructure, specific future generic archive + dependency approvals, and production dDRM/dKMS under the Trusted + content/access-rights section. Do not keep duplicate numbered gap wording. +- Do not describe the object-provider work as fully extracted. This branch has + the standalone `object-provider` capsule/API boundary, `object` Runtime + scheme, package/profile routing, and fail-closed standalone wrapper tests. + The pure object-provider core still lives in `elastos-server::library`; + extracting it into a smaller core crate is architecture/build-review cleanup, + not a user-facing behavior fix. +- Do not track provider-to-provider Carrier invocation as a missing baseline. + Runtime-native stream sessions are complete for this branch; future + storage-market execution belongs to production infrastructure work. +- Start the next PC2 slices only after this Library release is clean: AI Chat, + then dDRM and Elacity Marketplace. + +### Remaining Execution Order + +1. Complete the human Chrome-profile checklist above on + `https://elastos.elacitylabs.com/apps/home/`. +2. Choose the coordinated release version intentionally after confirming the + already-published `0.3.1` baseline. The version policy accepts dotted + prereleases such as `0.3.2-rc.1` and rejects compact forms such as + `0.3.2-rc1`. +3. Publish from `review/library-release` only with an operator release key: + `scripts/publish-release.sh --version --key `. + If the human pass fails, fix only the failing release blocker and rerun the + same gates before publishing. diff --git a/capsules/archive-manager/capsule.json b/capsules/archive-manager/capsule.json new file mode 100644 index 00000000..9cc21cc5 --- /dev/null +++ b/capsules/archive-manager/capsule.json @@ -0,0 +1,14 @@ +{ + "schema": "elastos.capsule/v1", + "name": "archive-manager", + "version": "0.1.0", + "description": "Browse and extract supported archive contents through Library", + "role": "viewer", + "type": "data", + "author": "elastos", + "entrypoint": "index.html", + "resources": { + "memory_mb": 24, + "gpu": false + } +} diff --git a/capsules/archive-manager/index.html b/capsules/archive-manager/index.html new file mode 100644 index 00000000..c61e1043 --- /dev/null +++ b/capsules/archive-manager/index.html @@ -0,0 +1,1212 @@ + + + + + + Archive - ElastOS + + + + + +
+
+
+

Archive

+
+
+ + + +
+
+ +
+
+
+
+ Files +
+
+ + + Not loaded +
+
+
+ +
+

Archive files load when this format is supported.

+
+
+
+ + +
+
+ + + + diff --git a/capsules/availability-provider/src/main.rs b/capsules/availability-provider/src/main.rs index 14e61cc0..1e51cd04 100644 --- a/capsules/availability-provider/src/main.rs +++ b/capsules/availability-provider/src/main.rs @@ -36,6 +36,8 @@ struct EnsureRequest { #[serde(default)] local: Value, #[serde(default)] + requirements: Value, + #[serde(default)] object_did: Option, #[serde(default)] publisher_did: Option, @@ -58,6 +60,13 @@ struct AvailabilityTarget { headers: BTreeMap, } +#[derive(Debug, Clone, Copy)] +struct TargetAvailabilityRequirements { + min_replicas: u64, + max_replicas: Option, + require_live_multi_peer_proof: bool, +} + #[derive(Debug, Serialize)] #[serde(tag = "status", rename_all = "snake_case")] enum Response { @@ -138,21 +147,52 @@ impl AvailabilityProvider { })); } - let mut last_error = None; + let requirements = TargetAvailabilityRequirements::from_value(&request.requirements); + let mut network_available = Vec::new(); + let mut repair_needed_reports = Vec::new(); + let mut errors = Vec::new(); for target in &self.targets { match self.ensure_target(target, &request) { Ok(availability) => { - return Response::ok(json!({ "availability": availability })); + if availability_status(&availability) == Some("network_available") { + network_available.push(availability); + if let Some(availability) = availability_for_requirements( + &request, + requirements, + &network_available, + ) { + return Response::ok(json!({ "availability": availability })); + } + } else { + repair_needed_reports.push(availability); + } } - Err(err) => last_error = Some(format!("{}: {}", target.id, err)), + Err(err) => errors.push(format!("{}: {}", target.id, err)), } } + if !network_available.is_empty() { + return Response::ok(json!({ + "availability": repair_needed( + "availability-provider", + &request, + insufficient_target_reason(requirements, &network_available, &errors), + ) + })); + } + + if let Some(availability) = repair_needed_reports.into_iter().next() { + return Response::ok(json!({ "availability": availability })); + } + Response::ok(json!({ "availability": repair_needed( &self.targets[0].id, &request, - last_error.unwrap_or_else(|| "availability target failed".to_string()), + errors + .last() + .cloned() + .unwrap_or_else(|| "availability target failed".to_string()), ) })) } @@ -269,6 +309,26 @@ fn validate_header_value(target_id: &str, name: &str, value: &str) -> Result<(), Ok(()) } +impl TargetAvailabilityRequirements { + fn from_value(value: &Value) -> Self { + Self { + min_replicas: value + .get("min_replicas") + .and_then(Value::as_u64) + .unwrap_or(1) + .max(1), + max_replicas: value + .get("max_replicas") + .and_then(Value::as_u64) + .filter(|value| *value > 0), + require_live_multi_peer_proof: value + .get("require_live_multi_peer_proof") + .and_then(Value::as_bool) + .unwrap_or(false), + } + } +} + fn normalize_upstream_availability( target: &AvailabilityTarget, request: &EnsureRequest, @@ -302,6 +362,12 @@ fn normalize_upstream_availability( .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or(&request.policy); + let peer_selection = upstream_peer_selection(target, availability, replicas)?; + let quota = upstream_quota(target, availability, request); + let repair_worker = upstream_repair_worker(availability); + let storage_market = upstream_storage_market(target, availability); + let repair_graph = upstream_repair_graph(target, availability); + let abuse_controls = upstream_abuse_controls(target, availability); match status { "network_available" if replicas > 0 => Ok(json!({ @@ -309,6 +375,12 @@ fn normalize_upstream_availability( "provider": provider, "policy": policy, "replicas": replicas, + "peer_selection": peer_selection, + "quota": quota, + "repair_worker": repair_worker, + "storage_market": storage_market, + "repair_graph": repair_graph, + "abuse_controls": abuse_controls, })), "network_available" => Err("network_available requires replicas > 0".to_string()), "repair_needed" => Ok(json!({ @@ -317,11 +389,311 @@ fn normalize_upstream_availability( "policy": policy, "replicas": replicas, "reason": availability.get("reason").and_then(Value::as_str).unwrap_or("availability target requested repair"), + "peer_selection": peer_selection, + "quota": quota, + "repair_worker": repair_worker, + "storage_market": storage_market, + "repair_graph": repair_graph, + "abuse_controls": abuse_controls, })), other => Err(format!("unsupported availability status: {other}")), } } +fn availability_for_requirements( + request: &EnsureRequest, + requirements: TargetAvailabilityRequirements, + reports: &[Value], +) -> Option { + let first = reports.first()?; + if reports.len() == 1 && target_report_satisfies_requirements(first, requirements) { + return Some(first.clone()); + } + let aggregate = aggregate_target_availability(request, reports); + if target_report_satisfies_requirements(&aggregate, requirements) { + Some(aggregate) + } else { + None + } +} + +fn target_report_satisfies_requirements( + availability: &Value, + requirements: TargetAvailabilityRequirements, +) -> bool { + if availability_status(availability) != Some("network_available") { + return false; + } + let replicas = availability_replicas(availability); + if replicas < requirements.min_replicas { + return false; + } + if let Some(max_replicas) = requirements.max_replicas { + if replicas > max_replicas { + return false; + } + } + if replicas > 1 && !availability_live_multi_peer_proof(availability) { + return false; + } + if requirements.require_live_multi_peer_proof + && !availability_live_multi_peer_proof(availability) + { + return false; + } + true +} + +fn aggregate_target_availability(request: &EnsureRequest, reports: &[Value]) -> Value { + let replicas = reports.iter().map(availability_replicas).sum::(); + let live_multi_peer_proof = + reports.len() > 1 || reports.iter().any(availability_live_multi_peer_proof); + let target_reports = reports + .iter() + .map(target_report_summary) + .collect::>(); + json!({ + "status": "network_available", + "provider": "availability-provider", + "policy": request.policy, + "replicas": replicas, + "peer_selection": { + "mode": "configured_availability_target_fanout", + "strategy": "target_fanout", + "target_count": reports.len(), + "live_multi_peer_proof": live_multi_peer_proof, + "targets": target_reports, + }, + "quota": { + "policy": "configured_availability_target_fanout", + "target_count": reports.len(), + "requirements": request.requirements.clone(), + }, + "repair_worker": { + "scheduled": false, + "status": "healthy", + "worker": "availability-provider", + }, + "storage_market": { + "schema": "elastos.content.storage-market/v1", + "mode": "configured_availability_target_fanout", + "status": "target_reports_no_market_settlement", + "settlement": "not_configured", + "escrow": "not_configured", + "quota_enforced": false, + "target_count": reports.len(), + }, + "repair_graph": { + "schema": "elastos.content.repair-graph/v1", + "policy": "configured_availability_target_fanout", + "status": "target_reports_only", + "supported": ["target_report"], + "target_count": reports.len(), + }, + "abuse_controls": { + "schema": "elastos.content.abuse-controls/v1", + "policy": "configured_availability_target_fanout", + "enforced": reports.iter().any(|report| { + report.get("abuse_controls") + .and_then(|value| value.get("enforced")) + .and_then(Value::as_bool) + .unwrap_or(false) + }), + "throttled": reports.iter().any(|report| { + report.get("abuse_controls") + .and_then(|value| value.get("throttled")) + .and_then(Value::as_bool) + .unwrap_or(false) + }), + "target_count": reports.len(), + }, + }) +} + +fn target_report_summary(report: &Value) -> Value { + json!({ + "provider": report.get("provider").cloned().unwrap_or(Value::Null), + "policy": report.get("policy").cloned().unwrap_or(Value::Null), + "status": report.get("status").cloned().unwrap_or(Value::Null), + "replicas": availability_replicas(report), + "peer_selection": report.get("peer_selection").cloned().unwrap_or(Value::Null), + "quota": report.get("quota").cloned().unwrap_or(Value::Null), + "repair_worker": report.get("repair_worker").cloned().unwrap_or(Value::Null), + "storage_market": report.get("storage_market").cloned().unwrap_or(Value::Null), + "repair_graph": report.get("repair_graph").cloned().unwrap_or(Value::Null), + "abuse_controls": report.get("abuse_controls").cloned().unwrap_or(Value::Null), + }) +} + +fn insufficient_target_reason( + requirements: TargetAvailabilityRequirements, + reports: &[Value], + errors: &[String], +) -> String { + let replicas = reports.iter().map(availability_replicas).sum::(); + let live_multi_peer_proof = + reports.len() > 1 || reports.iter().any(availability_live_multi_peer_proof); + let mut reason = + format!("configured availability targets reported {replicas} replicas below requirements"); + if replicas < requirements.min_replicas { + reason = format!( + "configured availability targets reported {replicas} replicas below required {}", + requirements.min_replicas + ); + } else if let Some(max_replicas) = requirements.max_replicas { + if replicas > max_replicas { + reason = format!( + "configured availability targets reported {replicas} replicas above quota {max_replicas}" + ); + } + } + if requirements.require_live_multi_peer_proof && !live_multi_peer_proof { + reason.push_str(" and no live multi-peer proof"); + } + if let Some(last_error) = errors.last() { + reason.push_str("; last target error: "); + reason.push_str(last_error); + } + reason +} + +fn availability_status(availability: &Value) -> Option<&str> { + availability.get("status").and_then(Value::as_str) +} + +fn availability_replicas(availability: &Value) -> u64 { + availability + .get("replicas") + .and_then(Value::as_u64) + .unwrap_or(0) +} + +fn availability_live_multi_peer_proof(availability: &Value) -> bool { + availability + .get("peer_selection") + .and_then(|value| value.get("live_multi_peer_proof")) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn upstream_peer_selection( + target: &AvailabilityTarget, + availability: &Value, + replicas: u64, +) -> Result { + if let Some(peer_selection) = availability + .get("peer_selection") + .filter(|value| value.is_object()) + .cloned() + { + let live_multi_peer_proof = peer_selection + .get("live_multi_peer_proof") + .and_then(Value::as_bool) + .unwrap_or(false); + if replicas > 1 && !live_multi_peer_proof { + return Err( + "multi-replica availability target response requires live_multi_peer_proof=true" + .to_string(), + ); + } + return Ok(peer_selection); + } + if replicas > 1 { + return Err( + "multi-replica availability target response requires peer_selection metadata" + .to_string(), + ); + } + Ok(json!({ + "mode": "configured_availability_target", + "strategy": "target_report", + "target_id": target.id, + "live_multi_peer_proof": false, + })) +} + +fn upstream_quota( + target: &AvailabilityTarget, + availability: &Value, + request: &EnsureRequest, +) -> Value { + availability + .get("quota") + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(|| { + json!({ + "policy": "configured_availability_target", + "target_id": target.id, + "requirements": request.requirements.clone(), + }) + }) +} + +fn upstream_repair_worker(availability: &Value) -> Value { + availability + .get("repair_worker") + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(|| { + json!({ + "scheduled": false, + "status": "healthy", + "worker": "availability-provider", + }) + }) +} + +fn upstream_storage_market(target: &AvailabilityTarget, availability: &Value) -> Value { + availability + .get("storage_market") + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(|| { + json!({ + "schema": "elastos.content.storage-market/v1", + "mode": "configured_availability_target", + "status": "target_report_no_market_settlement", + "target_id": target.id, + "settlement": "not_configured", + "escrow": "not_configured", + "quota_enforced": false, + }) + }) +} + +fn upstream_repair_graph(target: &AvailabilityTarget, availability: &Value) -> Value { + availability + .get("repair_graph") + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(|| { + json!({ + "schema": "elastos.content.repair-graph/v1", + "policy": "configured_availability_target", + "status": "target_report_only", + "target_id": target.id, + "supported": ["target_report"], + }) + }) +} + +fn upstream_abuse_controls(target: &AvailabilityTarget, availability: &Value) -> Value { + availability + .get("abuse_controls") + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(|| { + json!({ + "schema": "elastos.content.abuse-controls/v1", + "policy": "configured_availability_target", + "target_id": target.id, + "enforced": false, + "throttled": false, + }) + }) +} + fn repair_needed(provider: &str, request: &EnsureRequest, reason: impl Into) -> Value { json!({ "status": "repair_needed", @@ -329,6 +701,40 @@ fn repair_needed(provider: &str, request: &EnsureRequest, reason: impl Into version, + None => concat!(env!("CARGO_PKG_VERSION"), "-dev"), +}; + +#[derive(Debug, Deserialize)] +struct CoordFile { + kubo_pid: u32, + api_port: u16, + gateway_port: u16, + started_at: u64, + last_used: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] +enum Request { + Init { + #[serde(default)] + config: Value, + }, + ExportGraph(GraphRequest), + ImportGraph(ImportGraphRequest), + Status { + #[serde(default)] + _runtime_invocation: Option, + #[serde(default)] + _runtime_transfer: Option, + }, + Shutdown, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(dead_code)] +struct GraphRequest { + cid: String, + #[serde(default)] + schema: Option, + #[serde(default)] + repair_graph_kind: Option, + #[serde(default)] + availability_requirements: Value, + #[serde(default)] + policy: Option, + #[serde(default)] + object_did: Option, + #[serde(default)] + publisher_did: Option, + #[serde(default)] + _runtime_invocation: Option, + #[serde(default)] + _runtime_transfer: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +#[allow(dead_code)] +struct ImportGraphRequest { + cid: String, + graph: Value, + #[serde(default)] + availability_policy: Option, + #[serde(default)] + availability_requirements: Value, + #[serde(default)] + ensure_failure: Option, + #[serde(default)] + object_did: Option, + #[serde(default)] + publisher_did: Option, + #[serde(default)] + _runtime_invocation: Option, + #[serde(default)] + _runtime_transfer: Option, +} + +struct ContentBlockGraphProvider { + data_dir: PathBuf, + max_graph_bytes: usize, +} + +impl Default for ContentBlockGraphProvider { + fn default() -> Self { + Self { + data_dir: default_data_dir(), + max_graph_bytes: DEFAULT_MAX_GRAPH_BYTES, + } + } +} + +impl ContentBlockGraphProvider { + fn handle(&mut self, request: Request) -> Value { + match request { + Request::Init { config } => self.init(config), + Request::ExportGraph(request) => self.export_graph(request), + Request::ImportGraph(request) => self.import_graph(request), + Request::Status { .. } => ok(json!({ + "provider": PROVIDER_ID, + "version": PROVIDER_VERSION, + "schema": GRAPH_SCHEMA, + "backend": self.backend_status(), + "operations": ["export_graph", "import_graph", "status"], + "status": if self.kubo_api_url().is_ok() { + "ready" + } else { + "backend_not_configured" + } + })), + Request::Shutdown => ok(json!({ "provider": PROVIDER_ID })), + } + } + + fn init(&mut self, config: Value) -> Value { + let extra = config + .get("extra") + .filter(|value| !value.is_null()) + .unwrap_or(&config); + if let Some(base_path) = config + .get("base_path") + .and_then(Value::as_str) + .or_else(|| extra.get("data_dir").and_then(Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let path = PathBuf::from(base_path); + if !path.is_absolute() { + return error( + "invalid_config", + "content block graph data_dir must be absolute", + ); + } + self.data_dir = path; + } + if let Some(max_graph_bytes) = extra.get("max_graph_bytes").and_then(Value::as_u64) { + let max_graph_bytes = usize::try_from(max_graph_bytes).unwrap_or(usize::MAX); + if max_graph_bytes == 0 || max_graph_bytes > DEFAULT_MAX_GRAPH_BYTES { + return error( + "invalid_config", + "max_graph_bytes must be between 1 and 67108864", + ); + } + self.max_graph_bytes = max_graph_bytes; + } + + ok(json!({ + "provider": PROVIDER_ID, + "protocol_version": "1.0", + "version": PROVIDER_VERSION, + "schema": GRAPH_SCHEMA, + "backend": self.backend_status(), + "status": if self.kubo_api_url().is_ok() { + "ready" + } else { + "backend_not_configured" + } + })) + } + + fn export_graph(&self, request: GraphRequest) -> Value { + if let Err(message) = validate_cid(&request.cid) { + return error("invalid_request", &message); + } + if let Some(schema) = request.schema.as_deref() { + if schema != GRAPH_SCHEMA { + return error( + "unsupported_schema", + "export_graph requires block graph schema v1", + ); + } + } + if request + .repair_graph_kind + .as_deref() + .is_some_and(|kind| !matches!(kind, "ipld_dag" | "block_dag" | "dag" | "arbitrary_dag")) + { + return error( + "unsupported_repair_graph", + "export_graph only handles arbitrary block/IPLD DAG repair", + ); + } + let car = match self.kubo_dag_export(&request.cid) { + Ok(car) => car, + Err(message) => return error("export_failed", &message), + }; + if car.len() > self.max_graph_bytes { + return error( + "graph_too_large", + "exported graph exceeds content-block-graph-provider max_graph_bytes", + ); + } + ok(json!({ + "graph": { + "schema": GRAPH_SCHEMA, + "root_cid": request.cid, + "kind": "ipld_dag", + "encoding": GRAPH_ENCODING, + "car": BASE64.encode(&car), + "bytes": car.len(), + "backend": "kubo_dag_car", + "exported_at": now_unix_secs(), + "max_graph_bytes": self.max_graph_bytes, + } + })) + } + + fn import_graph(&self, request: ImportGraphRequest) -> Value { + if let Err(message) = validate_cid(&request.cid) { + return error("invalid_request", &message); + } + if request.graph.get("schema").and_then(Value::as_str) != Some(GRAPH_SCHEMA) { + return error( + "unsupported_schema", + "import_graph requires block graph schema v1", + ); + } + if request.graph.get("root_cid").and_then(Value::as_str) != Some(request.cid.as_str()) { + return error("cid_mismatch", "graph root_cid must match import cid"); + } + if request.graph.get("encoding").and_then(Value::as_str) != Some(GRAPH_ENCODING) { + return error( + "unsupported_encoding", + "import_graph requires base64-car encoding", + ); + } + let car = match request.graph.get("car").and_then(Value::as_str) { + Some(car) => match BASE64.decode(car) { + Ok(bytes) => bytes, + Err(err) => { + return error("invalid_car", &format!("invalid graph CAR base64: {err}")) + } + }, + None => return error("invalid_graph", "import_graph requires graph.car"), + }; + if car.is_empty() { + return error("invalid_graph", "import_graph requires non-empty CAR bytes"); + } + if car.len() > self.max_graph_bytes { + return error( + "graph_too_large", + "import graph exceeds content-block-graph-provider max_graph_bytes", + ); + } + if let Err(message) = self.kubo_dag_import_and_pin(&request.cid, &car) { + return error("import_failed", &message); + } + ok(json!({ + "cid": request.cid, + "availability": { + "status": "local_pinned", + "provider": PROVIDER_ID, + "policy": request + .availability_policy + .unwrap_or_else(|| "carrier_block_graph_import".to_string()), + "replicas": 1, + "peer_selection": { + "mode": "single_local", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "provider_local", + "enforced": false + }, + "repair_worker": { + "scheduled": false, + "status": "healthy", + "worker": PROVIDER_ID + }, + "repair_graph": { + "schema": "elastos.content.repair-graph/v1", + "policy": "carrier_provider_block_graph_repair", + "requested_kind": "ipld_dag", + "status": "block_graph_provider_imported", + "backend": "kubo_dag_car" + } + }, + "import": { + "schema": GRAPH_SCHEMA, + "verified_cid": true, + "bytes": car.len(), + "backend": "kubo_dag_import" + } + })) + } + + fn backend_status(&self) -> Value { + match read_coord_file(&self.data_dir) { + Some(coord) => json!({ + "kind": "kubo_coord", + "configured": true, + "coord_file": coord_file_path(&self.data_dir), + "kubo_pid": coord.kubo_pid, + "api_port": coord.api_port, + "gateway_port": coord.gateway_port, + "started_at": coord.started_at, + "last_used": coord.last_used, + "max_graph_bytes": self.max_graph_bytes, + }), + None => json!({ + "kind": "kubo_coord", + "configured": false, + "coord_file": coord_file_path(&self.data_dir), + "max_graph_bytes": self.max_graph_bytes, + }), + } + } + + fn kubo_api_url(&self) -> Result { + let coord = read_coord_file(&self.data_dir).ok_or_else(|| { + format!( + "Kubo coord file missing at {}; start ipfs-provider before block graph repair", + coord_file_path(&self.data_dir).display() + ) + })?; + if coord.api_port == 0 { + return Err("Kubo coord file has no API port".to_string()); + } + Ok(format!("http://127.0.0.1:{}", coord.api_port)) + } + + fn kubo_dag_export(&self, cid: &str) -> Result, String> { + let url = format!("{}/api/v0/dag/export?arg={}", self.kubo_api_url()?, cid); + let response = ureq::post(&url) + .timeout(LARGE_HTTP_TIMEOUT) + .call() + .map_err(|err| format!("kubo dag export failed: {err}"))?; + if response.status() != 200 { + return Err(format!( + "kubo dag export returned HTTP {}", + response.status() + )); + } + let mut bytes = Vec::new(); + response + .into_reader() + .take((self.max_graph_bytes as u64).saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|err| format!("kubo dag export read failed: {err}"))?; + update_coord_last_used(&self.data_dir); + Ok(bytes) + } + + fn kubo_dag_import_and_pin(&self, cid: &str, car: &[u8]) -> Result<(), String> { + let api_url = self.kubo_api_url()?; + let boundary = format!("----elastos-block-graph-{}", now_unix_secs()); + let mut body = Vec::new(); + write!(body, "--{boundary}\r\n").unwrap(); + write!( + body, + "Content-Disposition: form-data; name=\"file\"; filename=\"graph.car\"\r\n" + ) + .unwrap(); + write!(body, "Content-Type: application/vnd.ipld.car\r\n\r\n").unwrap(); + body.extend_from_slice(car); + write!(body, "\r\n--{boundary}--\r\n").unwrap(); + + let import_url = format!("{api_url}/api/v0/dag/import?pin-roots=true"); + let response = ureq::post(&import_url) + .set( + "Content-Type", + &format!("multipart/form-data; boundary={boundary}"), + ) + .timeout(LARGE_HTTP_TIMEOUT) + .send_bytes(&body) + .map_err(|err| format!("kubo dag import failed: {err}"))?; + if response.status() != 200 { + return Err(format!( + "kubo dag import returned HTTP {}", + response.status() + )); + } + + let pin_url = format!("{api_url}/api/v0/pin/add?arg={cid}"); + let pin_response = ureq::post(&pin_url) + .timeout(HTTP_TIMEOUT) + .call() + .map_err(|err| format!("kubo pin after dag import failed: {err}"))?; + if pin_response.status() != 200 { + return Err(format!( + "kubo pin after dag import returned HTTP {}", + pin_response.status() + )); + } + update_coord_last_used(&self.data_dir); + Ok(()) + } +} + +fn validate_cid(cid: &str) -> Result<(), String> { + let value = cid.trim(); + if value.is_empty() { + return Err("cid must not be empty".to_string()); + } + if !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err("cid contains unsupported characters".to_string()); + } + Ok(()) +} + +fn ok(data: Value) -> Value { + json!({ + "status": "ok", + "data": data, + }) +} + +fn error(code: &str, message: &str) -> Value { + json!({ + "status": "error", + "code": code, + "message": message, + }) +} + +fn default_data_dir() -> PathBuf { + if let Ok(dir) = std::env::var("ELASTOS_DATA_DIR") { + PathBuf::from(dir) + } else if let Ok(dir) = std::env::var("XDG_DATA_HOME") { + PathBuf::from(dir).join("elastos") + } else if let Some(home) = std::env::var_os("HOME") { + PathBuf::from(home).join(".local/share/elastos") + } else { + PathBuf::from("/tmp/elastos") + } +} + +fn coord_file_path(data_dir: &Path) -> PathBuf { + data_dir.join("ipfs-coords.json") +} + +fn read_coord_file(data_dir: &Path) -> Option { + let path = coord_file_path(data_dir); + let content = fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() +} + +fn update_coord_last_used(data_dir: &Path) { + let Some(mut coord) = read_coord_file(data_dir) else { + return; + }; + coord.last_used = now_unix_secs(); + let path = coord_file_path(data_dir); + let Ok(json) = serde_json::to_string_pretty(&json!({ + "kubo_pid": coord.kubo_pid, + "api_port": coord.api_port, + "gateway_port": coord.gateway_port, + "started_at": coord.started_at, + "last_used": coord.last_used, + })) else { + return; + }; + let tmp = path.with_extension("tmp"); + if fs::write(&tmp, json).is_ok() { + let _ = fs::rename(tmp, path); + } +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "elastos-block-graph-provider-test-{name}-{}", + now_unix_secs() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn cid_validation_rejects_empty_and_path_like_values() { + assert!( + validate_cid("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi").is_ok() + ); + assert!(validate_cid("").is_err()); + assert!(validate_cid("../secret").is_err()); + assert!(validate_cid("bafy cid").is_err()); + } + + #[test] + fn status_reports_missing_kubo_coord_as_not_configured() { + let mut provider = ContentBlockGraphProvider::default(); + let data_dir = temp_dir("missing-coord"); + let response = provider.handle(Request::Init { + config: json!({ + "base_path": data_dir, + }), + }); + assert_eq!(response["status"], "ok"); + + let response = provider.handle(Request::Status { + _runtime_invocation: None, + _runtime_transfer: None, + }); + assert_eq!(response["data"]["status"], "backend_not_configured"); + assert_eq!(response["data"]["backend"]["configured"], false); + } + + #[test] + fn export_graph_fails_closed_without_kubo_coord() { + let provider = ContentBlockGraphProvider { + data_dir: temp_dir("export-no-coord"), + ..Default::default() + }; + let response = provider.export_graph(GraphRequest { + cid: "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi".to_string(), + schema: Some(GRAPH_SCHEMA.to_string()), + repair_graph_kind: Some("ipld_dag".to_string()), + availability_requirements: Value::Null, + policy: None, + object_did: None, + publisher_did: None, + _runtime_invocation: None, + _runtime_transfer: None, + }); + + assert_eq!(response["status"], "error"); + assert_eq!(response["code"], "export_failed"); + assert!(response["message"] + .as_str() + .unwrap() + .contains("Kubo coord file missing")); + } + + #[test] + fn import_graph_rejects_wrong_schema_and_root() { + let provider = ContentBlockGraphProvider { + data_dir: temp_dir("import-validate"), + ..Default::default() + }; + let cid = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi".to_string(); + let response = provider.import_graph(ImportGraphRequest { + cid: cid.clone(), + graph: json!({ + "schema": "wrong", + "root_cid": cid, + }), + availability_policy: None, + availability_requirements: Value::Null, + ensure_failure: None, + object_did: None, + publisher_did: None, + _runtime_invocation: None, + _runtime_transfer: None, + }); + assert_eq!(response["code"], "unsupported_schema"); + + let response = provider.import_graph(ImportGraphRequest { + cid, + graph: json!({ + "schema": GRAPH_SCHEMA, + "root_cid": "bafywrong", + "encoding": GRAPH_ENCODING, + "car": BASE64.encode(b"car"), + }), + availability_policy: None, + availability_requirements: Value::Null, + ensure_failure: None, + object_did: None, + publisher_did: None, + _runtime_invocation: None, + _runtime_transfer: None, + }); + assert_eq!(response["code"], "cid_mismatch"); + } +} + +fn main() { + eprintln!("{PROVIDER_ID}: starting v{PROVIDER_VERSION}"); + let stdin = io::stdin(); + let mut stdout = io::stdout(); + let mut provider = ContentBlockGraphProvider::default(); + + for line in stdin.lock().lines() { + let line = match line { + Ok(line) => line, + Err(err) => { + eprintln!("{PROVIDER_ID} read error: {err}"); + break; + } + }; + if line.trim().is_empty() { + continue; + } + + let request = match serde_json::from_str::(&line) { + Ok(request) => request, + Err(err) => { + let response = error("invalid_request", &err.to_string()); + writeln!(stdout, "{}", serde_json::to_string(&response).unwrap()).unwrap(); + stdout.flush().unwrap(); + continue; + } + }; + let is_shutdown = matches!(request, Request::Shutdown); + let response = provider.handle(request); + writeln!(stdout, "{}", serde_json::to_string(&response).unwrap()).unwrap(); + stdout.flush().unwrap(); + if is_shutdown { + break; + } + } + + eprintln!("{PROVIDER_ID}: exiting"); +} diff --git a/capsules/decrypt-provider/src/main.rs b/capsules/decrypt-provider/src/main.rs index 7412b26b..f0e5c271 100644 --- a/capsules/decrypt-provider/src/main.rs +++ b/capsules/decrypt-provider/src/main.rs @@ -7,7 +7,7 @@ use elastos_common::protected_content::{ DecryptSessionRequestV1, DECRYPT_SESSION_REQUEST_SCHEMA, PROTECTED_CONTENT_ACTIONS, - PROTECTED_CONTENT_OUTPUTS, + PROTECTED_CONTENT_OUTPUTS, RELEASE_RECEIPT_SCHEMA, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -108,6 +108,44 @@ impl DecryptProvider { "next_required_providers": [ "key-provider" ], + "contract": { + "schema": "elastos.protected-content.decrypt-provider/v1", + "authority_boundary": "viewer-scoped decrypt/render sessions only", + "denied_to_apps": [ + "raw_cek", + "raw_plaintext", + "filesystem", + "key_backend_sdk", + "kms_node_credentials", + "chain_rpc", + "wallet_rpc", + "provider_credentials" + ], + "operations": { + "open_session": { + "input_schema": DECRYPT_SESSION_REQUEST_SCHEMA, + "input": [ + "request_id", + "principal_id", + "session_id", + "object_cid", + "action", + "viewer_interface", + "release_receipt", + "output_kind", + "reason", + "expires_at" + ], + "output": "viewer-scoped decrypt session receipt" + }, + "render": { + "input_schema": DECRYPT_SESSION_REQUEST_SCHEMA, + "output": "bounded rendered output for an authorized viewer capsule" + } + }, + "supported_outputs": PROTECTED_CONTENT_OUTPUTS, + "status": "fail_closed_until_key_release_and_decrypt_backend_configured" + }, })) } @@ -142,7 +180,7 @@ fn validate_decrypt_session_request(request: &DecryptSessionRequestV1) -> Result require_identifier(&request.object_cid, "object_cid")?; validate_action(&request.action)?; require_non_empty(&request.viewer_interface, "viewer_interface")?; - require_non_empty(&request.release_receipt_id, "release_receipt_id")?; + validate_release_receipt_binding(request)?; validate_output_kind(&request.output_kind)?; require_non_empty(&request.reason, "reason")?; if request.expires_at == 0 { @@ -151,6 +189,47 @@ fn validate_decrypt_session_request(request: &DecryptSessionRequestV1) -> Result Ok(()) } +fn validate_release_receipt_binding(request: &DecryptSessionRequestV1) -> Result<(), String> { + let receipt = &request.release_receipt; + if receipt.schema != RELEASE_RECEIPT_SCHEMA { + return Err("release receipt schema is unsupported".to_string()); + } + require_identifier(&receipt.request_id, "release_receipt.request_id")?; + require_identifier(&receipt.object_cid, "release_receipt.object_cid")?; + require_non_empty(&receipt.principal_id, "release_receipt.principal_id")?; + require_non_empty(&receipt.session_id, "release_receipt.session_id")?; + validate_action(&receipt.action)?; + require_non_empty(&receipt.provider, "release_receipt.provider")?; + if receipt.provider != "key-provider" { + return Err("release receipt provider must be key-provider".to_string()); + } + if !matches!(receipt.status.as_str(), "released" | "issued") { + return Err("release receipt must be issued or released".to_string()); + } + if receipt.object_cid != request.object_cid { + return Err("release receipt object_cid must match decrypt object_cid".to_string()); + } + if receipt.principal_id != request.principal_id { + return Err("release receipt principal_id must match decrypt principal_id".to_string()); + } + if receipt.session_id != request.session_id { + return Err("release receipt session_id must match decrypt session_id".to_string()); + } + if receipt.action != request.action { + return Err("release receipt action must match decrypt action".to_string()); + } + if receipt.issued_at == 0 { + return Err("release receipt issued_at is required".to_string()); + } + if receipt.expires_at == 0 { + return Err("release receipt expires_at is required".to_string()); + } + if receipt.expires_at < request.expires_at { + return Err("release receipt must cover the decrypt session expiry".to_string()); + } + Ok(()) +} + fn validate_action(action: &str) -> Result<(), String> { if PROTECTED_CONTENT_ACTIONS.contains(&action) { Ok(()) @@ -238,6 +317,7 @@ fn main() { #[cfg(test)] mod tests { use super::*; + use elastos_common::protected_content::ReleaseReceiptV1; fn decrypt_request() -> DecryptSessionRequestV1 { DecryptSessionRequestV1 { @@ -248,7 +328,18 @@ mod tests { object_cid: "bafybeigprotectedcontent".to_string(), action: "view".to_string(), viewer_interface: "elastos.viewer/document@1".to_string(), - release_receipt_id: "key-release:test".to_string(), + release_receipt: ReleaseReceiptV1 { + schema: RELEASE_RECEIPT_SCHEMA.to_string(), + request_id: "key-release:test".to_string(), + object_cid: "bafybeigprotectedcontent".to_string(), + principal_id: "person:local:test".to_string(), + session_id: "session:test".to_string(), + action: "view".to_string(), + provider: "key-provider".to_string(), + status: "released".to_string(), + issued_at: 1_800_000_000, + expires_at: 1_900_000_000, + }, output_kind: "rendered".to_string(), reason: "open protected document".to_string(), expires_at: 1_900_000_000, @@ -284,6 +375,14 @@ mod tests { .as_array() .unwrap() .contains(&json!("raw_plaintext"))); + assert_eq!( + data["contract"]["schema"], + "elastos.protected-content.decrypt-provider/v1" + ); + assert_eq!( + data["contract"]["status"], + "fail_closed_until_key_release_and_decrypt_backend_configured" + ); } #[test] @@ -339,4 +438,28 @@ mod tests { "invalid_request" ); } + + #[test] + fn open_session_rejects_denied_release_receipt() { + let provider = DecryptProvider; + let mut request = decrypt_request(); + request.release_receipt.status = "denied".to_string(); + + assert_eq!( + error_code(provider.open_session(request)), + "invalid_request" + ); + } + + #[test] + fn open_session_rejects_mismatched_release_receipt() { + let provider = DecryptProvider; + let mut request = decrypt_request(); + request.release_receipt.object_cid = "bafybeigother".to_string(); + + assert_eq!( + error_code(provider.open_session(request)), + "invalid_request" + ); + } } diff --git a/capsules/documents/index.html b/capsules/documents/index.html index cfc8627d..f117f057 100644 --- a/capsules/documents/index.html +++ b/capsules/documents/index.html @@ -1139,6 +1139,10 @@

Continue?

homeToken: new URLSearchParams(window.location.search).get("home_token") || "", initialDocDid: new URLSearchParams(window.location.search).get("doc") || "", initialCid: new URLSearchParams(window.location.search).get("cid") || "", + initialLibraryObjectUri: + new URLSearchParams(window.location.search).get("objectUri") || + new URLSearchParams(window.location.search).get("object_uri") || + "", initialView: new URLSearchParams(window.location.search).get("view") || "", initialIntent: new URLSearchParams(window.location.search).get("intent") || "", mode: "share", @@ -1347,8 +1351,22 @@

Continue?

return docDid ? "localhost://ElastOS/Documents/" + docDid : ""; } +function baseName(uri) { + const clean = String(uri || "").replace(/\/+$/, ""); + const name = clean.split("/").pop() || "Document"; + try { + return decodeURIComponent(name); + } catch (_error) { + return name; + } +} + +function isLibraryFileDocument(document) { + return !!document && !!document.library_object_uri; +} + function isDraftDocument(document) { - return !!document && !document.doc_did; + return !!document && !document.doc_did && !isLibraryFileDocument(document); } function createDraftDocument() { @@ -1424,11 +1442,12 @@

Continue?

state.dirty = !!dirty; const hasCurrent = !!state.current; const hasPersistedCurrent = hasCurrent && !isDraftDocument(state.current); + const isLibraryFile = isLibraryFileDocument(state.current); elements.saveButton.disabled = !hasCurrent || !state.dirty; - elements.saveAsButton.disabled = !hasPersistedCurrent; - elements.publishButton.disabled = !hasCurrent; - elements.unpublishButton.disabled = !hasPersistedCurrent || !state.current.latest_published_cid; - elements.deleteButton.disabled = !hasPersistedCurrent; + elements.saveAsButton.disabled = !hasPersistedCurrent || isLibraryFile; + elements.publishButton.disabled = !hasCurrent || isLibraryFile; + elements.unpublishButton.disabled = !hasPersistedCurrent || isLibraryFile || !state.current.latest_published_cid; + elements.deleteButton.disabled = !hasPersistedCurrent || isLibraryFile; if (hasCurrent && state.dirty) { setStatus("Unsaved local changes.", "warning"); } else if (hasCurrent) { @@ -1541,7 +1560,7 @@

Continue?

return; } - elements.titleInput.disabled = false; + elements.titleInput.disabled = isLibraryFileDocument(state.current); elements.editor.disabled = false; elements.titleInput.value = state.current.title; elements.editor.value = state.current.body; @@ -1634,6 +1653,43 @@

Continue?

return response && typeof response === "object" && "data" in response ? response.data : response; } +async function libraryObjectApi(op, payload) { + const uri = payload && payload.uri ? String(payload.uri) : ""; + const endpoint = "/api/viewers/documents/library-object?uri=" + encodeURIComponent(uri); + const response = op === "write" + ? await documentsApi(endpoint, { + method: "PUT", + body: JSON.stringify({ + data: payload.data || "", + mime: payload.mime || null, + if_revision: payload.if_revision || null, + }), + }) + : await documentsApi(endpoint, { method: "GET" }); + if (response && response.status === "error") { + throw new Error(response.message || "Library object request failed"); + } + return response && typeof response === "object" && "data" in response ? response.data : response; +} + +function base64ToUtf8(data) { + const binary = atob(data || ""); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return new TextDecoder().decode(bytes); +} + +function utf8ToBase64(text) { + const bytes = new TextEncoder().encode(String(text || "")); + let binary = ""; + for (let i = 0; i < bytes.length; i += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + } + return btoa(binary); +} + function upsertDocumentListItem(document) { const listItem = { doc_did: document.doc_did, @@ -1674,6 +1730,13 @@

Continue?

await createDocument(""); return; } + if (state.initialLibraryObjectUri) { + const requestedUri = state.initialLibraryObjectUri.trim(); + state.initialLibraryObjectUri = ""; + await loadLibraryObject(requestedUri); + setWorkspaceView(state.initialView === "write" ? "write" : "read"); + return; + } if (state.initialCid) { const requestedCid = state.initialCid.trim(); state.initialCid = ""; @@ -1705,6 +1768,30 @@

Continue?

startDraftDocument({ focusTitle: true }); } +async function loadLibraryObject(uri) { + const response = await libraryObjectApi("read", { uri }); + const object = response.object || {}; + const name = object.name || baseName(object.uri || uri); + state.current = { + doc_did: "", + document_uri: object.uri || uri, + library_object_uri: object.uri || uri, + revision: object.revision || "", + title: name, + file_name: name, + working_copy_uri: object.uri || uri, + body: base64ToUtf8(response.data || ""), + mime: object.mime || "text/markdown", + created_at: object.created_at || 0, + updated_at: object.modified_at || 0, + latest_published_cid: null, + publish_history: [], + }; + renderDocumentsList(); + renderCurrentDocument(); + setStatus("Opened from Library."); +} + async function selectDocument(docDid, options) { const force = !!(options && options.force); if (state.current && state.current.doc_did !== docDid && state.dirty) { @@ -1751,6 +1838,30 @@

Continue?

return; } setStatus("Saving…"); + if (isLibraryFileDocument(state.current)) { + const response = await libraryObjectApi("write", { + uri: state.current.library_object_uri, + data: utf8ToBase64(elements.editor.value), + mime: state.current.mime || "text/markdown", + if_revision: state.current.revision || null, + }); + const object = response.object || {}; + state.current = { + ...state.current, + document_uri: object.uri || state.current.document_uri, + library_object_uri: object.uri || state.current.library_object_uri, + revision: object.revision || state.current.revision, + title: object.name || state.current.title, + file_name: object.name || state.current.file_name, + body: elements.editor.value, + mime: object.mime || state.current.mime, + updated_at: object.modified_at || state.current.updated_at, + }; + renderCurrentDocument(); + renderDocumentsList(); + setStatus("Saved to Library."); + return; + } if (isDraftDocument(state.current)) { const created = await documentsProviderApi("create", { title: elements.titleInput.value || null, @@ -1852,6 +1963,10 @@

Continue?

if (!state.current) { return; } + if (isLibraryFileDocument(state.current)) { + setStatus("Publish Library files from Library.", "warning"); + return; + } try { if (state.dirty || isDraftDocument(state.current)) { await saveCurrent(); @@ -1873,7 +1988,12 @@

Continue?

} async function unpublishCurrent() { - if (!state.current || isDraftDocument(state.current) || !state.current.latest_published_cid) { + if ( + !state.current || + isDraftDocument(state.current) || + isLibraryFileDocument(state.current) || + !state.current.latest_published_cid + ) { return; } try { @@ -1890,7 +2010,7 @@

Continue?

} async function requestDeleteCurrent() { - if (!state.current || isDraftDocument(state.current)) { + if (!state.current || isDraftDocument(state.current) || isLibraryFileDocument(state.current)) { return; } const deletedDocDid = state.current.doc_did; diff --git a/capsules/home/browser/index.html b/capsules/home/browser/index.html index 02c08ece..1ea28a3f 100644 --- a/capsules/home/browser/index.html +++ b/capsules/home/browser/index.html @@ -10,10 +10,10 @@ Home · ElastOS - + - +
@@ -206,6 +206,6 @@

Runtime data not attached on this host

- + diff --git a/capsules/home/browser/service-worker.js b/capsules/home/browser/service-worker.js index 6aa8895a..0fd88dac 100644 --- a/capsules/home/browser/service-worker.js +++ b/capsules/home/browser/service-worker.js @@ -1,4 +1,3 @@ -const CACHE_NAME = "elastos-home-20260526d"; const CACHE_PREFIX = "elastos-home-"; self.addEventListener("install", (event) => { @@ -10,36 +9,12 @@ self.addEventListener("activate", (event) => { caches.keys() .then((keys) => Promise.all( keys - .filter((key) => key.startsWith(CACHE_PREFIX) && key !== CACHE_NAME) + .filter((key) => key.startsWith(CACHE_PREFIX)) .map((key) => caches.delete(key)), )) - .then(() => self.clients.claim()), + .then(() => self.clients.claim()) + .then(() => self.registration.unregister()), ); }); -self.addEventListener("fetch", (event) => { - const request = event.request; - if (request.method !== "GET") { - return; - } - - const url = new URL(request.url); - if (url.origin !== self.location.origin || !url.pathname.includes("/apps/home/")) { - return; - } - - event.respondWith( - fetch(request) - .then((response) => { - if (response.ok) { - const copy = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); - } - return response; - }) - .catch(() => caches.match(request).then((cached) => cached || new Response("", { - status: 504, - statusText: "Offline", - }))), - ); -}); +self.addEventListener("fetch", () => {}); diff --git a/capsules/home/browser/shell-auth.js b/capsules/home/browser/shell-auth.js index 2236432c..d852f592 100644 --- a/capsules/home/browser/shell-auth.js +++ b/capsules/home/browser/shell-auth.js @@ -1,4 +1,4 @@ -import { fetchJson } from "./shell-core.js?v=home-20260526d"; +import { fetchJson } from "./shell-core.js?v=home-20260607d"; const unlockPanel = document.querySelector("#home-unlock"); const unlockCard = document.querySelector(".home-unlock-card"); diff --git a/capsules/home/browser/shell-chrome.js b/capsules/home/browser/shell-chrome.js index 63ab10fa..b22b7ff8 100644 --- a/capsules/home/browser/shell-chrome.js +++ b/capsules/home/browser/shell-chrome.js @@ -1,4 +1,4 @@ -import { clockNode } from "./shell-core.js?v=home-20260526d"; +import { clockNode } from "./shell-core.js?v=home-20260607d"; export function syncIdentity(_summary) {} diff --git a/capsules/home/browser/shell-core.js b/capsules/home/browser/shell-core.js index 7f943884..6dc424e9 100644 --- a/capsules/home/browser/shell-core.js +++ b/capsules/home/browser/shell-core.js @@ -25,6 +25,12 @@ export const taskbarItemTemplate = document.querySelector("#taskbar-item-templat export const SHELL_APP_ID = "home"; export const SYSTEM_APP_ID = "system"; +const TARGET_TITLE_OVERRIDES = Object.freeze({ + "archive-manager": "Archive", +}); +const STALE_TARGET_TITLES = Object.freeze({ + "archive-manager": new Set(["Archive Manager"]), +}); const MAX_RECENT_TARGETS = 10; export const ICON_DRAG_THRESHOLD = 6; const DESKTOP_ICON_WIDTH = 92; @@ -126,7 +132,63 @@ export async function fetchJson(url, init) { } export function allVisibleTargets(summary) { - return (summary && Array.isArray(summary.targets)) ? summary.targets : []; + return (summary && Array.isArray(summary.targets)) + ? summary.targets.map((target) => ({ + ...target, + title: canonicalTargetTitle(target?.target, target?.title), + })) + : []; +} + +export function desktopObjects(summary) { + const objects = summary && + summary.desktop_objects && + Array.isArray(summary.desktop_objects.objects) + ? summary.desktop_objects.objects + : []; + return objects.filter((object) => ( + object && + typeof object.uri === "string" && + object.uri.trim() !== "" && + typeof object.name === "string" && + object.name.trim() !== "" + )); +} + +export function desktopObjectEntryId(object) { + return `object:${object.uri}`; +} + +export function desktopObjectByEntryId(summary, entryId) { + if (typeof entryId !== "string" || !entryId.startsWith("object:")) { + return null; + } + const uri = entryId.slice("object:".length); + return desktopObjects(summary).find((object) => object.uri === uri) || null; +} + +export function desktopEntryExists(summary, entryId) { + if (!summary || typeof entryId !== "string" || entryId.trim() === "") { + return false; + } + if (entryId.startsWith("object:")) { + return Boolean(desktopObjectByEntryId(summary, entryId)); + } + return Boolean(targetById(summary, entryId) && isTargetOnDesktop(entryId)); +} + +export function desktopLayoutEntries(summary) { + const appEntries = allVisibleTargets(summary).map((target) => ({ + id: target.target, + kind: "target", + target, + })); + const objectEntries = desktopObjects(summary).map((object) => ({ + id: desktopObjectEntryId(object), + kind: "object", + object, + })); + return [...appEntries, ...objectEntries]; } function syncHomeBrowserState(summary) { @@ -165,7 +227,20 @@ export function shellAppId(summary) { export function targetTitle(summary, targetId) { const target = targetById(summary, targetId); - return target ? target.title : targetId; + return canonicalTargetTitle(targetId, target?.title); +} + +export function canonicalTargetTitle(targetId, title) { + const normalizedTitle = normalizeText(title); + const override = TARGET_TITLE_OVERRIDES[targetId]; + if (!override) { + return normalizedTitle || targetId; + } + const staleTitles = STALE_TARGET_TITLES[targetId]; + if (!normalizedTitle || staleTitles?.has(normalizedTitle)) { + return override; + } + return normalizedTitle; } export function desktopLabelForTarget(summary, targetId) { @@ -213,11 +288,11 @@ export function initializeShellLayout(summary) { normalizedDesktopHidden, ) || typeof stored.desktopIconsVisible !== "boolean"; - for (const [index, app] of allVisibleTargets(summary).entries()) { + for (const [index, entry] of desktopLayoutEntries(summary).entries()) { const defaultPosition = defaultDesktopPosition(index); - const storedPosition = stored && stored.desktop ? stored.desktop[app.target] : null; + const storedPosition = stored && stored.desktop ? stored.desktop[entry.id] : null; const position = clampDesktopPosition(normalizeDesktopPosition(storedPosition, defaultPosition)); - shellState.shellLayoutState.desktop[app.target] = position; + shellState.shellLayoutState.desktop[entry.id] = position; if (!storedPosition || !positionsEqual(storedPosition, position)) { changed = true; } @@ -359,6 +434,9 @@ function normalizeDesktopLabels(labels, summary) { if (!knownTargets.has(targetId) || nextLabel === "") { continue; } + if (STALE_TARGET_TITLES[targetId]?.has(nextLabel)) { + continue; + } normalized[targetId] = nextLabel; } return normalized; @@ -502,9 +580,13 @@ export function autoArrangeDesktopIcons(summary = shellState.currentSummary) { } let changed = false; const desktopTargets = sortedDesktopTargets(summary) - .filter((target) => isTargetOnDesktop(target.target)); - for (const [index, target] of desktopTargets.entries()) { - changed = setDesktopPosition(target.target, defaultDesktopPosition(index)) || changed; + .filter((target) => isTargetOnDesktop(target.target)) + .map((target) => ({ id: target.target })); + const desktopObjectEntries = desktopObjects(summary).map((object) => ({ + id: desktopObjectEntryId(object), + })); + for (const [index, entry] of [...desktopTargets, ...desktopObjectEntries].entries()) { + changed = setDesktopPosition(entry.id, defaultDesktopPosition(index)) || changed; } if (!changed) { return false; @@ -546,10 +628,10 @@ export function clampDesktopLayoutToViewport() { return false; } let changed = false; - for (const [index, app] of allVisibleTargets(shellState.currentSummary).entries()) { - const next = clampDesktopPosition(desktopPositionForTarget(app.target, index)); - if (!positionsEqual(shellState.shellLayoutState.desktop[app.target], next)) { - shellState.shellLayoutState.desktop[app.target] = next; + for (const [index, entry] of desktopLayoutEntries(shellState.currentSummary).entries()) { + const next = clampDesktopPosition(desktopPositionForTarget(entry.id, index)); + if (!positionsEqual(shellState.shellLayoutState.desktop[entry.id], next)) { + shellState.shellLayoutState.desktop[entry.id] = next; changed = true; } } @@ -563,6 +645,9 @@ export function mountGlyph(container, targetId, forcedTone) { } export function glyphTone(targetId) { + if (targetId === "trash" || targetId === "trash-full") { + return "system"; + } if (targetId === SYSTEM_APP_ID) { return "system"; } @@ -591,6 +676,17 @@ export function glyphTone(targetId) { } function glyphSvg(targetId) { + if (targetId === "trash" || targetId === "trash-full") { + const full = targetId === "trash-full"; + return ` + + `; + } if (targetId === SYSTEM_APP_ID) { return ` - Library · ElastOS - + Explorer · ElastOS + - + diff --git a/capsules/library/library.css b/capsules/library/library.css new file mode 100644 index 00000000..fd2616c3 --- /dev/null +++ b/capsules/library/library.css @@ -0,0 +1,1432 @@ + :root { + color-scheme: light; + --bg: #f6f7f9; + --sidebar-bg: #f0f1f4; + --panel: #ffffff; + --panel-strong: #ffffff; + --panel-soft: #f3f4f6; + --line: rgba(60, 60, 67, 0.14); + --line-strong: rgba(60, 60, 67, 0.26); + --ink: #1d1d1f; + --muted: #6b6b6b; + --brand: #f6921a; + --brand-soft: rgba(246, 146, 26, 0.16); + --accent: #007aff; + --accent-soft: rgba(0, 122, 255, 0.11); + --accent-deep: #0064d2; + --good: #2f8f68; + --warn: #b57918; + --danger: #b14c5a; + --shadow: none; + --radius-xl: 0; + --radius-lg: 6px; + --radius-md: 6px; + --mono: "SFMono-Regular", "SF Mono", "Cascadia Code", "JetBrains Mono", monospace; + --explorer-list-columns: 30px minmax(260px, 1fr) 170px 86px 180px; + font-family: "Segoe UI", "Inter", "Helvetica Neue", sans-serif; + } + + * { + box-sizing: border-box; + } + + body { + margin: 0; + min-height: 100vh; + min-height: 100dvh; + background: var(--bg); + color: var(--ink); + } + + button, + input, + select { + font: inherit; + } + + button { + color: inherit; + } + + .hidden { + display: none !important; + } + + .locked-shell { + min-height: 100vh; + min-height: 100dvh; + display: grid; + place-items: center; + padding: 24px; + } + + .locked-card, + .sidebar, + .main, + .dialog-card { + border: 1px solid var(--line); + background: var(--panel); + border-radius: var(--radius-xl); + box-shadow: var(--shadow); + } + + .locked-card { + max-width: 520px; + padding: 30px; + } + + .shell { + height: 100vh; + height: 100dvh; + display: grid; + grid-template-columns: 180px minmax(0, 1fr); + gap: 0; + padding: 0; + overflow: hidden; + background: var(--panel); + } + + .sidebar { + display: flex; + flex-direction: column; + gap: 0; + min-width: 0; + padding: 15px 10px; + overflow-y: auto; + border: 0; + border-right: 0.5px solid #ccc; + background: var(--sidebar-bg); + border-radius: 0; + box-shadow: inset -2px 0 5px -2px rgba(0, 0, 0, 0.24); + } + + .main { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + height: 100%; + overflow: hidden; + padding: 0; + border: 0; + border-radius: 0; + background: var(--panel); + } + + .eyebrow { + margin: 0 0 5px; + color: var(--muted); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + } + + h1, + h2, + p { + margin: 0; + } + + h1 { + font-size: 1.45rem; + letter-spacing: -0.04em; + } + + h2 { + font-size: 1.18rem; + letter-spacing: -0.03em; + } + + .subtitle, + .muted { + color: var(--muted); + line-height: 1.45; + } + + .button-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + } + + .btn { + border: 1px solid var(--line); + border-radius: 7px; + background: rgba(255, 255, 255, 0.84); + padding: 6px 10px; + cursor: pointer; + font-size: 0.84rem; + font-weight: 600; + transition: background 150ms ease, border-color 150ms ease; + white-space: nowrap; + } + + .btn:hover { + border-color: var(--line-strong); + } + + .btn-primary { + border-color: transparent; + background: var(--ink); + color: #f8fbff; + } + + .btn-danger { + color: var(--danger); + } + + .search { + width: 100%; + border: 1px solid var(--line); + border-radius: 7px; + background: rgba(255, 255, 255, 0.82); + padding: 7px 9px; + color: var(--ink); + outline: none; + } + + .search:focus { + border-color: rgba(95, 118, 216, 0.45); + box-shadow: 0 0 0 3px rgba(95, 118, 216, 0.12); + } + + .places { + display: grid; + gap: 2px; + min-height: 0; + overflow: visible; + padding-right: 1px; + } + + .place { + width: 100%; + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + align-items: center; + gap: 7px; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + margin: 1px 0 4px; + padding: 4px 5px; + cursor: pointer; + text-align: left; + color: var(--ink); + font-size: 14px; + user-select: none; + backface-visibility: hidden; + } + + .place[draggable="true"]:hover { + cursor: grab; + } + + .places[data-reordering="true"] .place, + .place.window-sidebar-item-dragging { + cursor: grabbing; + transition: none; + } + + .place.window-sidebar-item-dragging { + opacity: 0.72; + } + + .place[data-drop-position="before"] { + box-shadow: inset 0 2px 0 var(--accent); + } + + .place[data-drop-position="after"] { + box-shadow: inset 0 -2px 0 var(--accent); + } + + .place:hover, + .place[data-active="true"] { + border-color: transparent; + background: rgba(0, 0, 0, 0.05); + } + + .place[data-active="true"] { + color: var(--accent); + background: #e5e7eb; + } + + .place[data-active="true"] .place-icon img, + .place[data-active="true"] .place-icon svg { + filter: brightness(0) saturate(100%) invert(36%) sepia(99%) saturate(1942%) hue-rotate(192deg) brightness(101%) contrast(104%); + } + + .place-icon, + .file-icon { + display: grid; + place-items: center; + width: 18px; + height: 18px; + font-size: 15px; + filter: drop-shadow(0 0 0.2px rgba(51, 51, 51, 0.35)); + } + + .place-icon img, + .place-icon svg { + width: 16px; + height: 16px; + display: block; + } + + .inline-icon { + display: grid; + place-items: center; + pointer-events: none; + } + + .inline-icon img, + .inline-icon svg { + width: 100%; + height: 100%; + display: block; + } + + .place-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + } + + .window-sidebar-title { + margin: 0 0 5px; + padding-left: 1px; + color: var(--muted); + cursor: default; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + } + + .toolbar { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; + border-bottom: 1px solid var(--line); + min-height: 48px; + padding: 5px 5px 5px 1px; + margin-bottom: 0; + background: #fafafa; + } + + .toolbar-left, + .toolbar-right { + min-width: 0; + display: flex; + gap: 6px; + align-items: center; + } + + .toolbar-left { + flex: 1 1 auto; + } + + .toolbar-right { + justify-content: flex-end; + flex: 0 1 auto; + } + + .toolbar .search { + width: 150px; + padding: 5px 8px; + } + + .navbar-btn { + width: 25px; + height: 25px; + border: 1px solid transparent; + border-radius: 999px; + background: transparent; + color: var(--ink); + cursor: pointer; + display: grid; + place-items: center; + margin: 0 2px; + font-size: 16px; + font-weight: 700; + line-height: 1; + } + + .navbar-btn:hover:not(:disabled) { + background: #e5e7eb; + } + + .navbar-btn:disabled { + cursor: default; + opacity: 0.38; + } + + .navbar-btn img, + .navbar-btn .inline-icon { + width: 17px; + height: 17px; + display: block; + pointer-events: none; + } + + .pathbar { + min-width: 180px; + flex: 1 1 auto; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 3px; + background: #fbfbfc; + min-height: 35px; + padding: 0 10px; + } + + .breadcrumbs { + display: flex; + flex-wrap: nowrap; + gap: 0; + align-items: center; + min-width: 0; + height: 33px; + overflow: hidden; + } + + .crumb { + border: 0; + background: transparent; + border-radius: 5px; + padding: 0 7px; + cursor: pointer; + max-width: 190px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; + font-size: 14px; + line-height: 33px; + } + + .crumb-current { + background: transparent; + color: var(--ink); + cursor: default; + } + + .path-seperator { + color: var(--muted); + cursor: default; + font-size: 13px; + line-height: 33px; + opacity: 0.7; + user-select: none; + } + + .view-controls { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; + } + + .select { + border: 1px solid var(--line); + border-radius: 7px; + background: #fff; + padding: 5px 8px; + color: var(--ink); + font-weight: 600; + } + + .window-navbar-layout-toggle { + display: inline-flex; + align-items: center; + gap: 2px; + border-radius: 6px; + background: #e5e7eb; + padding: 2px; + } + + .layout-toggle-segment { + width: 28px; + height: 26px; + display: grid; + place-items: center; + border: 0; + border-radius: 5px; + background: transparent; + cursor: pointer; + padding: 0; + } + + .layout-toggle-segment img, + .layout-toggle-segment .inline-icon { + width: 15px; + height: 15px; + display: block; + pointer-events: none; + } + + .layout-toggle-segment-active { + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18); + } + + .content { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding: 7px 0 12px; + background: #fff; + } + + .content[data-view="grid"] { + display: grid; + grid-template-columns: repeat(auto-fill, 120px); + align-content: start; + gap: 0; + padding: 5px 0 0; + } + + .content[data-view="list"] { + display: block; + align-content: start; + overflow-x: auto; + } + + .content[data-empty="true"] { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + place-items: center; + padding: 0; + } + + .item { + position: relative; + border: 0; + background: transparent; + border-radius: 3px; + cursor: default; + user-select: none; + min-width: 0; + } + + .content[data-view="grid"] .item { + display: grid; + grid-template-rows: 45px auto 18px; + gap: 4px; + justify-items: center; + width: 110px; + height: 90px; + min-height: 90px; + margin: 15px 5px 18px; + padding: 0; + text-align: center; + } + + .content[data-view="list"] .item { + display: grid; + grid-template-columns: var(--explorer-list-columns); + grid-template-rows: 24px; + gap: 0; + align-items: center; + min-width: 726px; + height: 24px; + padding: 0; + text-align: left; + } + + .content[data-view="list"] .item:nth-child(2n+3):not([data-selected="true"]) { + background: #f7f8fa; + } + + .content[data-view="list"] .item[data-selected="true"] { + background: var(--accent); + color: var(--ink); + } + + .content[data-view="grid"] .item[data-selected="true"] .item-name { + background: var(--brand); + color: #fff; + } + + .content[data-view="grid"] .item[data-selected="true"] .file-icon { + background: rgba(212, 212, 212, 0.19); + border-radius: 3px; + filter: drop-shadow(0 0 1px rgba(102, 102, 102, 1)); + } + + .content[data-view="grid"] .file-icon { + width: 45px; + height: 45px; + line-height: 1; + } + + .content[data-view="grid"] .file-icon img, + .content[data-view="grid"] .file-icon svg { + max-width: 45px; + max-height: 45px; + display: block; + } + + .content[data-view="list"] .file-icon { + grid-column: 1; + grid-row: 1; + justify-self: center; + width: 15px; + height: 15px; + } + + .content[data-view="list"] .file-icon img, + .content[data-view="list"] .file-icon svg { + max-width: 15px; + max-height: 15px; + display: block; + } + + .item-name { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: pre-wrap; + word-break: break-word; + font-size: 13px; + font-weight: 500; + border-radius: 4px; + padding: 2px 4px; + color: var(--ink); + } + + .content[data-view="grid"] .item-name { + max-width: 110px; + text-align: center; + } + + .content[data-view="list"] .item-name { + grid-column: 2; + grid-row: 1; + align-self: center; + max-width: 100%; + white-space: nowrap; + height: 24px; + line-height: 24px; + padding: 0 86px 0 0; + } + + .content[data-view="list"] .item[data-selected="true"] .item-name { + color: var(--ink); + } + + .content[data-view="list"] .item[data-selected="true"] .item-size, + .content[data-view="list"] .item[data-selected="true"] .item-date, + .content[data-view="list"] .item[data-selected="true"] .item-type { + color: var(--muted); + } + + .rename-input { + width: 100%; + border: 1px solid rgba(0, 122, 255, 0.45); + border-radius: 4px; + padding: 2px 4px; + color: var(--ink); + background: #fff; + outline: none; + text-align: inherit; + font-size: 13px; + font-weight: 500; + } + + .item-meta, + .item-size, + .item-date, + .item-type { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--muted); + font-size: 12px; + } + + .content[data-view="grid"] .item-size, + .content[data-view="grid"] .item-date, + .content[data-view="grid"] .item-type { + display: none; + } + + .content[data-view="list"] .item-date { + grid-column: 3; + grid-row: 1; + padding: 0 10px; + } + + .content[data-view="list"] .item-size { + grid-column: 4; + grid-row: 1; + padding: 0 10px; + text-align: right; + } + + .content[data-view="list"] .item-type { + grid-column: 5; + grid-row: 1; + padding: 0 10px; + } + + .badges { + display: flex; + gap: 4px; + flex-wrap: wrap; + align-items: center; + justify-content: center; + pointer-events: none; + } + + .content[data-view="grid"] .badges { + grid-row: 3; + max-width: 110px; + align-self: start; + } + + .content[data-view="list"] .badges { + grid-column: 2; + grid-row: 1; + justify-self: end; + align-self: center; + max-width: 82px; + height: 24px; + margin-right: 8px; + overflow: hidden; + } + + .badge { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 2px 5px; + background: rgba(60, 60, 67, 0.08); + color: var(--muted); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + line-height: 1.2; + white-space: nowrap; + } + + .badge-published { + background: rgba(47, 143, 104, 0.12); + color: var(--good); + } + + .badge-public-folder { + background: rgba(38, 112, 153, 0.12); + color: #0b5e8e; + } + + .badge-trash { + background: rgba(177, 76, 90, 0.12); + color: var(--danger); + } + + .badge-blocked { + background: rgba(181, 121, 24, 0.14); + color: var(--warn); + } + + .explore-table-headers { + display: grid; + grid-template-columns: var(--explorer-list-columns); + min-width: 726px; + height: 25px; + align-items: center; + border-bottom: 1px solid var(--line); + background: #f9fafb; + color: #555c61; + font-size: 12px; + line-height: 25px; + position: sticky; + top: -7px; + z-index: 1; + margin-top: -7px; + margin-bottom: 8px; + } + + .explore-table-headers-th { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .explore-table-headers-th--name { + grid-column: 1 / span 2; + padding-left: 45px; + } + + .explore-table-headers-th--modified { + grid-column: 3; + padding: 0 10px; + } + + .explore-table-headers-th--size { + grid-column: 4; + padding: 0 10px; + text-align: right; + } + + .explore-table-headers-th--type { + grid-column: 5; + padding: 0 10px; + } + + .empty { + box-sizing: border-box; + display: grid; + place-items: center; + min-height: 100%; + height: 100%; + width: 100%; + padding: 28px; + text-align: center; + color: var(--muted); + } + + .empty-inner { + max-width: 380px; + margin: 0 auto; + } + + .upload-progress { + display: grid; + gap: 8px; + margin: 8px 10px 0; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.62); + padding: 10px; + } + + .upload-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(110px, 24%); + gap: 10px; + align-items: center; + color: var(--muted); + font-size: 0.82rem; + } + + .upload-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--ink); + font-weight: 700; + } + + .upload-meter { + height: 8px; + overflow: hidden; + border-radius: 999px; + background: var(--accent-soft); + } + + .upload-fill { + width: var(--progress, 0%); + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--accent), var(--brand)); + transition: width 160ms ease; + } + + .statusbar { + flex: 0 0 auto; + margin-top: auto; + display: flex; + justify-content: space-between; + gap: 12px; + min-height: 38px; + border-top: 1px solid rgba(83, 103, 164, 0.1); + padding: 6px 12px 14px; + color: var(--muted); + font-size: 13px; + min-width: 0; + background: #fafafa; + user-select: none; + } + + .statusbar span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .context-menu { + position: fixed; + z-index: 80; + width: 218px; + max-width: calc(100vw - 16px); + max-height: calc(100vh - 16px); + overflow: visible; + border: 1px solid rgba(0, 0, 0, 0.18); + border-radius: 7px; + background: rgba(248, 248, 248, 0.98); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.22); + padding: 5px 0; + color: #1f2328; + white-space: normal; + user-select: none; + scrollbar-width: thin; + } + + .menu-entry { + position: relative; + } + + .menu-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; + border: 0; + border-radius: 0; + background: transparent; + min-height: 25px; + padding: 4px 12px 4px 18px; + cursor: default; + text-align: left; + font-size: 12px; + font-weight: 400; + line-height: 17px; + white-space: nowrap; + color: inherit; + } + + .menu-item:hover { + background: #0a84ff; + color: #fff; + } + + .menu-item-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + .menu-item-mark { + flex: 0 0 auto; + color: rgba(31, 35, 40, 0.56); + font-size: 12px; + } + + .menu-item-arrow { + font-size: 15px; + line-height: 12px; + } + + .menu-item:hover .menu-item-mark { + color: rgba(255, 255, 255, 0.74); + } + + .menu-item[disabled], + .menu-item[disabled]:hover { + cursor: default; + opacity: 0.5; + background: transparent; + color: var(--ink); + } + + .menu-divider { + height: 1px; + margin: 4px 8px; + background: rgba(0, 0, 0, 0.12); + } + + .menu-divider hr { + display: none; + } + + .menu-submenu { + position: absolute; + top: -5px; + left: calc(100% - 4px); + display: none; + width: 218px; + max-width: calc(100vw - 16px); + border: 1px solid rgba(0, 0, 0, 0.18); + border-radius: 7px; + background: rgba(248, 248, 248, 0.98); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.22); + padding: 5px 0; + color: #1f2328; + z-index: 81; + } + + .context-menu[data-submenu-side="left"] .menu-submenu { + left: auto; + right: calc(100% - 4px); + } + + .menu-entry:hover > .menu-submenu, + .menu-entry:focus-within > .menu-submenu, + .menu-entry[data-open="true"] > .menu-submenu { + display: block; + } + + .dialog-backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; + background: rgba(29, 36, 56, 0.18); + backdrop-filter: blur(4px); + padding: 18px; + } + + .dialog-card { + width: min(560px, 100%); + max-height: calc(100vh - 36px); + max-height: calc(100dvh - 36px); + padding: 20px; + display: grid; + gap: 14px; + overflow: auto; + overscroll-behavior: contain; + } + + .dialog-card form { + display: grid; + gap: 14px; + } + + .dialog-card-wide { + width: min(760px, 100%); + } + + .dialog-card-wide .details-json pre { + max-height: min(20vh, 180px); + } + + .properties-card.window-item-properties { + width: min(450px, 100%); + padding: 0; + display: block; + overflow: hidden; + background: rgb(241 242 246); + border-radius: 6px; + } + + .properties-window-title { + min-height: 32px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 10px; + color: #1d1d1f; + font-size: 0.83rem; + font-weight: 600; + border-bottom: 1px solid rgba(60, 60, 67, 0.16); + background: linear-gradient(180deg, #f8f8f9, #eceef3); + } + + .properties-window-title button { + width: 22px; + height: 22px; + display: grid; + place-items: center; + border: 1px solid rgba(60, 60, 67, 0.2); + border-radius: 999px; + background: #fff; + color: #3f4650; + cursor: pointer; + line-height: 1; + } + + .item-props-tabview { + display: flex; + flex-direction: column; + min-height: 240px; + padding: 10px; + } + + .item-props-tab { + min-height: 36px; + white-space: nowrap; + } + + .item-props-tab-content { + display: none; + flex-grow: 1; + max-height: min(58vh, 430px); + margin-top: -1px; + padding: 5px 10px; + overflow: auto; + border: 1px solid #ccc; + border-radius: 3px; + background: #fff; + color: #333; + } + + .item-props-tab-content-selected { + display: block; + background: #fff; + } + + .item-props-tab-btn { + display: inline-block; + margin-right: 10px; + margin-bottom: -1px; + padding: 10px 15px; + cursor: pointer; + border: 1px solid transparent; + border-top-left-radius: 3px; + border-top-right-radius: 3px; + color: #374653; + font-size: 0.84rem; + } + + .item-props-tab-btn:hover { + color: #1a1a1a; + } + + .item-props-tab-selected { + position: relative; + margin-bottom: -1px; + border: 1px solid #ccc; + border-bottom: none; + background: #fff; + color: #000; + } + + .item-props-tbl { + width: 100%; + border-collapse: collapse; + font-size: 13px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + .item-props-tbl td { + padding: 0 0 10px; + vertical-align: top; + word-break: break-all; + } + + .item-prop-label { + width: 1%; + padding-right: 10px !important; + color: var(--muted); + font-weight: 500; + text-align: left; + white-space: nowrap; + } + + .item-prop-val { + color: var(--ink); + overflow-wrap: anywhere; + } + + .props-copy-value { + display: flex; + align-items: flex-start; + gap: 8px; + min-width: 0; + } + + .props-copy-text { + flex: 1; + min-width: 0; + color: #1f2933; + font-family: var(--mono); + font-size: 11px; + line-height: 1.45; + white-space: normal; + word-break: break-all; + } + + .props-copy-btn { + width: 26px; + height: 24px; + display: inline-grid; + flex: 0 0 auto; + place-items: center; + border: 1px solid #d1d5db; + border-radius: 3px; + background: #fff; + color: #374151; + cursor: pointer; + } + + .props-copy-btn:hover { + background: #f3f4f6; + } + + .props-copy-btn.copied { + border-color: #8fd19e; + background: #d4edda; + color: #155724; + } + + .item-prop-badge { + display: inline-flex; + align-items: center; + min-height: 20px; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + line-height: 1.3; + } + + .item-prop-badge-public { + background: #d4edda; + color: #155724; + } + + .item-prop-badge-shared { + background: #dbeafe; + color: #1e3a8a; + } + + .item-prop-badge-staged { + background: #e0f2fe; + color: #075985; + } + + .item-prop-badge-private { + background: #eceff4; + color: #374151; + } + + .item-prop-badge-readonly { + background: #fff3cd; + color: #856404; + } + + .item-prop-badge-writable { + background: #e0f2fe; + color: #075985; + } + + .item-prop-badge-neutral { + background: #eceff4; + color: #374151; + } + + .properties-window-actions { + display: flex; + justify-content: flex-end; + padding: 0 10px 10px; + } + + .details { + display: grid; + gap: 8px; + font-family: var(--mono); + font-size: 0.84rem; + word-break: break-all; + } + + .details-json { + display: grid; + gap: 6px; + font-family: var(--mono); + font-size: 0.82rem; + color: var(--muted); + } + + .details-json pre { + max-height: min(28vh, 260px); + margin: 0; + overflow: auto; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.68); + padding: 10px; + color: var(--ink); + white-space: pre-wrap; + word-break: break-word; + } + + .share-options { + display: grid; + gap: 8px; + } + + .share-option { + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + gap: 10px; + align-items: start; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.72); + padding: 10px; + cursor: pointer; + } + + .share-option input { + margin-top: 2px; + } + + .share-option:has(input:disabled) { + cursor: not-allowed; + opacity: 0.62; + } + + .share-option span, + .dialog-field { + display: grid; + gap: 4px; + } + + .share-option small, + .dialog-hint, + .dialog-error { + color: var(--muted); + line-height: 1.4; + } + + .dialog-field span { + font-weight: 700; + font-size: 0.82rem; + } + + .dialog-field textarea { + width: 100%; + resize: vertical; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.84); + padding: 9px; + color: var(--ink); + font: inherit; + outline: none; + } + + .dialog-field textarea:focus { + border-color: rgba(0, 122, 255, 0.45); + box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1); + } + + .dialog-error { + color: var(--danger); + font-weight: 700; + } + + .preview-frame { + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--panel-soft); + overflow: hidden; + } + + .preview-frame img, + .preview-frame video, + .preview-frame audio, + .preview-frame iframe { + display: block; + width: 100%; + border: 0; + } + + .preview-frame img, + .preview-frame video, + .preview-frame iframe { + max-height: min(62vh, 620px); + } + + .preview-frame img { + object-fit: contain; + background: #101014; + } + + .preview-text { + max-height: min(62vh, 620px); + margin: 0; + padding: 14px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-family: var(--mono); + font-size: 0.82rem; + line-height: 1.55; + } + + @media (max-width: 980px) { + .shell { + grid-template-columns: 1fr; + grid-template-rows: auto minmax(0, 1fr); + overflow: auto; + } + + .sidebar { + overflow: visible; + } + + .places { + grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); + overflow: visible; + } + } + + @media (max-width: 720px) { + .shell { + gap: 8px; + padding: 4px; + } + + .sidebar, + .main, + .locked-card { + border-radius: 14px; + box-shadow: 0 10px 26px rgba(34, 49, 88, 0.08); + } + + .sidebar, + .main { + padding: 10px; + } + + .toolbar { + display: grid; + gap: 8px; + } + + .toolbar-left, + .toolbar-right { + flex-wrap: wrap; + } + + .pathbar { + order: 10; + width: 100%; + flex-basis: 100%; + } + + .view-controls { + justify-content: flex-start; + } + + .content[data-view="grid"] { + grid-template-columns: repeat(auto-fill, 120px); + } + + .content[data-view="list"] .item { + grid-template-columns: 30px minmax(0, 1fr); + min-width: 0; + } + + .content[data-view="list"] .item-size, + .content[data-view="list"] .item-date, + .content[data-view="list"] .item-type, + .content[data-view="list"] .badges { + display: none; + } + } diff --git a/capsules/library/src/actions.js b/capsules/library/src/actions.js new file mode 100644 index 00000000..1fe4d385 --- /dev/null +++ b/capsules/library/src/actions.js @@ -0,0 +1,618 @@ +import { + archiveLibraryObjectPayload, + baseName, + canPreviewObject, + childUri, + contentCid, + hasCapability, + inTrash, + isArchiveObject, + isBlockedObject, + isDirectory, + isTrashRootUri, + isTrashUri, + isWebSpaceUri, + parentUri, + publishedCid, + viewerOptions, +} from "./model.js"; + +export function createLibraryActions({ + clearSelection, + closeSelf, + confirmDestructive, + currentFolderReadOnly, + deliverToTarget, + downloadObjectRaw, + loadCurrentFolder, + loadRoots, + navigate, + openPublishedUri, + openTarget, + previewObject, + providerApi, + renderUploads, + selectedObjects, + setStatus, + setUploadProgress, + showMenuForObject, + showObjectStatus, + showProperties, + showShareDialog, + showShareReceipt, + showSharedAccessReceipt, + startCreateObject, + state, + uploadObject, +}) { + async function openObject(object) { + if (!object) return; + if (isDirectory(object)) { + await navigate(object.uri); + return; + } + if (isBlockedObject(object)) { + setStatus("This object is blocked because it is not encrypted for the protected principal root."); + showProperties(object); + return; + } + if (isAttachMode()) { + await attachObject(object); + return; + } + if (isArchiveOpenMode()) { + deliverArchiveObject(object); + return; + } + if (isArchiveObject(object) && openWithViewer(object, "archive-manager")) { + return; + } + const viewer = viewerOptions(object)[0]; + if (viewer && openWithViewer(object, viewer.id)) { + return; + } + if (canPreviewObject(object)) { + await previewObject(object); + return; + } + setStatus("No installed viewer for this object."); + showProperties(object); + } + + function openWithViewer(object, viewer) { + if (!viewer) return false; + const cid = publishedCid(object); + if (object.published && cid) { + openPublishedUri("elastos://" + cid, viewer); + return true; + } + const query = { + objectUri: object.uri, + uri: object.uri, + name: object.name || "", + mime: object.mime || "application/octet-stream", + }; + const localCid = contentCid(object); + if (localCid) query.contentCid = localCid; + if (viewer === "archive-manager" && object.metadata?.archive_support) { + query.archiveSupport = JSON.stringify(object.metadata.archive_support); + } + return openTarget(viewer, query); + } + + async function attachObject(object) { + const cid = publishedCid(object); + if (!object.published || !cid) { + setStatus("Publish this object before attaching it."); + showMenuForObject(object, window.innerWidth / 2, 120); + return; + } + if (deliverToTarget(state.returnTarget, { + type: "chat-room:attach-library-item", + uri: "elastos://" + cid, + title: object.name || "", + objectUri: object.uri, + })) { + setStatus("Attached to Chat Room."); + window.setTimeout(closeSelf, 80); + return; + } + setStatus("Open Chat Room from Home."); + } + + function deliverArchiveObject(object) { + if (!isArchiveObject(object)) { + setStatus("Select a ZIP, tar, tar.gz, or tgz archive."); + return false; + } + const payload = { + type: "archive:open-library-object", + object: archiveLibraryObjectPayload(object), + }; + if (deliverToTarget("archive-manager", payload)) { + setStatus("Opening in Archive."); + window.setTimeout(closeSelf, 80); + return true; + } + if (openWithViewer(object, "archive-manager")) { + return true; + } + setStatus("Open Archive from Home, then choose this archive again."); + return false; + } + + async function createFolder() { + if (currentFolderReadOnly()) { + setStatus("This location is read-only."); + return; + } + startCreateObject("directory"); + } + + async function uploadFiles(files) { + const list = Array.from(files || []); + if (!list.length) return; + if (currentFolderReadOnly()) { + setStatus("This location is read-only."); + return; + } + const batch = Date.now().toString(36); + state.uploads = list.map((file, index) => ({ + id: `${batch}:${index}`, + name: file.name, + progress: 0, + status: "Queued", + })); + renderUploads(); + try { + for (let index = 0; index < list.length; index += 1) { + const file = list[index]; + const upload = state.uploads[index]; + try { + setStatus(`Uploading ${file.name}...`); + setUploadProgress(upload.id, { status: "Preparing", progress: 4 }); + await uploadObject({ + uri: childUri(state.currentUri, file.name), + file, + mime: file.type || "application/octet-stream", + onProgress: (fraction) => { + setUploadProgress(upload.id, { + status: "Uploading through Runtime provider", + progress: Math.max(4, Math.round(fraction * 96)), + }); + }, + }); + setUploadProgress(upload.id, { + status: "Committing through Runtime provider", + progress: 98, + }); + setUploadProgress(upload.id, { status: "Complete", progress: 100 }); + } catch (error) { + setUploadProgress(upload.id, { status: error?.message || "Upload failed" }); + throw error; + } + } + setStatus(`Uploaded ${list.length} file${list.length === 1 ? "" : "s"}.`); + await loadCurrentFolder(); + } finally { + window.setTimeout(() => { + if (state.uploads.every((upload) => upload.progress >= 100)) { + state.uploads = []; + renderUploads(); + } + }, 1_200); + } + } + + async function downloadObject(object) { + const data = await downloadObjectRaw({ uri: object.uri }); + saveDownloadBlob(data.blob, data.filename || object.name || "download"); + } + + async function downloadObjectAsZip(object) { + if (!isDirectory(object)) return; + const data = await downloadObjectRaw({ uri: object.uri, archive: "zip" }); + saveDownloadBlob(data.blob, data.filename || `${object.name || "Library"}.zip`); + setStatus(`Downloaded ${object.name} as ZIP.`); + } + + async function downloadSelectedObjects() { + const objects = downloadableSelectedObjects(); + if (objects.length < 2) return; + const data = await downloadObjectRaw({ uris: objects.map((object) => object.uri) }); + saveDownloadBlob(data.blob, data.filename || "Library Selection.tar.gz"); + setStatus(`Downloaded ${objects.length} selected objects.`); + } + + async function downloadSelectedObjectsAsZip() { + const objects = downloadableSelectedObjects(); + if (objects.length < 2) return; + const data = await downloadObjectRaw({ + uris: objects.map((object) => object.uri), + archive: "zip", + }); + saveDownloadBlob(data.blob, data.filename || "Library Selection.zip"); + setStatus(`Downloaded ${objects.length} selected objects as ZIP.`); + } + + async function compressObjectToZip(object) { + if (!object || !hasCapability(object, "compress_archive")) return; + setStatus(`Compressing ${object.name}...`); + await providerApi("compress_archive", { uri: object.uri, if_revision: object.revision }); + setStatus(`Compressed ${object.name} to ZIP.`); + await loadCurrentFolder(); + } + + async function compressSelectedObjectsToZip() { + const objects = compressibleSelectedObjects(); + if (objects.length < 2) return; + setStatus(`Compressing ${objects.length} selected objects...`); + await providerApi("compress_archive", { uris: objects.map((object) => object.uri) }); + setStatus(`Compressed ${objects.length} selected objects to ZIP.`); + await loadCurrentFolder(); + } + + async function extractArchiveObject(object) { + setStatus(`Extracting ${object.name}...`); + await providerApi("extract_archive", { uri: object.uri, if_revision: object.revision }); + setStatus(`Extracted ${object.name}.`); + await loadCurrentFolder(); + } + + function downloadableSelectedObjects() { + return selectedObjects().filter((object) => ( + object && + !isBlockedObject(object) && + !inTrash(object) && + !isWebSpaceUri(object.uri) && + hasCapability(object, "download") + )); + } + + function compressibleSelectedObjects() { + const objects = selectedObjects(); + const compressible = objects.filter((object) => ( + object && + !isBlockedObject(object) && + !inTrash(object) && + !isWebSpaceUri(object.uri) && + hasCapability(object, "compress_archive") + )); + if (compressible.length !== objects.length || compressible.length < 2) return []; + const parent = parentUri(compressible[0].uri); + return compressible.every((object) => parentUri(object.uri) === parent) ? compressible : []; + } + + function saveDownloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } + + async function publishObject(object) { + setStatus(`Publishing ${object.name}...`); + await providerApi("publish", { uri: object.uri, if_revision: object.revision }); + setStatus(`Published ${object.name}.`); + await loadCurrentFolder(); + } + + async function unpublishObject(object) { + setStatus(`Unpublishing ${object.name}...`); + await providerApi("unpublish", { uri: object.uri, if_revision: object.revision }); + setStatus(`Unpublished ${object.name}.`); + await loadCurrentFolder(); + } + + async function repairObject(object) { + setStatus(`Repairing availability for ${object.name}...`); + await providerApi("repair", { uri: object.uri }); + setStatus(`Repaired availability for ${object.name}.`); + await loadCurrentFolder(); + } + + async function showStatusObject(object) { + setStatus(`Checking availability for ${object.name}...`); + const status = await providerApi("status", { uri: object.uri }); + showObjectStatus(status); + setStatus(`Availability ready for ${object.name}.`); + } + + async function shareObject(object) { + const decision = await showShareDialog(object); + if (!decision) return; + const payload = { uri: object.uri, policy: decision.policy }; + if (decision.policy === "recipient_scoped") { + payload.recipients = decision.recipients; + } + const share = await providerApi("share", payload); + await copyText(share.uri, "share URI"); + showShareReceipt(share); + await loadCurrentFolder(); + } + + async function checkMyAccess(object) { + const principalId = principalIdFromHomeToken(state.homeToken); + if (!principalId) { + setStatus("A signed Home principal is required to check recipient access."); + return; + } + setStatus(`Checking access for ${object.name}...`); + const access = await providerApi("shared_access", { + uri: object.uri, + recipient: principalId, + }); + showSharedAccessReceipt(access); + setStatus(`Access check ready for ${object.name}.`); + } + + async function trashObject(object) { + await providerApi("trash", { uri: object.uri, if_revision: object.revision }); + setStatus(`Moved ${object.name} to Trash.`); + await loadRoots?.(); + await loadCurrentFolder(); + } + + async function restoreObject(object) { + const name = object.name || baseName(object.uri); + await providerApi("restore", { uri: object.uri, if_revision: object.revision }); + setStatus(`Restored ${name}.`); + await loadRoots?.(); + await loadCurrentFolder(); + } + + async function deleteObject(object) { + const trash = inTrash(object); + const confirmed = await confirmDestructive({ + title: "Delete permanently?", + message: trash + ? `${object.name} will be removed from Trash and cannot be restored.` + : `${object.name} will be permanently removed from this provider-owned location. This cannot be restored.`, + confirmLabel: "Delete Permanently", + }); + if (!confirmed) return; + await providerApi("delete_permanently", { uri: object.uri, if_revision: object.revision }); + setStatus(`Deleted ${object.name}.`); + await loadRoots?.(); + await loadCurrentFolder(); + } + + async function runBatchAction(label, objects, action) { + const list = objects.filter(Boolean); + if (!list.length) return; + setStatus(`${label} ${list.length} object${list.length === 1 ? "" : "s"}...`); + for (const object of list) { + await action(object); + } + clearSelection(false); + setStatus(`${label} ${list.length} object${list.length === 1 ? "" : "s"}.`); + await loadCurrentFolder(); + } + + async function publishSelectedObjects() { + const objects = selectedObjects().filter((object) => !isDirectory(object) && !inTrash(object) && !object.published && hasCapability(object, "publish")); + await runBatchAction("Published", objects, (object) => providerApi("publish", { + uri: object.uri, + if_revision: object.revision, + })); + } + + async function unpublishSelectedObjects() { + const objects = selectedObjects().filter((object) => !isDirectory(object) && !inTrash(object) && object.published && hasCapability(object, "unpublish")); + await runBatchAction("Unpublished", objects, (object) => providerApi("unpublish", { + uri: object.uri, + if_revision: object.revision, + })); + } + + async function trashSelectedObjects() { + const objects = selectedObjects().filter((object) => !inTrash(object) && hasCapability(object, "trash")); + await runBatchAction("Moved to Trash", objects, (object) => providerApi("trash", { + uri: object.uri, + if_revision: object.revision, + })); + await loadRoots?.(); + } + + function setClipboard(op, objects) { + const uris = objects + .filter((object) => object && !isBlockedObject(object) && !inTrash(object) && hasCapability(object, op)) + .map((object) => object.uri); + state.clipboard = { + op, + uris, + }; + setStatus(`${op === "move" ? "Cut" : "Copied"} ${uris.length} object${uris.length === 1 ? "" : "s"}.`); + } + + function canPasteInto(targetParentUri) { + return !!( + targetParentUri && + !isWebSpaceUri(targetParentUri) && + state.clipboard.uris.length && + !isTrashRootUri(targetParentUri) && + !isTrashUri(targetParentUri) && + (state.clipboard.op === "copy" || state.clipboard.op === "move") + ); + } + + async function pasteClipboardTo(targetParentUri) { + if (!canPasteInto(targetParentUri)) return; + const uris = [...state.clipboard.uris]; + const op = state.clipboard.op; + for (const uri of uris) { + if (targetParentUri === uri || targetParentUri.startsWith(uri + "/")) { + continue; + } + await providerApi(op === "move" ? "move" : "copy", { + uri, + target_parent_uri: targetParentUri, + }); + } + if (op === "move") { + state.clipboard = { op: "", uris: [] }; + } + setStatus(`${op === "move" ? "Moved" : "Copied"} ${uris.length} object${uris.length === 1 ? "" : "s"}.`); + await loadCurrentFolder(); + } + + async function transferSelectedObjectsTo(targetParentUri, op) { + if (!targetParentUri || isWebSpaceUri(targetParentUri) || isTrashRootUri(targetParentUri) || isTrashUri(targetParentUri)) return; + const capability = op === "move" ? "move" : "copy"; + const objects = selectedObjects().filter((object) => ( + object && + !isBlockedObject(object) && + !inTrash(object) && + hasCapability(object, capability) + )); + let changed = 0; + for (const object of objects) { + if (targetParentUri === object.uri || targetParentUri.startsWith(object.uri + "/")) { + continue; + } + if (op === "move" && parentUri(object.uri) === targetParentUri) { + continue; + } + await providerApi(op, { + uri: object.uri, + target_parent_uri: targetParentUri, + if_revision: object.revision, + }); + changed += 1; + } + if (changed) { + setStatus(`${op === "move" ? "Moved" : "Copied"} ${changed} object${changed === 1 ? "" : "s"}.`); + await loadCurrentFolder(); + } + } + + async function moveSelectedObjectsTo(targetParentUri) { + await transferSelectedObjectsTo(targetParentUri, "move"); + } + + async function copySelectedObjectsTo(targetParentUri) { + await transferSelectedObjectsTo(targetParentUri, "copy"); + } + + async function createTextDocument() { + if (currentFolderReadOnly()) { + setStatus("This location is read-only."); + return; + } + startCreateObject("file"); + } + + async function restoreSelectedObjects() { + const objects = selectedObjects().filter(inTrash); + await runBatchAction("Restored", objects, (object) => providerApi("restore", { + uri: object.uri, + if_revision: object.revision, + })); + await loadRoots?.(); + } + + async function deleteSelectedObjects() { + const objects = selectedObjects().filter((object) => ( + inTrash(object) || (!inTrash(object) && hasCapability(object, "delete_permanently")) + )); + if (!objects.length) return; + const allTrash = objects.every(inTrash); + const confirmed = await confirmDestructive({ + title: "Delete permanently?", + message: allTrash + ? `${objects.length} object${objects.length === 1 ? "" : "s"} will be removed from Trash and cannot be restored.` + : `${objects.length} object${objects.length === 1 ? "" : "s"} will be permanently removed from provider-owned locations. This cannot be restored.`, + confirmLabel: "Delete Permanently", + }); + if (!confirmed) return; + await runBatchAction("Deleted", objects, (object) => providerApi("delete_permanently", { + uri: object.uri, + if_revision: object.revision, + })); + await loadRoots?.(); + } + + async function emptyTrash() { + const confirmed = await confirmDestructive({ + title: "Empty Trash?", + message: "Every object in Trash will be permanently deleted. This cannot be restored.", + confirmLabel: "Empty Trash", + }); + if (!confirmed) return; + const result = await providerApi("empty_trash", {}); + const count = Number(result?.deleted_count || 0); + setStatus(`Emptied Trash${count ? ` (${count} object${count === 1 ? "" : "s"})` : ""}.`); + await loadRoots?.(); + await loadCurrentFolder(); + } + + async function copyText(value, label) { + await navigator.clipboard.writeText(value); + setStatus(`Copied ${label}.`); + } + + function isAttachMode() { + return state.mode === "attach" && state.returnTarget === "chat-room"; + } + + function isArchiveOpenMode() { + return state.mode === "archive-open" && state.returnTarget === "archive-manager"; + } + + function principalIdFromHomeToken(token) { + try { + const decoded = JSON.parse(atob(base64Padding(String(token || "").replace(/-/g, "+").replace(/_/g, "/")))); + return typeof decoded?.payload?.principal_id === "string" + ? decoded.payload.principal_id.trim() + : ""; + } catch { + return ""; + } + } + + function base64Padding(value) { + const remainder = value.length % 4; + return remainder ? value + "=".repeat(4 - remainder) : value; + } + + return { + attachObject, + canPasteInto, + checkMyAccess, + compressObjectToZip, + compressSelectedObjectsToZip, + copySelectedObjectsTo, + copyText, + createFolder, + createTextDocument, + deleteObject, + deleteSelectedObjects, + downloadObject, + downloadObjectAsZip, + downloadSelectedObjects, + downloadSelectedObjectsAsZip, + emptyTrash, + extractArchiveObject, + moveSelectedObjectsTo, + openObject, + openWithViewer, + pasteClipboardTo, + publishObject, + publishSelectedObjects, + repairObject, + restoreObject, + restoreSelectedObjects, + setClipboard, + shareObject, + showStatusObject, + trashObject, + trashSelectedObjects, + unpublishObject, + unpublishSelectedObjects, + uploadFiles, + }; +} diff --git a/capsules/library/src/api.js b/capsules/library/src/api.js new file mode 100644 index 00000000..4412be7a --- /dev/null +++ b/capsules/library/src/api.js @@ -0,0 +1,256 @@ +export function createLibraryRuntime({ getHomeToken }) { + const CHUNKED_UPLOAD_THRESHOLD_BYTES = 512 * 1024; + const CHUNKED_UPLOAD_BYTES = 512 * 1024; + const CHUNKED_UPLOAD_TRANSPORT = "http-chunk-session"; + + async function providerApi(op, payload) { + const response = await fetch("/api/provider/object/" + encodeURIComponent(op), { + method: "POST", + headers: { + "content-type": "application/json", + "x-elastos-home-token": getHomeToken(), + }, + body: JSON.stringify(payload || {}), + }); + const envelope = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(envelope.message || envelope.error || `Library request failed: ${response.status}`); + } + if (envelope.status === "error") { + throw new Error(envelope.message || "Runtime object provider failed."); + } + return envelope.data || envelope; + } + + function uploadObject({ uri, file, mime, onProgress }) { + if (file?.size > CHUNKED_UPLOAD_THRESHOLD_BYTES) { + return uploadObjectChunked({ uri, file, mime, onProgress }); + } + return uploadObjectRaw({ uri, file, mime, onProgress }); + } + + function uploadObjectRaw({ uri, file, mime, onProgress }) { + return new Promise((resolve, reject) => { + const url = new URL("/api/provider/object/upload", window.location.origin); + url.searchParams.set("uri", uri); + const xhr = new XMLHttpRequest(); + xhr.open("PUT", url.pathname + url.search); + xhr.setRequestHeader("x-elastos-home-token", getHomeToken()); + xhr.setRequestHeader("content-type", mime || file?.type || "application/octet-stream"); + xhr.upload.onprogress = (event) => { + if (event.lengthComputable && typeof onProgress === "function") { + onProgress(event.loaded / Math.max(event.total, 1)); + } + }; + xhr.onerror = () => reject(new Error("Library upload failed before Runtime accepted the object.")); + xhr.onload = () => { + let envelope = {}; + try { + envelope = JSON.parse(xhr.responseText || "{}"); + } catch { + envelope = { message: xhr.responseText || "" }; + } + if (xhr.status < 200 || xhr.status >= 300) { + reject(new Error(uploadFailureMessage(xhr, envelope))); + return; + } + if (envelope.status === "error") { + reject(new Error(envelope.message || "Runtime object provider upload failed.")); + return; + } + resolve(envelope.data || envelope); + }; + xhr.send(file); + }); + } + + async function uploadObjectChunked({ uri, file, mime, onProgress }) { + const contentType = mime || file?.type || "application/octet-stream"; + const start = await uploadJsonRequest("/api/provider/object/upload/start", { + uri, + mime: contentType, + size_bytes: file.size, + transport: CHUNKED_UPLOAD_TRANSPORT, + }); + const uploadId = start.upload_id; + try { + let offset = 0; + while (offset < file.size) { + const end = Math.min(offset + CHUNKED_UPLOAD_BYTES, file.size); + const chunk = file.slice(offset, end, contentType); + await uploadChunkRequest({ + uploadId, + offset, + chunk, + mime: contentType, + onProgress: (loaded) => { + if (typeof onProgress === "function") { + onProgress((offset + loaded) / Math.max(file.size, 1)); + } + }, + }); + offset = end; + if (typeof onProgress === "function") { + onProgress(offset / Math.max(file.size, 1)); + } + } + return await uploadJsonRequest(`/api/provider/object/upload/${encodeURIComponent(uploadId)}/finish`, null); + } catch (error) { + await cancelUploadSession(uploadId); + throw error; + } + } + + async function uploadJsonRequest(path, payload) { + const response = await fetch(path, { + method: "POST", + headers: { + "content-type": "application/json", + "x-elastos-home-token": getHomeToken(), + }, + body: payload ? JSON.stringify(payload) : undefined, + }); + const text = await response.text(); + const envelope = parseEnvelope(text); + if (!response.ok || envelope.status === "error") { + throw new Error(uploadFailureMessage(responseLike(response, text), envelope)); + } + return envelope.data || envelope; + } + + function uploadChunkRequest({ uploadId, offset, chunk, mime, onProgress }) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("PUT", `/api/provider/object/upload/${encodeURIComponent(uploadId)}/chunk`); + xhr.setRequestHeader("x-elastos-home-token", getHomeToken()); + xhr.setRequestHeader("x-elastos-upload-offset", String(offset)); + xhr.setRequestHeader("content-type", mime || "application/octet-stream"); + xhr.upload.onprogress = (event) => { + if (event.lengthComputable && typeof onProgress === "function") { + onProgress(event.loaded); + } + }; + xhr.onerror = () => reject(new Error("Library chunk upload failed before Runtime accepted the object.")); + xhr.onload = () => { + const envelope = parseEnvelope(xhr.responseText || ""); + if (xhr.status < 200 || xhr.status >= 300 || envelope.status === "error") { + reject(new Error(uploadFailureMessage(xhr, envelope))); + return; + } + resolve(envelope.data || envelope); + }; + xhr.send(chunk); + }); + } + + async function cancelUploadSession(uploadId) { + if (!uploadId) return; + try { + await fetch(`/api/provider/object/upload/${encodeURIComponent(uploadId)}`, { + method: "DELETE", + headers: { + "x-elastos-home-token": getHomeToken(), + }, + }); + } catch { + // Best-effort cleanup; Runtime also expires stale provider-private sessions on new upload starts. + } + } + + function parseEnvelope(text) { + try { + return JSON.parse(text || "{}"); + } catch { + return { message: text || "" }; + } + } + + function responseLike(response, text) { + return { + status: response.status, + responseText: text, + }; + } + + function uploadFailureMessage(xhr, envelope) { + const body = String(envelope.message || envelope.error || xhr.responseText || ""); + if (xhr.status === 413 || /request entity too large|nginx/i.test(body)) { + return "Upload rejected by the public gateway body-size limit before Runtime accepted the object. Large files use Runtime chunked upload sessions; if this appears during a chunked upload, increase the edge proxy limit for /api/provider/object/upload/*/chunk or lower the configured chunk size."; + } + return body || `Library upload failed: ${xhr.status}`; + } + + async function downloadObjectRaw({ uri, uris, archive }) { + const url = new URL("/api/provider/object/download/raw", window.location.origin); + const list = Array.isArray(uris) ? uris : [uri]; + for (const item of list) { + if (item) url.searchParams.append("uri", item); + } + if (archive) { + url.searchParams.set("archive", archive); + } + const response = await fetch(url.pathname + url.search, { + method: "GET", + headers: { + "x-elastos-home-token": getHomeToken(), + }, + }); + if (!response.ok) { + throw new Error((await response.text()) || `Library download failed: ${response.status}`); + } + const blob = await response.blob(); + return { + blob, + filename: filenameFromContentDisposition(response.headers.get("content-disposition")), + requestId: response.headers.get("x-elastos-request-id") || "", + receipt: transferReceiptFromHeader(response.headers.get("x-elastos-transfer-receipt")), + }; + } + + function transferReceiptFromHeader(header) { + try { + return header ? JSON.parse(header) : null; + } catch { + return null; + } + } + + function filenameFromContentDisposition(header) { + const match = String(header || "").match(/filename="([^"]+)"/); + return match ? match[1] : ""; + } + + function shellMessage(message) { + if (window.parent && window.parent !== window) { + window.parent.postMessage({ ...message, homeToken: getHomeToken() }, window.location.origin); + return true; + } + return false; + } + + function openTarget(target, query) { + return shellMessage({ type: "home:open-target", target, query: query || {} }); + } + + function openPublishedUri(uri, preferredViewer) { + return shellMessage({ type: "home:open-uri", uri, preferredViewer: preferredViewer || "documents" }); + } + + function deliverToTarget(target, payload) { + return shellMessage({ type: "home:deliver-to-target", target, payload: payload || {} }); + } + + function closeSelf() { + return shellMessage({ type: "home:close-self" }); + } + + return { + providerApi, + uploadObject, + downloadObjectRaw, + openTarget, + openPublishedUri, + deliverToTarget, + closeSelf, + }; +} diff --git a/capsules/library/src/app.js b/capsules/library/src/app.js new file mode 100644 index 00000000..67d2ed2f --- /dev/null +++ b/capsules/library/src/app.js @@ -0,0 +1,1022 @@ +import { + archiveLibraryObjectPayload, + baseName, + contentCid, + escapeHtml, + hasCapability, + inTrash, + isBlockedObject, + isDirectory, + isTrashRootUri, + isTrashUri, + isWebSpaceUri, + parentUri, + publishedCid, + viewerOptions, +} from "./model.js"; +import { createLibraryRuntime } from "./api.js"; +import { createLibraryActions } from "./actions.js"; +import { createLibraryDialog } from "./dialog.js"; +import { createLibraryEditor } from "./editor.js"; +import { bindLibraryEvents } from "./events.js"; +import { createLibraryMenu } from "./menu.js"; +import { createLibraryNavigation } from "./navigation.js"; +import { createLibraryPreview } from "./preview.js"; +import { createLibraryRealtime } from "./realtime.js"; +import { createLibraryRenderer, iconPlaceholder } from "./render.js"; +import { createLibrarySelection } from "./selection.js"; +import { + MUTATING_PROVIDER_OPS, + cacheFolderListing, + createLibraryState, + setLibraryObjects, + visibleObjectsForState, +} from "./state.js"; +import { createLibraryUploads } from "./uploads.js"; + + const queryParams = new URLSearchParams(window.location.search); + const { state, perf } = createLibraryState({ + queryParams, + storage: localStorage, + perfTarget: (window.__libraryPerf = window.__libraryPerf || {}), + }); + const { + providerApi: runtimeProviderApi, + uploadObject, + downloadObjectRaw, + openTarget, + openPublishedUri, + deliverToTarget, + closeSelf, + } = createLibraryRuntime({ getHomeToken: () => state.homeToken }); + + const elements = { + lockedShell: document.getElementById("locked-shell"), + libraryShell: document.getElementById("library-shell"), + places: document.getElementById("places"), + backButton: document.getElementById("back-button"), + forwardButton: document.getElementById("forward-button"), + upButton: document.getElementById("up-button"), + uploadButton: document.getElementById("upload-button"), + newFolderButton: document.getElementById("new-folder-button"), + pickerActionButton: document.getElementById("picker-action-button"), + search: document.getElementById("search"), + currentTitle: document.getElementById("current-title"), + statusText: document.getElementById("status-text"), + refreshButton: document.getElementById("refresh-button"), + gridButton: document.getElementById("grid-button"), + listButton: document.getElementById("list-button"), + sortSelect: document.getElementById("sort-select"), + breadcrumbs: document.getElementById("breadcrumbs"), + content: document.getElementById("content"), + footerLeft: document.getElementById("footer-left"), + footerRight: document.getElementById("footer-right"), + fileInput: document.getElementById("file-input"), + uploadProgress: document.getElementById("upload-progress"), + contextMenu: document.getElementById("context-menu"), + dialog: document.getElementById("dialog"), + sidebar: document.querySelector(".sidebar"), + }; + let renderContent = () => {}; + let renderFooter = () => {}; + let scheduleContentRender = () => {}; + let syncContentViewMode = () => {}; + let syncViewButtons = () => {}; + let previewObject = async () => {}; + let revokePreviewUrl = () => {}; + let startCreateObject = () => {}; + let startRename = () => {}; + let startLibraryEventStream = () => {}; + let stopLibraryEventStream = () => {}; + let canPasteInto = () => false; + let checkMyAccess = async () => {}; + let compressObjectToZip = async () => {}; + let compressSelectedObjectsToZip = async () => {}; + let copySelectedObjectsTo = async () => {}; + let copyText = async () => {}; + let createFolder = async () => {}; + let createTextDocument = async () => {}; + let deleteObject = async () => {}; + let deleteSelectedObjects = async () => {}; + let downloadObject = async () => {}; + let downloadObjectAsZip = async () => {}; + let downloadSelectedObjects = async () => {}; + let downloadSelectedObjectsAsZip = async () => {}; + let emptyTrash = async () => {}; + let extractArchiveObject = async () => {}; + let moveSelectedObjectsTo = async () => {}; + let openObject = async () => {}; + let openWithViewer = () => false; + let pasteClipboardTo = async () => {}; + let publishObject = async () => {}; + let publishSelectedObjects = async () => {}; + let repairObject = async () => {}; + let restoreObject = async () => {}; + let restoreSelectedObjects = async () => {}; + let setClipboard = () => {}; + let shareObject = async () => {}; + let showStatusObject = async () => {}; + let trashObject = async () => {}; + let trashSelectedObjects = async () => {}; + let unpublishObject = async () => {}; + let unpublishSelectedObjects = async () => {}; + let uploadFiles = async () => {}; + const { + clearSelection, + isSelected, + objectByUri, + prepareDragSelection, + selectAllVisible, + selectedObjects, + selectOnly, + selectRangeTo, + syncSelectionDom, + toggleSelected, + } = createLibrarySelection({ + content: elements.content, + renderFooter: () => renderFooter(), + state, + visibleObjects, + }); + const { + handleBrowserPopState, + installBrowserHistory, + navigate, + navigateBack, + navigateForward, + navigateUp, + syncNavigationButtons, + } = createLibraryNavigation({ + backButton: elements.backButton, + forwardButton: elements.forwardButton, + loadCurrentFolder, + parentUri, + rootForUri, + searchInput: elements.search, + setStatus, + state, + syncRouteChrome, + upButton: elements.upButton, + }); + const { + renderUploads, + setUploadProgress, + } = createLibraryUploads({ + container: elements.uploadProgress, + perf, + state, + }); + const { + hideMenu, + menuAction, + renderMenu, + } = createLibraryMenu({ + contextMenu: elements.contextMenu, + perf, + showError, + }); + const { + bindDialogEvents, + confirmDestructive, + hideDialog, + showObjectStatus, + showProperties, + showShareDialog, + showShareReceipt, + showSharedAccessReceipt, + } = createLibraryDialog({ + copyText: (...args) => copyText(...args), + dialog: elements.dialog, + hideMenu, + objectByUri, + onBeforeClose: () => revokePreviewUrl(), + }); + ({ + previewObject, + revokePreviewUrl, + } = createLibraryPreview({ + dialog: elements.dialog, + providerApi, + setStatus, + showProperties, + state, + })); + ({ + renderContent, + renderFooter, + scheduleContentRender, + syncContentViewMode, + syncViewButtons, + } = createLibraryRenderer({ + elements, + isSelected, + perf, + selectedObjects, + state, + visibleObjects, + })); + ({ + startCreateObject, + startRename, + } = createLibraryEditor({ + content: elements.content, + loadCurrentFolder, + providerApi, + renderContent, + setObjects, + setStatus, + showError, + state, + })); + ({ + startLibraryEventStream, + stopLibraryEventStream, + } = createLibraryRealtime({ + loadCurrentFolder, + parentUri, + showError, + state, + })); + ({ + canPasteInto, + checkMyAccess, + compressObjectToZip, + compressSelectedObjectsToZip, + copySelectedObjectsTo, + copyText, + createFolder, + createTextDocument, + deleteObject, + deleteSelectedObjects, + downloadObject, + downloadObjectAsZip, + downloadSelectedObjects, + downloadSelectedObjectsAsZip, + emptyTrash, + extractArchiveObject, + moveSelectedObjectsTo, + openObject, + openWithViewer, + pasteClipboardTo, + publishObject, + publishSelectedObjects, + repairObject, + restoreObject, + restoreSelectedObjects, + setClipboard, + shareObject, + showStatusObject, + trashObject, + trashSelectedObjects, + unpublishObject, + unpublishSelectedObjects, + uploadFiles, + } = createLibraryActions({ + clearSelection, + closeSelf, + confirmDestructive, + currentFolderReadOnly, + deliverToTarget, + downloadObjectRaw, + loadCurrentFolder, + loadRoots, + navigate, + openPublishedUri, + openTarget, + previewObject, + providerApi, + renderUploads, + selectedObjects, + setStatus, + setUploadProgress, + showMenuForObject, + showObjectStatus, + showProperties, + showShareDialog, + showShareReceipt, + showSharedAccessReceipt, + startCreateObject, + state, + uploadObject, + })); + + function isAttachMode() { + return state.mode === "attach" && state.returnTarget === "chat-room"; + } + + function isArchiveOpenMode() { + return state.mode === "archive-open" && state.returnTarget === "archive-manager"; + } + + function isArchiveCreateMode() { + return state.mode === "archive-create" && state.returnTarget === "archive-manager"; + } + + function isArchivePickerMode() { + return isArchiveOpenMode() || isArchiveCreateMode(); + } + + function setStatus(text) { + elements.statusText.textContent = text; + elements.statusText.classList.toggle("hidden", !text); + } + + async function providerApi(op, payload) { + const result = await runtimeProviderApi(op, payload); + if (MUTATING_PROVIDER_OPS.has(op)) { + state.folderCache.clear(); + state.folderPrefetches.clear(); + perf.folderCacheSize = 0; + } + return result; + } + + function setObjects(objects) { + setLibraryObjects(state, objects); + } + + function rootForUri(uri) { + return activeRootForUri(uri) || state.roots[0] || null; + } + + function activeRootForUri(uri) { + const value = String(uri || ""); + return state.roots + .filter((root) => value === root.uri || value.startsWith(root.uri + "/")) + .sort((left, right) => right.uri.length - left.uri.length)[0] || null; + } + + function currentFolderReadOnly() { + if (isTrashRootUri(state.currentUri) || isTrashUri(state.currentUri)) return true; + if (!isWebSpaceUri(state.currentUri)) return false; + const folderObject = state.currentObject || objectByUri(state.currentUri); + return folderObject?.metadata?.readonly !== false; + } + + function setFolderStatus(text) { + if (isArchivePickerMode()) { + setStatus(""); + return; + } + setStatus(text); + } + + function syncModeChrome() { + elements.pickerActionButton.classList.toggle("hidden", !isArchivePickerMode()); + if (isAttachMode()) { + setStatus("Choose a published object for Chat Room."); + elements.uploadButton.textContent = "Upload"; + return; + } + if (isArchiveOpenMode()) { + elements.pickerActionButton.textContent = "Open in Archive"; + setStatus(""); + return; + } + if (isArchiveCreateMode()) { + elements.pickerActionButton.textContent = "Create ZIP"; + setStatus(""); + return; + } + setStatus("Ready."); + } + + async function completeArchivePicker() { + if (isArchiveOpenMode()) { + const selection = selectedObjects(); + if (selection.length !== 1) { + setStatus("Select one archive to open."); + return; + } + await openObject(selection[0]); + return; + } + if (!isArchiveCreateMode()) return; + const objects = archiveCreateSelection(); + if (!objects.length) { + setStatus("Select one compressible item, or several same-folder items."); + return; + } + setStatus(objects.length === 1 ? `Creating ${objects[0].name}.zip...` : `Creating ZIP from ${objects.length} items...`); + const response = objects.length === 1 + ? await providerApi("compress_archive", { uri: objects[0].uri, if_revision: objects[0].revision }) + : await providerApi("compress_archive", { uris: objects.map((object) => object.uri) }); + const archiveObject = response?.object; + await loadCurrentFolder(); + if (archiveObject && deliverArchiveToArchive(archiveObject)) { + setStatus(`Created ${archiveObject.name || "archive"} and opened it in Archive.`); + return; + } + setStatus("ZIP created. Select it and press Open in Archive."); + } + + function deliverArchiveToArchive(object) { + const payload = { + type: "archive:open-library-object", + object: archiveLibraryObjectPayload(object), + }; + if (deliverToTarget("archive-manager", payload) || openWithViewer(object, "archive-manager")) { + window.setTimeout(closeSelf, 80); + return true; + } + return false; + } + + function archiveCreateSelection() { + const objects = selectedObjects(); + const compressible = objects.filter((object) => ( + object && + !isBlockedObject(object) && + !inTrash(object) && + !isWebSpaceUri(object.uri) && + hasCapability(object, "compress_archive") + )); + if (compressible.length !== objects.length || !compressible.length) return []; + if (compressible.length === 1) return compressible; + const parent = parentUri(compressible[0].uri); + return compressible.every((object) => parentUri(object.uri) === parent) ? compressible : []; + } + + async function loadRoots() { + const data = await providerApi("roots"); + state.roots = orderRoots(Array.isArray(data.roots) ? data.roots : []); + if (!state.currentUri && state.roots.length) { + const documents = state.roots.find((root) => root.id === "documents"); + const initialRoot = state.initialUri ? rootForUri(state.initialUri) : null; + state.currentUri = initialRoot ? state.initialUri : (documents || state.roots[0]).uri; + } + renderPlaces(); + } + + function orderRoots(roots) { + const rank = new Map(state.sidebarOrder.map((key, index) => [key, index])); + return roots + .map((root, index) => ({ root, index, key: rootKey(root) })) + .sort((left, right) => { + const leftRank = rank.has(left.key) ? rank.get(left.key) : Number.MAX_SAFE_INTEGER; + const rightRank = rank.has(right.key) ? rank.get(right.key) : Number.MAX_SAFE_INTEGER; + if (leftRank !== rightRank) return leftRank - rightRank; + return left.index - right.index; + }) + .map((entry) => entry.root); + } + + function rootKey(root) { + return root?.id || root?.uri || ""; + } + + function reorderPlace(sourceRootId, targetRootId, placement = "before") { + if (!sourceRootId || !targetRootId || sourceRootId === targetRootId) return; + const sourceIndex = state.roots.findIndex((root) => rootKey(root) === sourceRootId); + const targetIndex = state.roots.findIndex((root) => rootKey(root) === targetRootId); + if (sourceIndex < 0 || targetIndex < 0) return; + const previousTops = capturePlaceTops(); + const [source] = state.roots.splice(sourceIndex, 1); + const adjustedTargetIndex = state.roots.findIndex((root) => rootKey(root) === targetRootId); + const insertIndex = placement === "after" ? adjustedTargetIndex + 1 : adjustedTargetIndex; + state.roots.splice(insertIndex, 0, source); + state.sidebarOrder = state.roots.map(rootKey).filter(Boolean); + localStorage.setItem("library.sidebarOrder", JSON.stringify(state.sidebarOrder)); + renderPlaces({ animateFrom: previousTops }); + syncPlacesActive(); + setStatus("Sidebar order saved."); + } + + async function loadCurrentFolder(options = {}) { + if (!state.currentUri) return; + const loadSeq = ++state.loadSeq; + const uri = state.currentUri; + const cached = options.useCache ? state.folderCache.get(uri) : null; + let renderedCached = false; + let renderAfterFetch = true; + if (cached) { + perf.folderCacheHits += 1; + state.loading = false; + state.currentObject = cached.object || null; + setObjects(cached.objects); + state.selectedUris.clear(); + setFolderStatus(`${state.objects.length} object${state.objects.length === 1 ? "" : "s"}.`); + renderAll(); + await runInitialObjectAction(); + renderedCached = true; + } else { + state.loading = true; + state.currentObject = null; + setStatus("Loading..."); + } + try { + const data = await providerApi("list", { uri }); + if (loadSeq !== state.loadSeq || uri !== state.currentUri) { + return; + } + const objects = Array.isArray(data.objects) ? data.objects : []; + const currentObject = data.object || null; + const nextCache = cacheFolderListing(state, perf, uri, objects, currentObject); + state.currentObject = currentObject; + if (renderedCached && cached.signature === nextCache.signature) { + renderAfterFetch = false; + setFolderStatus(`${state.objects.length} object${state.objects.length === 1 ? "" : "s"}.`); + return; + } + setObjects(objects); + state.selectedUris.clear(); + setFolderStatus(`${state.objects.length} object${state.objects.length === 1 ? "" : "s"}.`); + } finally { + if (loadSeq === state.loadSeq && uri === state.currentUri) { + state.loading = false; + if (renderAfterFetch) renderAll(); + if (renderAfterFetch) await runInitialObjectAction(); + } + } + } + + async function runInitialObjectAction() { + if (state.initialActionHandled || !state.initialObjectUri || !state.initialAction) { + return; + } + const object = objectByUri(state.initialObjectUri) + || (state.currentObject?.uri === state.initialObjectUri ? state.currentObject : null); + if (!object) { + return; + } + state.initialActionHandled = true; + selectOnly(object.uri); + if (state.initialAction === "properties") { + showProperties(object); + return; + } + if (state.initialAction === "empty-trash" && isTrashRootUri(object.uri)) { + await emptyTrash(); + return; + } + if (state.initialAction === "download" && hasCapability(object, "download")) { + try { + await downloadObject(object); + } catch (error) { + showError(error); + } + } + } + + function prefetchFolder(uri) { + if (!uri || state.folderCache.has(uri) || state.folderPrefetches.has(uri)) { + return Promise.resolve(); + } + const promise = providerApi("list", { uri }) + .then((data) => cacheFolderListing(state, perf, uri, data.objects, data.object || null)) + .catch(() => null) + .finally(() => { + state.folderPrefetches.delete(uri); + }); + state.folderPrefetches.set(uri, promise); + return promise; + } + + function scheduleRootPrefetch() { + if (state.rootPrefetchStarted || !state.roots.length) return; + state.rootPrefetchStarted = true; + window.setTimeout(() => { + const roots = state.roots + .filter((root) => root.uri !== state.currentUri) + .map((root) => prefetchFolder(root.uri)); + Promise.all(roots).catch(() => null); + }, 50); + } + + function renderAll() { + syncPlacesActive(); + renderBreadcrumbs(); + renderContent(); + renderFooter(); + renderUploads(); + syncViewButtons(); + syncNavigationButtons(); + } + + function renderPlaces(options = {}) { + perf.renderPlacesCount += 1; + elements.places.innerHTML = ""; + const activeRoot = activeRootForUri(state.currentUri); + for (const root of state.roots) { + const active = activeRoot?.uri === root.uri; + const button = document.createElement("button"); + button.className = active ? "place window-sidebar-item window-sidebar-item-active" : "place window-sidebar-item"; + button.type = "button"; + button.dataset.uri = root.uri; + button.dataset.rootId = rootKey(root); + button.dataset.active = active ? "true" : "false"; + button.draggable = true; + button.title = "Drag to reorder"; + button.innerHTML = ` + ${iconPlaceholder(placeIcon(root), "place-icon window-sidebar-item-icon")} + ${escapeHtml(root.label)} + `; + elements.places.appendChild(button); + } + if (options.animateFrom) animatePlaceReorder(options.animateFrom); + } + + function capturePlaceTops() { + return new Map(Array.from(elements.places.querySelectorAll(".place[data-root-id]")) + .map((button) => [button.dataset.rootId, button.getBoundingClientRect().top])); + } + + function animatePlaceReorder(previousTops) { + if (window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches) return; + const animated = []; + for (const button of elements.places.querySelectorAll(".place[data-root-id]")) { + const previousTop = previousTops.get(button.dataset.rootId); + if (typeof previousTop !== "number") continue; + const delta = previousTop - button.getBoundingClientRect().top; + if (Math.abs(delta) < 0.5) continue; + button.style.transition = "none"; + button.style.transform = `translateY(${delta}px)`; + button.style.willChange = "transform"; + animated.push(button); + } + if (!animated.length) return; + perf.sidebarReorderAnimationCount = (perf.sidebarReorderAnimationCount || 0) + 1; + window.requestAnimationFrame(() => { + for (const button of animated) { + button.style.transition = "transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1)"; + button.style.transform = ""; + button.addEventListener("transitionend", () => { + button.style.transition = ""; + button.style.willChange = ""; + }, { once: true }); + } + }); + } + + function syncPlacesActive() { + const activeRoot = activeRootForUri(state.currentUri); + for (const button of elements.places.querySelectorAll(".place[data-uri]")) { + const uri = button.dataset.uri || ""; + const active = activeRoot?.uri === uri; + button.className = active ? "place window-sidebar-item window-sidebar-item-active" : "place window-sidebar-item"; + button.dataset.active = active ? "true" : "false"; + } + } + + function syncRouteChrome() { + syncPlacesActive(); + renderBreadcrumbs(); + renderFooter(); + } + + function placeIcon(root) { + const id = typeof root === "string" ? root : root?.id; + if (id === "trash") { + return root?.metadata?.empty === false ? "icons/trash-full.svg" : "icons/trash.svg"; + } + return { + home: "icons/sidebar-folder-home.svg", + desktop: "icons/sidebar-folder-desktop.svg", + documents: "icons/sidebar-folder-documents.svg", + pictures: "icons/sidebar-folder-pictures.svg", + videos: "icons/sidebar-folder-videos.svg", + downloads: "icons/sidebar-folder.svg", + public: "icons/sidebar-folder-public.svg", + webspaces: "icons/sidebar-folder.svg", + }[id] || "icons/sidebar-folder.svg"; + } + + function renderBreadcrumbs() { + elements.breadcrumbs.innerHTML = ""; + const root = rootForUri(state.currentUri); + if (!root) return; + const segments = state.currentUri === root.uri + ? [] + : state.currentUri.slice(root.uri.length + 1).split("/").filter(Boolean); + const rootButton = crumbButton(root.label, root.uri, segments.length === 0); + elements.breadcrumbs.appendChild(rootButton); + let cursor = root.uri; + for (let index = 0; index < segments.length; index += 1) { + cursor += "/" + segments[index]; + elements.breadcrumbs.appendChild(pathSeparator()); + elements.breadcrumbs.appendChild(crumbButton(decodeURIComponent(segments[index]), cursor, index === segments.length - 1)); + } + elements.currentTitle.textContent = segments.length ? decodeURIComponent(segments[segments.length - 1]) : root.label; + } + + function pathSeparator() { + const separator = document.createElement("span"); + separator.className = "path-seperator"; + separator.textContent = "/"; + return separator; + } + + function crumbButton(label, uri, current) { + const button = document.createElement("button"); + button.type = "button"; + button.className = current ? "crumb crumb-current" : "crumb"; + button.textContent = label; + button.dataset.uri = uri; + button.disabled = current; + return button; + } + + function visibleObjects() { + return visibleObjectsForState(state); + } + + function showMenuForObject(object, x, y) { + if (!isSelected(object.uri)) { + selectOnly(object.uri); + } + const selection = selectedObjects(); + if (selection.length > 1) { + showMenuForSelection(x, y); + return; + } + const actions = []; + if (isBlockedObject(object)) { + actions.push(menuAction("Properties", () => showProperties(object))); + renderMenu(actions, x, y); + return; + } + if (isDirectory(object)) { + actions.push(menuAction("Open", () => navigate(object.uri))); + if (!inTrash(object)) { + actions.push(menuAction("Open in New Window", () => openTarget("library", { uri: object.uri }))); + } + } else { + actions.push(menuAction("Open", () => openObject(object))); + const viewers = viewerOptions(object); + if (viewers.length) { + actions.push(menuAction("Open With", null, { + children: viewers.map((viewer) => menuAction(viewer.label || viewer.id, () => openWithViewer(object, viewer.id))), + })); + } + } + if (hasCapability(object, "download")) { + actions.push(menuAction("Download", () => downloadObject(object))); + if (isDirectory(object) && !isWebSpaceUri(object.uri)) { + actions.push(menuAction("Download as ZIP", () => downloadObjectAsZip(object))); + } + } + if (!inTrash(object) && hasCapability(object, "compress_archive")) { + actions.push(menuAction("Compress to ZIP", () => compressObjectToZip(object))); + } + if (!inTrash(object)) { + if (!isDirectory(object)) { + if (hasCapability(object, "extract_archive")) { + actions.push(menuAction("Extract Here", () => extractArchiveObject(object))); + } else if (isPolicyGatedArchive(object)) { + actions.push(menuAction("Archive Support", () => showArchiveSupport(object))); + } + if (object.published) { + actions.push(menuAction("Status", () => showStatusObject(object))); + if (hasCapability(object, "repair")) actions.push(menuAction("Repair", () => repairObject(object))); + if (object.shared) actions.push(menuAction("Check My Access", () => checkMyAccess(object))); + if (hasCapability(object, "share")) actions.push(menuAction("Share", () => shareObject(object))); + if (hasCapability(object, "unpublish")) actions.push(menuAction("Unpublish", () => unpublishObject(object))); + } else if (hasCapability(object, "publish")) { + actions.push(menuAction("Publish", () => publishObject(object))); + } + } + actions.push("-"); + if (hasCapability(object, "move")) actions.push(menuAction("Cut", () => setClipboard("move", [object]))); + if (hasCapability(object, "copy")) actions.push(menuAction("Copy", () => setClipboard("copy", [object]))); + if (isDirectory(object) && canPasteInto(object.uri)) { + actions.push(menuAction("Paste Into Folder", () => pasteClipboardTo(object.uri))); + } + actions.push("-"); + if (hasCapability(object, "trash")) actions.push(menuAction("Delete", () => trashObject(object))); + if (hasCapability(object, "delete_permanently")) actions.push(menuAction("Delete Permanently", () => deleteObject(object))); + if (hasCapability(object, "rename")) actions.push(menuAction("Rename", () => startRename(object))); + } + if (inTrash(object)) { + if (hasCapability(object, "restore")) actions.push(menuAction("Restore", () => restoreObject(object))); + if (hasCapability(object, "delete_permanently")) actions.push(menuAction("Delete Permanently", () => deleteObject(object))); + } + actions.push("-"); + const localContentCid = contentCid(object); + const publicCid = publishedCid(object); + if (localContentCid) { + actions.push(menuAction("Copy Content CID", () => copyText(localContentCid, "content CID"))); + } + if (object.published && publicCid) { + actions.push(menuAction("Copy Published Link", () => copyText("elastos://" + publicCid, "published link"))); + } + actions.push(menuAction("Properties", () => showProperties(object))); + renderMenu(actions, x, y); + } + + function isPolicyGatedArchive(object) { + return object?.metadata?.archive_support?.status === "policy_gated_unsupported_archive_family"; + } + + function showArchiveSupport(object) { + const support = object?.metadata?.archive_support || {}; + const family = support.family || "archive"; + const archiveViewer = viewerOptions(object).find((viewer) => viewer?.id === "archive-manager"); + if (archiveViewer && openWithViewer(object, archiveViewer.id)) { + setStatus(`Opening ${object.name || "archive"} in Archive.`); + return; + } + setStatus(`${object.name || "Archive"} is a ${family} archive. Extraction is disabled pending dependency and release-policy review.`); + showProperties(object); + } + + function showPlaceMenu(uri, x, y) { + const root = state.roots.find((entry) => entry.uri === uri); + if (!root) { + hideMenu(); + return; + } + const actions = [ + menuAction("Open", () => navigate(root.uri)), + menuAction("Open in New Window", () => openTarget("library", { uri: root.uri })), + ]; + if (root.id === "trash" && root.metadata?.empty === false) { + actions.push("-"); + actions.push(menuAction("Empty Trash", emptyTrash)); + } + renderMenu(actions, x, y); + } + + function showMenuForSelection(x, y) { + const actions = []; + const objects = selectedObjects(); + const files = objects.filter((object) => !isDirectory(object) && !inTrash(object) && !isBlockedObject(object)); + const unpublished = files.filter((object) => !object.published); + const published = files.filter((object) => object.published); + const trash = objects.filter(inTrash); + const active = objects.filter((object) => !inTrash(object) && !isBlockedObject(object)); + const downloadable = active.filter((object) => !isWebSpaceUri(object.uri) && hasCapability(object, "download")); + const compressible = active.filter((object) => !isWebSpaceUri(object.uri) && hasCapability(object, "compress_archive")); + const permanentlyDeletable = trash.length || active.some((object) => hasCapability(object, "delete_permanently")); + if (downloadable.length > 1 && downloadable.length === active.length) { + actions.push(menuAction("Download Selected", downloadSelectedObjects)); + actions.push(menuAction("Download Selected as ZIP", downloadSelectedObjectsAsZip)); + } + if ( + compressible.length > 1 && + compressible.length === active.length && + compressible.every((object) => parentUri(object.uri) === parentUri(compressible[0].uri)) + ) { + actions.push(menuAction("Compress Selected to ZIP", compressSelectedObjectsToZip)); + } + if (unpublished.some((object) => hasCapability(object, "publish"))) actions.push(menuAction("Publish Selected", publishSelectedObjects)); + if (published.some((object) => hasCapability(object, "unpublish"))) actions.push(menuAction("Unpublish Selected", unpublishSelectedObjects)); + if (active.length) actions.push("-"); + if (active.some((object) => hasCapability(object, "move"))) actions.push(menuAction("Cut", () => setClipboard("move", active))); + if (active.some((object) => hasCapability(object, "copy"))) actions.push(menuAction("Copy", () => setClipboard("copy", active))); + if (active.some((object) => hasCapability(object, "trash"))) actions.push(menuAction("Delete", trashSelectedObjects)); + if (trash.length) actions.push(menuAction("Restore", restoreSelectedObjects)); + if (permanentlyDeletable) actions.push(menuAction("Delete Permanently", deleteSelectedObjects)); + renderMenu(actions, x, y); + } + + function showBackgroundMenu(x, y) { + const readOnly = currentFolderReadOnly(); + const actions = []; + actions.push(menuAction("Sort By", null, { + children: [ + menuAction("Name", () => setSort("name"), { checked: state.sort === "name" }), + menuAction("Date Modified", () => setSort("modified"), { checked: state.sort === "modified" }), + menuAction("Type", () => setSort("type"), { checked: state.sort === "type" }), + menuAction("Size", () => setSort("size"), { checked: state.sort === "size" }), + "-", + menuAction("Ascending", () => setSortOrder("asc"), { checked: state.sortOrder !== "desc" }), + menuAction("Descending", () => setSortOrder("desc"), { checked: state.sortOrder === "desc" }), + ], + })); + actions.push(menuAction("View", null, { + children: [ + menuAction("Icons", () => setView("grid"), { checked: state.view !== "list" }), + menuAction("Details", () => setView("list"), { checked: state.view === "list" }), + ], + })); + actions.push("-"); + actions.push(menuAction("Refresh", loadCurrentFolder)); + actions.push(menuAction("Show Hidden", () => toggleShowHidden(), { checked: state.showHidden })); + actions.push("-"); + if (!readOnly) { + actions.push(menuAction("New", null, { + children: [ + menuAction("Folder", createFolder), + menuAction("Text Document", createTextDocument), + ], + })); + actions.push("-"); + } + if (canPasteInto(state.currentUri)) actions.push(menuAction("Paste", () => pasteClipboardTo(state.currentUri))); + if (!readOnly) actions.push(menuAction("Upload Here", () => elements.fileInput.click())); + actions.push("-"); + actions.push(menuAction("Properties", () => showFolderProperties())); + renderMenu(actions, x, y); + } + + function showFolderProperties() { + const root = rootForUri(state.currentUri); + showProperties({ + uri: state.currentUri, + name: root?.uri === state.currentUri ? root.label : baseName(state.currentUri), + kind: "directory", + mime: "inode/directory", + size: 0, + modified_at: 0, + revision: "-", + availability: "local-only", + viewers: [], + }); + } + + function setSort(sort) { + state.sort = sort || "name"; + elements.sortSelect.value = state.sort; + localStorage.setItem("library.sort", state.sort); + scheduleContentRender(); + } + + function setSortOrder(order) { + state.sortOrder = order === "desc" ? "desc" : "asc"; + localStorage.setItem("library.sortOrder", state.sortOrder); + scheduleContentRender(); + } + + function toggleShowHidden() { + state.showHidden = !state.showHidden; + localStorage.setItem("library.showHidden", String(state.showHidden)); + scheduleContentRender(); + } + + function setView(view) { + state.view = view === "list" ? "list" : "grid"; + localStorage.setItem("library.view", state.view); + syncContentViewMode(); + syncViewButtons(); + renderFooter(); + } + + function showError(error) { + console.error(error); + setStatus(error && error.message ? error.message : "Explorer action failed."); + } + + function bindEvents() { + elements.pickerActionButton.addEventListener("click", () => completeArchivePicker().catch(showError)); + bindLibraryEvents({ + bindDialogEvents, + clearSelection, + copySelectedObjectsTo, + createFolder, + elements, + handleBrowserPopState, + hideDialog, + hideMenu, + loadCurrentFolder, + moveSelectedObjectsTo, + navigate, + navigateBack, + navigateForward, + navigateUp, + objectByUri, + openObject, + prepareDragSelection, + reorderPlace, + scheduleContentRender, + selectAllVisible, + selectedObjects, + selectOnly, + selectRangeTo, + setSort, + setView, + deleteSelectedObjects, + showBackgroundMenu, + showError, + showMenuForObject, + showPlaceMenu, + startRename, + state, + stopLibraryEventStream, + toggleSelected, + trashSelectedObjects, + uploadFiles, + }); + } + + async function boot() { + if (!state.homeToken) { + elements.lockedShell.classList.remove("hidden"); + return; + } + elements.libraryShell.classList.remove("hidden"); + elements.content.dataset.view = state.view; + syncModeChrome(); + bindEvents(); + try { + await loadRoots(); + installBrowserHistory(); + await loadCurrentFolder(); + scheduleRootPrefetch(); + startLibraryEventStream(); + } catch (error) { + showError(error); + elements.content.innerHTML = `

Could not load Explorer

${escapeHtml(error.message || "Runtime object provider unavailable.")}

`; + } + } + + boot(); diff --git a/capsules/library/src/dialog.js b/capsules/library/src/dialog.js new file mode 100644 index 00000000..e49ec6de --- /dev/null +++ b/capsules/library/src/dialog.js @@ -0,0 +1,924 @@ +import { + contentCid, + escapeHtml, + formatBytes, + formatTime, + hasCapability, + isDirectory, + parentUri, + publishedCid, + shortUri, + visibilityContract, + viewerOptions, +} from "./model.js"; + +export function createLibraryDialog({ + copyText, + dialog, + hideMenu, + objectByUri, + onBeforeClose, +}) { + let pendingDialogResolve = null; + + function showProperties(object) { + const identity = smartWebIdentity(object); + const availability = safeAvailabilitySummary(object.availability); + const remoteAccess = safeRemoteAccessSummary({}, object); + const archive = safeArchiveSummary(object); + const viewers = viewerOptions(object).map((viewer) => viewer.label || viewer.id); + const typeLabel = isDirectory(object) ? "Folder" : object.mime || "File"; + const location = parentUri(object.uri || ""); + const visibility = propertiesVisibilitySummary(object, identity, remoteAccess); + const placement = propertiesPlacementSummary(object); + const generalRows = [ + ["Name", object.name || "-"], + ["Path", copyableValue(object.uri || "-", "path")], + ["UID", identity.objectId], + ["Type", typeLabel], + ["Opens with", viewers.join(", ") || "-"], + ["Where", location || "-"], + ["Size", isDirectory(object) ? "-" : formatBytes(object.size)], + ["Modified", formatTime(object.modified_at)], + ["Created", formatTime(object.created_at)], + ["SmartWeb Object", identity.kind], + ["Content ID", copyableValue(identity.contentId, "content CID")], + ["Published CID", copyableValue(identity.publishedId, "published CID")], + ["Published Link", copyableValue(identity.publishedLink, "published link")], + ["Placement", badgeValue(placement.label, placement.tone)], + ["Visibility", badgeValue(visibility.label, visibility.tone)], + ["Access granted to", remoteAccess.status], + ]; + if (archive.relevant) { + generalRows.push(["Archive", archive.status]); + } + if (object.metadata?.trash?.original_uri) { + generalRows.push(["Original location", copyableValue(object.metadata.trash.original_uri, "original location")]); + } + const runtimeRows = [ + ["Provider", identity.provider], + ["Object URI", copyableValue(object.uri || "-", "object URI")], + ["Content URI", copyableValue(identity.contentUri, "content URI")], + ["Head", copyableValue(identity.headId, "object head")], + ["Revision", object.revision || "-"], + ["Resolver", identity.resolver], + ["Resolver Target", copyableValue(identity.resolverTarget, "resolver target")], + ["Access Policy", identity.accessPolicy], + ["Availability", availability.status], + ["Replicas", availability.replicas], + ["Live Proof", availability.liveProof], + ["Quota", availability.quota], + ["Repair", availability.repair], + ["Storage Market", availability.storageMarket], + ["Remote Open", remoteAccess.openStatus], + ["Key Release", remoteAccess.keyRelease], + ["Protection", object.blocked_reason || "ok"], + ["Public Folder Policy", placement.policy], + ["Visibility Contract", object.metadata?.visibility?.schema || "elastos.library.visibility/v1"], + ]; + const archiveTab = archive.relevant + ? `
Archive
` + : ""; + const archivePanel = archive.relevant + ? propertiesPanel("archive", [ + ["Status", archive.status], + ["Family", archive.details?.object?.family || "-"], + ["Extractable", archive.details?.object?.extractable ? "yes" : "no"], + ["Compressible", archive.details?.object?.compressible ? "yes" : "no"], + ["Download formats", archive.details?.implemented?.download_formats?.join(", ") || "-"], + ["Extract formats", archive.details?.implemented?.extract_formats?.join(", ") || "-"], + ["Safety", archive.details?.implemented?.safety || "-"], + ["Remaining policy", archive.details?.remaining_policy || "-"], + ]) + : ""; + dialog.innerHTML = ` +
+
+ ${escapeHtml(object.name || "Object")} properties + +
+
+
+
General
+
Runtime
+ ${archiveTab} +
+ ${propertiesPanel("general", generalRows, true)} + ${propertiesPanel("runtime", runtimeRows)} + ${archivePanel} +
+
+ +
+
+ `; + dialog.classList.remove("hidden"); + } + + function propertiesPanel(tab, rows, selected = false) { + return ` +
+ + + ${rows.map(([label, value]) => propertiesRow(label, value)).join("")} + +
+
+ `; + } + + function propertiesRow(label, value) { + const rendered = propertiesValue(value); + return ` + + ${escapeHtml(label)} + ${rendered.html} + + `; + } + + function propertiesValue(value) { + if (value && typeof value === "object" && value.kind === "copyable") { + const text = displayValue(value.value); + if (text === "-") return { title: text, html: escapeHtml(text) }; + const label = value.label || "value"; + return { + title: text, + html: ` + + ${escapeHtml(text)} + + + `, + }; + } + if (value && typeof value === "object" && value.kind === "badge") { + const text = displayValue(value.value); + return { + title: text, + html: `${escapeHtml(text)}`, + }; + } + const text = displayValue(value); + return { title: text, html: escapeHtml(text) }; + } + + function copyableValue(value, label) { + return { kind: "copyable", value, label }; + } + + function badgeValue(value, tone) { + return { kind: "badge", value, tone }; + } + + function displayValue(value) { + return value === null || value === undefined || value === "" ? "-" : String(value); + } + + function copyIconSvg() { + return ''; + } + + function propertiesVisibilitySummary(object = {}, identity = {}, remoteAccess = {}) { + const visibility = visibilityContract(object); + if (visibility.effective_access === "public_content_link") { + return object.shared + ? { label: "Published and shared", tone: "public" } + : { label: "Published link", tone: "public" }; + } + if (visibility.effective_access === "recipient_scoped_link") { + return { label: "Recipient scoped", tone: "shared" }; + } + if (visibility.effective_access === "blocked") { + return { label: "Blocked", tone: "readonly" }; + } + if (visibility.placement === "public_folder") { + return { label: "Private until published", tone: "staged" }; + } + if (identity.publishedId && identity.publishedId !== "-") { + return object.shared + ? { label: "Published and shared", tone: "public" } + : { label: "Published", tone: "public" }; + } + if (remoteAccess.status === "recipient scoped") { + return { label: "Recipient scoped", tone: "shared" }; + } + if (object.shared) { + return { label: "Shared", tone: "shared" }; + } + if (object.metadata?.readonly === true) { + return { label: "Mounted read-only", tone: "readonly" }; + } + if (object.metadata?.readonly === false) { + return { label: "Mounted writable", tone: "writable" }; + } + return { label: "Private", tone: "private" }; + } + + function propertiesPlacementSummary(object = {}) { + const visibility = visibilityContract(object); + const placement = visibility.placement || "private_folder"; + if (placement === "public_folder") { + return { + label: visibility.placement_label || "Public folder", + tone: "staged", + policy: "Placement only; publish creates the public content link.", + }; + } + if (placement === "trash") { + return { + label: "Trash", + tone: "readonly", + policy: "Trash is not public and cannot publish until restored.", + }; + } + if (placement === "runtime_private") { + return { + label: "Runtime private", + tone: "readonly", + policy: "Runtime private objects are hidden from normal publishing.", + }; + } + return { + label: visibility.placement_label || "Private folder", + tone: "private", + policy: "Private placement; publish creates an explicit content-provider receipt.", + }; + } + + function smartWebIdentity(object = {}, record = null) { + const metadata = object.metadata || {}; + const localContentId = contentCid(object); + const publicContentId = record?.cid || publishedCid(object); + const contentUri = publicContentId ? `elastos://${publicContentId}` : "-"; + const resolver = metadata.resolver || metadata.provider || ""; + const resolverTarget = metadata.target_uri || metadata.resolver_target || metadata.source_uri || ""; + const accessPolicy = metadata.access_policy || ( + metadata.readonly === false + ? "owner-writable" + : metadata.readonly === true + ? "read-only" + : "-" + ); + const kind = publicContentId + ? "Published content object" + : localContentId + ? "Local content object" + : resolver + ? "Mounted Space object" + : "Local Library object"; + const provider = publicContentId + ? "content-provider" + : resolver + ? `${resolver} resolver` + : "object-provider"; + const objectId = metadata.object_id || object.uri || "-"; + return { + kind, + provider, + contentId: localContentId || "-", + publishedId: publicContentId || "-", + contentUri, + publishedLink: publicContentId ? `elastos://${publicContentId}` : "-", + objectId, + headId: metadata.head_id || object.revision || "-", + resolver: resolver || "-", + resolverTarget: resolverTarget || "-", + accessPolicy: accessPolicy || "-", + }; + } + + function safeAvailabilitySummary(availability) { + if (!availability || typeof availability === "string") { + const status = availability || "local-only"; + return { + status, + replicas: "-", + liveProof: "no", + quota: "-", + repair: status === "repair_needed" ? "needed" : "-", + storageMarket: "-", + details: { + schema: "elastos.library.availability-summary/v1", + status, + proof: "not_provided", + }, + }; + } + const peerSelection = availability.peer_selection || {}; + const quota = availability.quota || {}; + const repair = availability.repair_worker || {}; + const accounting = availability.accounting || null; + const abuseControls = availability.abuse_controls || null; + const storageMarket = availability.storage_market || {}; + const remoteReplicas = Number(peerSelection.remote_replicas ?? countRemotePeerRows(peerSelection)); + const liveProof = peerSelection.live_multi_peer_proof === true; + const quotaStatus = quota.status || (quota.enforced ? "enforced" : "not_enforced"); + const repairStatus = repair.status || (availability.status === "repair_needed" ? "needed" : "-"); + return { + status: availability.status || "unknown", + replicas: Number.isFinite(Number(availability.replicas)) ? String(availability.replicas) : "-", + liveProof: liveProof ? "yes" : "no", + quota: quotaStatus || "-", + repair: repairStatus, + storageMarket: storageMarket.status || storageMarket.settlement || "-", + details: { + schema: "elastos.library.availability-summary/v1", + status: availability.status || "unknown", + provider: availability.provider || "-", + policy: availability.policy || "-", + replicas: availability.replicas ?? null, + peer_selection: { + mode: peerSelection.mode || "-", + strategy: peerSelection.strategy || "-", + live_multi_peer_proof: liveProof, + remote_replicas: remoteReplicas, + truncated: peerSelection.replicas_truncated ?? peerSelection.recent_remote_replicas_truncated ?? false, + }, + quota: { + status: quota.status || null, + enforced: quota.enforced === true, + used_replicas: quota.used_replicas ?? null, + effective_max_replicas: quota.effective_max_replicas ?? null, + }, + repair_worker: { + status: repair.status || null, + scheduled: repair.scheduled === true, + }, + storage_market: { + mode: storageMarket.mode || null, + status: storageMarket.status || null, + settlement: storageMarket.settlement || null, + quota_enforced: storageMarket.quota_enforced === true, + }, + accounting: accounting ? { + content_bytes: accounting.content_bytes ?? null, + replica_bytes_estimate: accounting.replica_bytes_estimate ?? null, + storage_quota_status: accounting.storage_quota?.status || accounting.storage_quota_status || null, + } : null, + abuse_controls: abuseControls ? { + policy: abuseControls.policy || null, + enforced: abuseControls.enforced === true, + attempted_operations: abuseControls.attempted_operations ?? null, + failed_operations: abuseControls.failed_operations ?? null, + throttled: abuseControls.throttled === true, + } : null, + }, + }; + } + + function countRemotePeerRows(peerSelection) { + const replicas = Array.isArray(peerSelection?.replicas) ? peerSelection.replicas : []; + return replicas.filter((replica) => replica?.role === "remote").length; + } + + function safePublishReceiptSummary(receipt) { + if (!receipt) return { status: "not_available" }; + const payload = receipt.payload || receipt; + return { + schema: payload.schema || "elastos.content.availability.receipt/v1", + status: payload.status || null, + provider: payload.provider || null, + policy: payload.policy || null, + replicas: payload.replicas ?? null, + checked_at: payload.checked_at ?? null, + signer_did: receipt.signer_did || payload.signer_did || null, + verified: receipt.verified === true || payload.verified === true, + }; + } + + function safeShareReceiptSummary(payload) { + if (!payload) return { status: "not_available" }; + return { + schema: payload.schema || "elastos.library.share/v1", + policy: payload.policy || null, + uri: payload.uri || (payload.cid ? `elastos://${payload.cid}` : null), + object_uri: payload.object_uri || payload.object?.uri || null, + recipients: Array.isArray(payload.recipients) ? payload.recipients.length : 0, + grants: Array.isArray(payload.grants) ? payload.grants.length : 0, + availability: safeAvailabilitySummary(payload.availability || payload.object?.availability || null).details, + remote_access: safeRemoteAccessSummary(payload, payload.object || {}).details, + key_release_required: payload.key_release?.required === true, + shared_at: payload.shared_at || null, + }; + } + + function safeRemoteAccessSummary(payload = {}, object = {}) { + const metadata = object.metadata || payload.metadata || {}; + const providerStatus = payload.protected_content + || metadata.protected_content + || null; + const grants = Array.isArray(payload.grants) + ? payload.grants + : Array.isArray(payload.share_grants) + ? payload.share_grants + : []; + const firstGrantKeyRelease = grants.find((grant) => grant?.key_release)?.key_release || null; + const keyRelease = payload.key_release + || firstGrantKeyRelease + || metadata.key_release + || null; + const remoteEnforcement = payload.remote_enforcement + || payload.access?.open?.remote_enforcement + || payload.open?.remote_enforcement + || metadata.remote_enforcement + || null; + const policy = payload.policy + || payload.share_policy + || metadata.share_policy + || (object.shared ? "shared" : "not_shared"); + const keyReleaseRequired = keyRelease?.required === true || remoteEnforcement?.key_release_required === true; + const recipientProofRequired = remoteEnforcement?.recipient_proof_required === true || policy === "recipient_scoped"; + const keyReleaseStatus = keyRelease?.status + || remoteEnforcement?.key_release_status + || (keyReleaseRequired ? "required" : "not_required"); + const openStatus = remoteEnforcement?.status + || (keyReleaseRequired + ? "blocked_until_drm_rights_key_decrypt_providers" + : recipientProofRequired + ? "recipient_proof_enforced_by_runtime" + : policy === "public_link" + ? "public_link_ready" + : "not_shared"); + const status = policy === "recipient_scoped" + ? "recipient scoped" + : policy === "public_link" + ? "public link" + : object.shared + ? "shared" + : "not shared"; + return { + status, + openStatus, + keyRelease: keyReleaseStatus, + details: { + schema: "elastos.library.remote-access-summary/v1", + policy, + provider_gate: remoteEnforcement?.provider_gate || "object-provider shared_access", + recipient_proof_required: recipientProofRequired, + key_release_required: keyReleaseRequired, + key_release_status: keyReleaseStatus, + open_status: openStatus, + required_providers: remoteEnforcement?.required_providers || keyRelease?.required_providers || null, + provider_invocation: remoteEnforcement?.provider_invocation || null, + provider_status: providerStatus, + next: remoteEnforcement?.next || keyRelease?.next || null, + }, + }; + } + + function safeArchiveSummary(object = {}) { + const backend = object.metadata?.archive_support || null; + const name = String(object.name || "").toLowerCase(); + const mime = String(object.mime || "").toLowerCase(); + const family = backend?.family || archiveFamilyForName(name, mime); + const extractable = hasCapability(object, "extract_archive") + && (name.endsWith(".zip") + || name.endsWith(".tar") + || name.endsWith(".tar.gz") + || name.endsWith(".tgz")); + const policyGated = backend?.status === "policy_gated_unsupported_archive_family" + || (!!family && !extractable && !["zip", "tar", "tar.gz"].includes(family)); + const compressible = hasCapability(object, "compress_archive"); + const archiveLike = extractable + || policyGated + || !!backend + || mime.includes("zip") + || mime.includes("tar") + || name.endsWith(".gz"); + return { + relevant: archiveLike, + status: extractable + ? "extractable" + : policyGated + ? "policy-gated archive" + : compressible + ? "can compress/download" + : archiveLike + ? "view only" + : "-", + details: { + schema: "elastos.library.archive-support/v1", + backend, + implemented: { + download_formats: ["zip", "tar.gz"], + compress_to_library: ["zip"], + extract_formats: ["zip", "tar", "tar.gz", "tgz"], + safety: "relative UTF-8 file paths only; non-file archive entries are rejected", + }, + object: { + name: object.name || null, + mime: object.mime || null, + family, + extractable, + compressible, + policy_gated: policyGated, + }, + remaining_policy: policyGated + ? "This archive family is recognized but disabled pending dependency and release-policy review." + : "Other generic archive families need dependency and release-policy review before enabling.", + }, + }; + } + + function archiveFamilyForName(name, mime = "") { + if (name.endsWith(".tar.gz") || name.endsWith(".tgz")) return "tar.gz"; + if (name.endsWith(".tar")) return "tar"; + if (name.endsWith(".zip") || mime.includes("zip")) return "zip"; + if (name.endsWith(".tar.xz") || name.endsWith(".txz")) return "tar.xz"; + if (name.endsWith(".tar.bz2") || name.endsWith(".tbz2")) return "tar.bz2"; + if (name.endsWith(".tar.zst") || name.endsWith(".tzst")) return "tar.zst"; + if (name.endsWith(".7z")) return "7z"; + if (name.endsWith(".rar")) return "rar"; + if (name.endsWith(".xz")) return "xz"; + if (name.endsWith(".bz2")) return "bz2"; + if (name.endsWith(".zst")) return "zst"; + if (name.endsWith(".lz4")) return "lz4"; + if (name.endsWith(".gz")) return "gzip"; + if (mime.includes("tar")) return "tar"; + return ""; + } + + function showObjectStatus(payload) { + const object = payload?.object || {}; + const record = payload?.published || null; + const availability = record?.availability || object.availability || "local-only"; + const identity = smartWebIdentity(object, record); + const availabilitySummary = safeAvailabilitySummary(availability); + const receipt = record?.receipt || null; + const shareGrants = Array.isArray(record?.share_grants) ? record.share_grants : []; + const firstGrantKeyRelease = shareGrants.find((grant) => grant?.key_release)?.key_release || null; + const contentSecurity = record?.content_security || null; + const remoteAccess = safeRemoteAccessSummary({ + policy: record?.share_policy, + grants: shareGrants, + key_release: firstGrantKeyRelease, + remote_enforcement: record?.remote_enforcement, + content_security: contentSecurity, + protected_content: payload?.protected_content || record?.protected_content, + }, object); + const protectedContent = payload?.protected_content || record?.protected_content || null; + dialog.innerHTML = ` +
+
+

Availability

+

${escapeHtml(object.name || "Object status")}

+

${escapeHtml(shortUri(object.uri || ""))}

+
+
+
SmartWeb Object
${escapeHtml(identity.kind)}
+
Content URI
${escapeHtml(identity.contentUri)}
+
Availability
${escapeHtml(availabilitySummary.status)}
+
Live Proof
${escapeHtml(availabilitySummary.liveProof)}
+
Replicas
${escapeHtml(availabilitySummary.replicas)}
+
Quota
${escapeHtml(availabilitySummary.quota)}
+
Repair
${escapeHtml(availabilitySummary.repair)}
+
Storage Market
${escapeHtml(availabilitySummary.storageMarket)}
+
Published
${object.published ? "yes" : "no"}
+
Shared
${object.shared ? "yes" : "no"}
+
Object ID
${escapeHtml(identity.objectId)}
+
Published At
${escapeHtml(formatTime(record?.published_at))}
+
Unpublished At
${escapeHtml(formatTime(record?.unpublished_at))}
+
Shared At
${escapeHtml(formatTime(record?.shared_at))}
+
Share Policy
${escapeHtml(record?.share_policy || "not shared")}
+
Share Grants
${escapeHtml(String(shareGrants.length))}
+
Remote Open
${escapeHtml(remoteAccess.openStatus)}
+
Provider Chain
${escapeHtml(protectedContent?.encrypted_recipient_sharing?.status || "-")}
+
Payload
${escapeHtml(contentSecurity?.published_payload || "-")}
+
Key Release
${escapeHtml(remoteAccess.keyRelease || contentSecurity?.status || "not required")}
+
Revision
${escapeHtml(object.revision || "-")}
+
+
+ Remote Access Policy +
${escapeHtml(JSON.stringify(remoteAccess.details, null, 2))}
+
+ ${protectedContent ? `
+ Protected Content Providers +
${escapeHtml(JSON.stringify(protectedContent, null, 2))}
+
` : ""} +
+ Availability Summary +
${escapeHtml(JSON.stringify(availabilitySummary.details, null, 2))}
+
+
+ Share Grants / Key Release +
${escapeHtml(JSON.stringify({
+            policy: record?.share_policy || null,
+            grants: shareGrants,
+            content_security: contentSecurity,
+          }, null, 2))}
+
+
+ Publish Receipt Summary +
${escapeHtml(JSON.stringify(safePublishReceiptSummary(receipt), null, 2))}
+
+
+ + +
+
+ `; + dialog.dataset.previewUri = object.uri || ""; + dialog.classList.remove("hidden"); + } + + function showShareReceipt(payload) { + const object = payload?.object || {}; + const uri = payload?.uri || (payload?.cid ? `elastos://${payload.cid}` : ""); + const availability = payload?.availability || object.availability || "unknown"; + const identity = smartWebIdentity(object, { cid: payload?.cid, availability }); + const availabilitySummary = safeAvailabilitySummary(availability); + const policy = payload?.policy || "public_link"; + const recipientCount = Array.isArray(payload?.recipients) ? payload.recipients.length : 0; + const grantCount = Array.isArray(payload?.grants) ? payload.grants.length : 0; + const keyRelease = payload?.key_release || payload?.grants?.find((grant) => grant?.key_release)?.key_release || null; + const contentSecurity = payload?.content_security || null; + const remoteAccess = safeRemoteAccessSummary(payload, object); + const protectedContent = payload?.protected_content || null; + dialog.innerHTML = ` +
+
+

Share

+

${escapeHtml(object.name || "Published object")}

+

A published content link is ready. Recipient-scoped grants are recorded by the Runtime provider when recipients are supplied.

+
+
+
Policy
${escapeHtml(policy)}
+
Recipients
${escapeHtml(String(recipientCount))}
+
Grants
${escapeHtml(String(grantCount))}
+
Content URI
${escapeHtml(uri || identity.contentUri)}
+
SmartWeb Object
${escapeHtml(identity.kind)}
+
Shared At
${escapeHtml(formatTime(payload?.shared_at))}
+
Availability
${escapeHtml(availabilitySummary.status)}
+
Live Proof
${escapeHtml(availabilitySummary.liveProof)}
+
Storage Market
${escapeHtml(availabilitySummary.storageMarket)}
+
Payload
${escapeHtml(contentSecurity?.published_payload || "-")}
+
Remote Open
${escapeHtml(remoteAccess.openStatus)}
+
Provider Chain
${escapeHtml(protectedContent?.encrypted_recipient_sharing?.status || "-")}
+
Key Release
${escapeHtml(remoteAccess.keyRelease || keyRelease?.status || "not required")}
+
Object
${escapeHtml(shortUri(payload?.object_uri || object.uri || ""))}
+
+
+ Remote Access Policy +
${escapeHtml(JSON.stringify(remoteAccess.details, null, 2))}
+
+ ${protectedContent ? `
+ Protected Content Providers +
${escapeHtml(JSON.stringify(protectedContent, null, 2))}
+
` : ""} +
+ Availability Summary +
${escapeHtml(JSON.stringify(availabilitySummary.details, null, 2))}
+
+
+ Recipient Grants / Key Release +
${escapeHtml(JSON.stringify({
+            policy,
+            grants: payload?.grants || [],
+            key_release: keyRelease,
+            content_security: contentSecurity,
+          }, null, 2))}
+
+
+ Share Receipt Summary +
${escapeHtml(JSON.stringify(safeShareReceiptSummary(payload), null, 2))}
+
+
+ + + +
+
+ `; + dialog.dataset.previewUri = object.uri || payload?.object_uri || ""; + dialog.classList.remove("hidden"); + } + + function showSharedAccessReceipt(payload) { + const object = payload?.object || {}; + const access = payload?.access || {}; + const decision = access.decision || {}; + const open = access.open || {}; + const keyRelease = access.key_release || open.key_release || null; + const remoteAccess = safeRemoteAccessSummary({ + access, + key_release: keyRelease, + policy: decision.policy || open.policy, + protected_content: payload?.protected_content, + }, object); + dialog.innerHTML = ` +
+
+

Access Check

+

${escapeHtml(object.name || "Shared object")}

+

Runtime checked this object against the signed Home principal. Recipient proof is injected by Runtime only when the launch grant matches the requested recipient.

+
+
+
Decision
${escapeHtml(decision.allowed === false ? "denied" : "allowed")}
+
Policy
${escapeHtml(decision.policy || open.policy || "-")}
+
Recipient
${escapeHtml(decision.recipient || open.recipient || "-")}
+
Reason
${escapeHtml(decision.reason || "-")}
+
Content URI
${escapeHtml(payload?.uri || (payload?.cid ? `elastos://${payload.cid}` : "-"))}
+
Open Status
${escapeHtml(open.status || remoteAccess.openStatus || "-")}
+
Key Release
${escapeHtml(remoteAccess.keyRelease || keyRelease?.status || "not required")}
+
Payload
${escapeHtml(open.published_payload || "-")}
+
+
+ Open Contract +
${escapeHtml(JSON.stringify(open, null, 2))}
+
+
+ Remote Access Policy +
${escapeHtml(JSON.stringify(remoteAccess.details, null, 2))}
+
+
+ Shared Access Receipt +
${escapeHtml(JSON.stringify(access, null, 2))}
+
+
+ + +
+
+ `; + dialog.dataset.previewUri = object.uri || ""; + dialog.classList.remove("hidden"); + } + + function showShareDialog(object) { + hideMenu(); + hideDialog(); + return new Promise((resolve) => { + pendingDialogResolve = resolve; + dialog.innerHTML = ` +
+
+
+

Share

+

${escapeHtml(object.name || "Published object")}

+

Choose a Runtime share policy. Public links are open to anyone with the published content URI. Recipient-scoped sharing records explicit grants and fails closed for other recipients.

+
+ + +

Separate recipients with commas or new lines. Recipient-scoped access requires Runtime recipient proof; encrypted key release fails closed until drm/rights/key/decrypt providers and encrypted publish mode are configured.

+ +
+ + +
+
+
+ `; + dialog.classList.remove("hidden"); + dialog.querySelector("textarea")?.focus(); + }); + } + + function confirmDestructive({ title, message, confirmLabel }) { + hideMenu(); + hideDialog(); + return new Promise((resolve) => { + pendingDialogResolve = resolve; + dialog.innerHTML = ` +
+
+

Confirm

+

${escapeHtml(title)}

+

${escapeHtml(message)}

+
+
+ + +
+
+ `; + dialog.classList.remove("hidden"); + }); + } + + function resolveDialogDecision(value) { + if (!pendingDialogResolve) return; + const resolve = pendingDialogResolve; + pendingDialogResolve = null; + resolve(value); + } + + function hideDialog() { + onBeforeClose(); + delete dialog.dataset.previewUri; + dialog.classList.add("hidden"); + dialog.innerHTML = ""; + resolveDialogDecision(false); + } + + function bindDialogEvents() { + dialog.addEventListener("submit", (event) => { + const form = event.target.closest("[data-share-form]"); + if (!form) return; + event.preventDefault(); + const formData = new FormData(form); + const policy = String(formData.get("sharePolicy") || "public_link"); + const recipients = String(formData.get("shareRecipients") || "") + .split(/[\n,]+/) + .map((recipient) => recipient.trim()) + .filter(Boolean); + const error = form.querySelector("[data-share-error]"); + if (policy === "recipient_scoped" && !recipients.length) { + if (error) { + error.textContent = "Recipient-scoped sharing requires at least one recipient."; + error.classList.remove("hidden"); + } + return; + } + resolveDialogDecision({ policy, recipients }); + hideDialog(); + }); + + dialog.addEventListener("click", (event) => { + if (event.target.closest("[data-dialog-confirm]")) { + resolveDialogDecision(true); + hideDialog(); + return; + } + const propertyCopy = event.target.closest("[data-prop-copy]"); + if (propertyCopy) { + const value = propertyCopy.getAttribute("data-prop-copy") || ""; + const label = propertyCopy.getAttribute("data-copy-label") || "value"; + if (value && copyText) { + copyText(value, label).catch(() => {}); + propertyCopy.classList.add("copied"); + propertyCopy.setAttribute("aria-label", `Copied ${label}`); + setTimeout(() => { + propertyCopy.classList.remove("copied"); + propertyCopy.removeAttribute("aria-label"); + }, 1200); + } + return; + } + const propertiesTab = event.target.closest(".item-props-tab-btn[data-tab]"); + if (propertiesTab) { + const card = propertiesTab.closest(".properties-card"); + const tab = propertiesTab.getAttribute("data-tab"); + card?.querySelectorAll(".item-props-tab-btn").forEach((button) => { + button.classList.toggle("item-props-tab-selected", button === propertiesTab); + }); + card?.querySelectorAll(".item-props-tab-content").forEach((panel) => { + panel.classList.toggle("item-props-tab-content-selected", panel.getAttribute("data-tab") === tab); + }); + return; + } + if (event.target.closest('[data-dialog-action="properties"]')) { + const object = objectByUri(dialog.dataset.previewUri); + if (object) showProperties(object); + return; + } + const copyShare = event.target.closest('[data-dialog-action="copy-share-link"]'); + if (copyShare) { + const uri = copyShare.getAttribute("data-share-uri") || ""; + if (uri && copyText) copyText(uri, "published link").catch(() => {}); + return; + } + if (event.target === dialog || event.target.closest("[data-dialog-close]")) { + hideDialog(); + } + }); + } + + return { + bindDialogEvents, + confirmDestructive, + hideDialog, + showObjectStatus, + showProperties, + showShareDialog, + showShareReceipt, + showSharedAccessReceipt, + }; +} diff --git a/capsules/library/src/editor.js b/capsules/library/src/editor.js new file mode 100644 index 00000000..519c8be5 --- /dev/null +++ b/capsules/library/src/editor.js @@ -0,0 +1,126 @@ +import { + childUri, + nextObjectName, +} from "./model.js"; + +export function createLibraryEditor({ + content, + loadCurrentFolder, + providerApi, + renderContent, + setObjects, + setStatus, + showError, + state, +}) { + function startCreateObject(kind) { + const isFolder = kind === "directory"; + const name = isFolder + ? nextObjectName(state.objects, "New Folder") + : nextObjectName(state.objects, "New File", ".txt"); + const now = Math.floor(Date.now() / 1000); + const draft = { + schema: "elastos.library.object/v1", + uri: `draft:${++state.draftCounter}:${Date.now()}`, + name, + kind: isFolder ? "directory" : "file", + mime: isFolder ? "inode/directory" : "text/plain", + size: 0, + created_at: now, + modified_at: now, + revision: "draft", + viewer: null, + viewers: [], + thumbnail_uri: null, + availability: "draft", + blocked_reason: null, + content_cid: null, + published_cid: null, + published: false, + shared: false, + capabilities: ["rename"], + }; + setObjects([...state.objects, draft]); + state.selectedUris = new Set([draft.uri]); + renderContent(); + setStatus(isFolder ? "Name the new folder." : "Name the new text document."); + window.requestAnimationFrame(() => { + startNameEdit(draft, { + async commit(finalName) { + if (isFolder) { + await providerApi("mkdir", { parent_uri: state.currentUri, name: finalName }); + } else { + await providerApi("write", { + uri: childUri(state.currentUri, finalName), + mime: "text/plain", + data: "", + }); + } + setStatus(`Created ${finalName}.`); + await loadCurrentFolder(); + }, + cancel() { + setObjects(state.objects.filter((object) => object.uri !== draft.uri)); + state.selectedUris.delete(draft.uri); + renderContent(); + setStatus("Ready."); + }, + }); + }); + } + + function startRename(object) { + startNameEdit(object, { + async commit(name) { + if (name && name !== object.name) { + await providerApi("rename", { uri: object.uri, name, if_revision: object.revision }); + setStatus(`Renamed ${object.name} to ${name}.`); + await loadCurrentFolder(); + } else { + renderContent(); + } + }, + cancel() { + renderContent(); + }, + }); + } + + function startNameEdit(object, handlers) { + const label = content.querySelector(`[data-name-uri="${CSS.escape(object.uri)}"]`); + if (!label) return; + const input = document.createElement("input"); + input.className = "rename-input"; + input.value = object.name || ""; + label.replaceWith(input); + input.focus(); + input.select(); + let committed = false; + async function commit() { + if (committed) return; + committed = true; + const name = input.value.trim(); + if (!name) { + handlers.cancel(); + return; + } + await handlers.commit(name); + } + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + commit().catch(showError); + } + if (event.key === "Escape") { + committed = true; + handlers.cancel(); + } + }); + input.addEventListener("blur", () => commit().catch(showError)); + } + + return { + startCreateObject, + startRename, + }; +} diff --git a/capsules/library/src/events.js b/capsules/library/src/events.js new file mode 100644 index 00000000..fc280dbf --- /dev/null +++ b/capsules/library/src/events.js @@ -0,0 +1,331 @@ +import { + inTrash, + isDirectory, +} from "./model.js"; + +export function bindLibraryEvents({ + bindDialogEvents, + clearSelection, + copySelectedObjectsTo, + createFolder, + elements, + handleBrowserPopState, + hideDialog, + hideMenu, + loadCurrentFolder, + moveSelectedObjectsTo, + navigate, + navigateBack, + navigateForward, + navigateUp, + objectByUri, + openObject, + prepareDragSelection, + reorderPlace, + scheduleContentRender, + selectAllVisible, + selectRangeTo, + selectedObjects, + selectOnly, + setSort, + setView, + deleteSelectedObjects, + showBackgroundMenu, + showError, + showMenuForObject, + showPlaceMenu, + startRename, + state, + stopLibraryEventStream, + toggleSelected, + trashSelectedObjects, + uploadFiles, +}) { + let draggingPlaceId = ""; + + elements.sidebar?.addEventListener("contextmenu", (event) => { + event.preventDefault(); + event.stopPropagation(); + hideMenu(); + }); + elements.places.addEventListener("click", (event) => { + const button = event.target.closest(".place"); + if (button?.dataset.uri) { + navigate(button.dataset.uri).catch(showError); + } + }); + elements.places.addEventListener("contextmenu", (event) => { + event.preventDefault(); + event.stopPropagation(); + const button = event.target.closest(".place"); + if (button?.dataset.uri) { + showPlaceMenu(button.dataset.uri, event.clientX, event.clientY); + return; + } + hideMenu(); + }); + elements.places.addEventListener("dragstart", (event) => { + const button = event.target.closest(".place"); + if (!button?.dataset.rootId) return; + draggingPlaceId = button.dataset.rootId; + button.classList.add("window-sidebar-item-dragging"); + elements.places.dataset.reordering = "true"; + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("application/x-elastos-library-root-id", draggingPlaceId); + event.dataTransfer.setData("text/plain", button.dataset.uri || ""); + }); + elements.places.addEventListener("dragover", (event) => { + const button = event.target.closest(".place"); + if (button?.dataset.rootId && dataTransferHasType(event.dataTransfer, "application/x-elastos-library-root-id")) { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + markPlaceDropTarget(elements, button, event); + return; + } + if (button?.dataset.rootId === "trash" && selectedObjects().some((object) => !inTrash(object))) { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + return; + } + if (button?.dataset.uri && selectedObjects().length) { + event.preventDefault(); + event.dataTransfer.dropEffect = event.altKey ? "copy" : "move"; + } + }); + elements.places.addEventListener("dragleave", (event) => { + const button = event.target.closest(".place"); + if (button && !button.contains(event.relatedTarget)) { + delete button.dataset.dropPosition; + } + }); + elements.places.addEventListener("drop", (event) => { + const button = event.target.closest(".place"); + const sourceRootId = event.dataTransfer?.getData("application/x-elastos-library-root-id") || draggingPlaceId; + if (button?.dataset.rootId && sourceRootId) { + event.preventDefault(); + reorderPlace(sourceRootId, button.dataset.rootId, placeDropPosition(button, event)); + clearPlaceDropTargets(elements); + draggingPlaceId = ""; + return; + } + if (button?.dataset.rootId === "trash" && selectedObjects().some((object) => !inTrash(object))) { + event.preventDefault(); + trashSelectedObjects().catch(showError); + return; + } + if (!button?.dataset.uri || !selectedObjects().length) return; + event.preventDefault(); + const action = event.altKey ? copySelectedObjectsTo : moveSelectedObjectsTo; + action(button.dataset.uri).catch(showError); + }); + elements.places.addEventListener("dragend", () => { + draggingPlaceId = ""; + clearPlaceDropTargets(elements); + }); + elements.breadcrumbs.addEventListener("click", (event) => { + const button = event.target.closest(".crumb"); + if (button?.dataset.uri) { + navigate(button.dataset.uri).catch(showError); + } + }); + elements.search.addEventListener("input", () => { + state.query = elements.search.value || ""; + scheduleContentRender(); + }); + elements.sortSelect.addEventListener("change", () => { + setSort(elements.sortSelect.value || "name"); + }); + elements.backButton.addEventListener("click", () => navigateBack().catch(showError)); + elements.forwardButton.addEventListener("click", () => navigateForward().catch(showError)); + elements.upButton.addEventListener("click", () => navigateUp().catch(showError)); + elements.refreshButton.addEventListener("click", () => loadCurrentFolder().catch(showError)); + elements.gridButton.addEventListener("click", () => setView("grid")); + elements.listButton.addEventListener("click", () => setView("list")); + elements.uploadButton.addEventListener("click", () => elements.fileInput.click()); + elements.newFolderButton.addEventListener("click", () => createFolder().catch(showError)); + elements.fileInput.addEventListener("change", () => { + uploadFiles(elements.fileInput.files).catch(showError); + elements.fileInput.value = ""; + }); + elements.content.addEventListener("click", (event) => { + if (isNameEditorTarget(event.target)) return; + const item = event.target.closest(".item"); + if (!item?.dataset.uri) { + clearSelection(); + return; + } + item.focus({ preventScroll: true }); + if (event.shiftKey) { + selectRangeTo(item.dataset.uri, event.metaKey || event.ctrlKey); + } else if (event.metaKey || event.ctrlKey) { + toggleSelected(item.dataset.uri); + } else { + selectOnly(item.dataset.uri); + } + }); + elements.content.addEventListener("dblclick", (event) => { + if (isNameEditorTarget(event.target)) return; + const item = event.target.closest(".item"); + const object = item ? objectByUri(item.dataset.uri) : null; + if (object) openObject(object).catch(showError); + }); + elements.content.addEventListener("contextmenu", (event) => { + if (isNameEditorTarget(event.target)) return; + event.preventDefault(); + const item = event.target.closest(".item"); + const object = item ? objectByUri(item.dataset.uri) : null; + if (object) { + showMenuForObject(object, event.clientX, event.clientY); + } else { + clearSelection(); + showBackgroundMenu(event.clientX, event.clientY); + } + }); + elements.content.addEventListener("dragstart", (event) => { + const item = event.target.closest(".item"); + if (!item?.dataset.uri) return; + prepareDragSelection(item.dataset.uri, item); + event.dataTransfer.effectAllowed = "copyMove"; + event.dataTransfer.setData("application/x-elastos-library-uris", JSON.stringify(Array.from(state.selectedUris))); + event.dataTransfer.setData("text/plain", item.dataset.uri); + }); + elements.content.addEventListener("dragover", (event) => { + const item = event.target.closest(".item"); + const object = item ? objectByUri(item.dataset.uri) : null; + const hasFiles = dataTransferHasType(event.dataTransfer, "Files"); + if (hasFiles || (object && isDirectory(object) && selectedObjects().length)) { + event.preventDefault(); + event.dataTransfer.dropEffect = hasFiles || event.altKey ? "copy" : "move"; + } + }); + elements.content.addEventListener("drop", (event) => { + event.preventDefault(); + const files = event.dataTransfer?.files; + if (files?.length) { + uploadFiles(files).catch(showError); + return; + } + const item = event.target.closest(".item"); + const object = item ? objectByUri(item.dataset.uri) : null; + if (object && isDirectory(object) && selectedObjects().length) { + const action = event.altKey ? copySelectedObjectsTo : moveSelectedObjectsTo; + action(object.uri).catch(showError); + } + }); + document.addEventListener("click", (event) => { + if (!event.target.closest(".context-menu")) { + hideMenu(); + } + }); + bindDialogEvents(); + window.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + hideMenu(); + hideDialog(); + clearSelection(); + return; + } + const editable = event.target.closest?.("input, textarea, select, [contenteditable='true']"); + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "a" && !editable) { + event.preventDefault(); + selectAllVisible(); + } + if (event.key === "F2" && !editable) { + const objects = selectedObjects(); + if (objects.length === 1) { + event.preventDefault(); + startRename(objects[0]); + } + } + if (event.key === "Enter" && !editable && !isDialogOpen(elements) && !isMenuOpen(elements)) { + const objects = selectedObjects(); + if (objects.length) { + event.preventDefault(); + openSelectedObjects(objects, openObject, showError); + } + } + if (event.key === "Delete" && !editable && !isDialogOpen(elements) && !isMenuOpen(elements)) { + const objects = selectedObjects(); + if (objects.length) { + event.preventDefault(); + const hasTrash = objects.some(inTrash); + const action = event.shiftKey || hasTrash ? deleteSelectedObjects : trashSelectedObjects; + action().catch(showError); + } + } + if ((event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) && !editable && !isDialogOpen(elements)) { + event.preventDefault(); + const object = selectedObjects()[0]; + if (object) { + showKeyboardObjectMenu(elements, object, showMenuForObject); + } else { + showKeyboardBackgroundMenu(elements, showBackgroundMenu); + } + } + }); + window.addEventListener("popstate", (event) => { + handleBrowserPopState(event).catch(showError); + }); + window.addEventListener("beforeunload", () => { + stopLibraryEventStream(); + }); +} + +function dataTransferHasType(dataTransfer, type) { + return Array.from(dataTransfer?.types || []).includes(type); +} + +function markPlaceDropTarget(elements, button, event) { + const position = placeDropPosition(button, event); + if (button.dataset.dropPosition === position) return; + for (const place of elements.places.querySelectorAll(".place[data-drop-position]")) { + if (place !== button) delete place.dataset.dropPosition; + } + button.dataset.dropPosition = position; +} + +function placeDropPosition(button, event) { + const rect = button.getBoundingClientRect(); + return event.clientY > rect.top + rect.height / 2 ? "after" : "before"; +} + +function clearPlaceDropTargets(elements) { + delete elements.places.dataset.reordering; + for (const place of elements.places.querySelectorAll(".place")) { + place.classList.remove("window-sidebar-item-dragging"); + delete place.dataset.dropPosition; + } +} + +function isNameEditorTarget(target) { + return !!target?.closest?.(".rename-input"); +} + +function isDialogOpen(elements) { + return elements.dialog && !elements.dialog.classList.contains("hidden"); +} + +function isMenuOpen(elements) { + return elements.contextMenu && !elements.contextMenu.classList.contains("hidden"); +} + +function showKeyboardObjectMenu(elements, object, showMenuForObject) { + const item = elements.content.querySelector(`[data-uri="${CSS.escape(object.uri)}"]`); + const rect = item?.getBoundingClientRect(); + showMenuForObject(object, rect ? rect.left + 16 : window.innerWidth / 2, rect ? rect.top + 16 : 120); +} + +function showKeyboardBackgroundMenu(elements, showBackgroundMenu) { + const rect = elements.content.getBoundingClientRect(); + showBackgroundMenu(rect.left + Math.min(48, rect.width / 2), rect.top + Math.min(48, rect.height / 2)); +} + +async function openSelectedObjects(objects, openObject, showError) { + try { + for (const object of objects) { + await openObject(object); + } + } catch (error) { + showError(error); + } +} diff --git a/capsules/library/src/menu.js b/capsules/library/src/menu.js new file mode 100644 index 00000000..f0614578 --- /dev/null +++ b/capsules/library/src/menu.js @@ -0,0 +1,158 @@ +export function createLibraryMenu({ contextMenu, perf, showError }) { + function menuAction(label, action, options = {}) { + const children = Array.isArray(options.children) + ? normalizedMenuActions(options.children) + : []; + return { + label, + action, + children, + disabled: !!options.disabled || (!children.length && typeof action !== "function"), + checked: !!options.checked, + }; + } + + function normalizedMenuActions(actions) { + const normalized = []; + for (const entry of actions) { + if (entry === "-") { + if (normalized.length && normalized[normalized.length - 1] !== "-") normalized.push(entry); + continue; + } + const item = Array.isArray(entry) + ? { label: entry[0], action: entry[1], disabled: !!entry[2] } + : { ...entry }; + if (Array.isArray(item.children)) { + item.children = normalizedMenuActions(item.children); + } + normalized.push(item); + } + while (normalized[0] === "-") normalized.shift(); + while (normalized[normalized.length - 1] === "-") normalized.pop(); + return normalized; + } + + function renderMenu(actions, x, y) { + const startedAt = performance.now(); + contextMenu.innerHTML = ""; + const menuActions = normalizedMenuActions(actions); + if (!menuActions.length) { + hideMenu(); + return; + } + renderMenuEntries(contextMenu, menuActions); + contextMenu.style.visibility = "hidden"; + contextMenu.classList.remove("hidden"); + const rect = contextMenu.getBoundingClientRect(); + const margin = 8; + const left = Math.max(margin, Math.min(x, window.innerWidth - rect.width - margin)); + const top = Math.max(margin, Math.min(y, window.innerHeight - rect.height - margin)); + contextMenu.style.left = left + "px"; + contextMenu.style.top = top + "px"; + contextMenu.dataset.submenuSide = left + rect.width + 226 > window.innerWidth ? "left" : "right"; + contextMenu.style.visibility = ""; + perf.menuRenderCount += 1; + perf.lastMenuRender = { + durationMs: performance.now() - startedAt, + actionCount: countMenuActions(menuActions), + }; + } + + function renderMenuEntries(container, actions) { + for (const entry of actions) { + if (entry === "-") { + const divider = document.createElement("div"); + divider.className = "menu-divider"; + divider.setAttribute("role", "separator"); + container.appendChild(divider); + continue; + } + const { label, action, disabled, checked } = entry; + const children = Array.isArray(entry.children) ? entry.children : []; + const wrapper = document.createElement("div"); + wrapper.className = children.length ? "menu-entry menu-entry-has-children" : "menu-entry"; + const button = document.createElement("button"); + button.className = "menu-item"; + button.type = "button"; + button.title = String(label || ""); + const labelNode = document.createElement("span"); + labelNode.className = "menu-item-label"; + labelNode.textContent = label; + button.appendChild(labelNode); + if (checked) { + const markNode = document.createElement("span"); + markNode.className = "menu-item-mark"; + markNode.textContent = "\u2713"; + button.appendChild(markNode); + } + if (children.length) { + const arrowNode = document.createElement("span"); + arrowNode.className = "menu-item-mark menu-item-arrow"; + arrowNode.textContent = "\u203a"; + button.appendChild(arrowNode); + const openSubmenu = () => { + closeSiblingSubmenus(wrapper); + wrapper.dataset.open = "true"; + }; + button.addEventListener("pointerenter", openSubmenu); + button.addEventListener("mouseenter", openSubmenu); + button.addEventListener("focus", openSubmenu); + } + const runnable = typeof action === "function"; + button.disabled = !!disabled || (!children.length && !runnable); + button.addEventListener("click", (event) => { + event.stopPropagation(); + if (children.length) { + closeSiblingSubmenus(wrapper); + wrapper.dataset.open = "true"; + return; + } + if (button.disabled || !runnable) return; + hideMenu(); + Promise.resolve(action()).catch(showError); + }); + wrapper.appendChild(button); + if (children.length) { + const submenu = document.createElement("div"); + submenu.className = "menu-submenu"; + renderMenuEntries(submenu, children); + wrapper.appendChild(submenu); + wrapper.addEventListener("mouseenter", () => { + closeSiblingSubmenus(wrapper); + wrapper.dataset.open = "true"; + }); + wrapper.addEventListener("focusin", () => { + closeSiblingSubmenus(wrapper); + wrapper.dataset.open = "true"; + }); + } + container.appendChild(wrapper); + } + } + + function closeSiblingSubmenus(wrapper) { + for (const entry of wrapper.parentElement?.querySelectorAll(":scope > .menu-entry[data-open='true']") || []) { + if (entry !== wrapper) entry.dataset.open = "false"; + } + } + + function countMenuActions(actions) { + return actions.reduce((count, entry) => { + if (entry === "-") return count; + return count + 1 + countMenuActions(Array.isArray(entry.children) ? entry.children : []); + }, 0); + } + + function hideMenu() { + for (const entry of contextMenu.querySelectorAll(".menu-entry[data-open='true']")) { + entry.dataset.open = "false"; + } + contextMenu.classList.add("hidden"); + } + + return { + hideMenu, + menuAction, + renderMenu, + }; +} diff --git a/capsules/library/src/model.js b/capsules/library/src/model.js new file mode 100644 index 00000000..cd501534 --- /dev/null +++ b/capsules/library/src/model.js @@ -0,0 +1,243 @@ +export function escapeHtml(value) { + return String(value || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export function shortUri(uri) { + if (!uri) return ""; + if (uri.length <= 44) return uri; + return uri.slice(0, 24) + "..." + uri.slice(-14); +} + +export function baseName(uri) { + const clean = String(uri || "").replace(/\/+$/, ""); + return clean.split("/").pop() || "Explorer"; +} + +export function parentUri(uri) { + const clean = String(uri || "").replace(/\/+$/, ""); + const index = clean.lastIndexOf("/"); + return index > "localhost://".length ? clean.slice(0, index) : clean; +} + +export function childUri(parent, name) { + return String(parent || "").replace(/\/+$/, "") + "/" + encodeURIComponent(name).replace(/%20/g, " "); +} + +export function nextObjectName(objects, base, extension = "") { + const existing = new Set(objects.map((object) => String(object.name || "").toLowerCase())); + const initial = `${base}${extension}`; + if (!existing.has(initial.toLowerCase())) return initial; + for (let index = 2; index < 1000; index += 1) { + const candidate = `${base} ${index}${extension}`; + if (!existing.has(candidate.toLowerCase())) return candidate; + } + return `${base} ${Date.now()}${extension}`; +} + +export function isDirectory(object) { + return object && object.kind === "directory"; +} + +export function inTrash(object) { + return isTrashUri(object?.uri); +} + +export function isTrashUri(uri) { + const value = String(uri || ""); + return value.includes("/.Trash/"); +} + +export function isTrashRootUri(uri) { + return String(uri || "").endsWith("/.Trash"); +} + +export function isBlockedObject(object) { + return !!object?.blocked_reason; +} + +export function hasCapability(object, capability) { + const capabilities = object && object.capabilities; + return !Array.isArray(capabilities) || capabilities.includes(capability); +} + +export function isWebSpaceUri(uri) { + const value = String(uri || "").replace(/\/+$/, ""); + return value === "localhost://WebSpaces" || value.startsWith("localhost://WebSpaces/"); +} + +export function viewerOptions(object) { + return Array.isArray(object && object.viewers) ? object.viewers : []; +} + +export function isArchiveObject(object) { + if (!object || isDirectory(object)) return false; + const capabilities = Array.isArray(object.capabilities) ? object.capabilities : []; + const archiveSupport = object.metadata?.archive_support; + const name = String(object.name || object.uri || "").toLowerCase(); + const mime = String(object.mime || "").toLowerCase(); + return viewerOptions(object).some((viewer) => viewer?.id === "archive-manager") || + !!archiveSupport || + capabilities.includes("extract_archive") || + isArchiveName(name) || + ["application/zip", "application/x-tar", "application/gzip", "application/x-7z-compressed"].includes(mime); +} + +export function archiveLibraryObjectPayload(object) { + const payload = { + uri: object?.uri || "", + name: object?.name || "", + mime: object?.mime || "application/octet-stream", + }; + const localCid = contentCid(object); + if (localCid) payload.contentCid = localCid; + if (object?.metadata?.archive_support) payload.archiveSupport = object.metadata.archive_support; + return payload; +} + +export function contentCid(object) { + return String(object?.content_cid || "").trim(); +} + +export function publishedCid(object) { + return String(object?.published_cid || "").trim(); +} + +export function visibilityContract(object) { + const visibility = object?.metadata?.visibility; + if (visibility && typeof visibility === "object") return visibility; + return { + schema: "elastos.library.visibility/v1", + placement: inTrash(object) ? "trash" : "private_folder", + placement_label: inTrash(object) ? "Trash" : "Private folder", + effective_access: object?.published ? "public_content_link" : "principal_private", + published: !!object?.published, + published_cid: publishedCid(object) || null, + published_link: publishedCid(object) ? `elastos://${publishedCid(object)}` : null, + shared: !!object?.shared, + share_policy: object?.shared ? "shared" : "not_shared", + public_folder_policy: "placement_only", + publish_required_for_public_link: !isDirectory(object) && !object?.published, + }; +} + +export function previewKind(object) { + if (!object || isDirectory(object)) return ""; + const mime = String(object.mime || ""); + const name = String(object.name || "").toLowerCase(); + if (mime.startsWith("image/")) return "image"; + if (mime.startsWith("video/")) return "video"; + if (mime.startsWith("audio/")) return "audio"; + if (mime === "application/pdf" || name.endsWith(".pdf")) return "pdf"; + if ( + mime.startsWith("text/") || + [ + ".txt", + ".md", + ".json", + ".csv", + ".log", + ".html", + ".css", + ".js", + ".ts", + ".rs", + ".toml", + ".yaml", + ".yml", + ].some((ext) => name.endsWith(ext)) + ) { + return "text"; + } + return ""; +} + +export function canPreviewObject(object) { + return !!previewKind(object); +} + +export function iconFor(object) { + if (inTrash(object)) return "icons/trash.svg"; + if (isDirectory(object)) return "icons/folder.svg"; + const mime = String(object.mime || ""); + const name = String(object.name || "").toLowerCase(); + if (mime.startsWith("image/")) return "icons/file-image.svg"; + if (mime.startsWith("video/")) return "icons/file-video.svg"; + if (mime.startsWith("audio/")) return "icons/file-audio.svg"; + if (mime.includes("pdf")) return "icons/file-pdf.svg"; + if (isArchiveName(name)) return "icons/file-zip.svg"; + if (name.endsWith(".json")) return "icons/file-json.svg"; + if (name.endsWith(".md")) return "icons/file-md.svg"; + if (mime.startsWith("text/")) return "icons/file-text.svg"; + return "icons/file.svg"; +} + +function isArchiveName(name) { + return [ + ".zip", + ".tar", + ".tar.gz", + ".tgz", + ".tar.xz", + ".txz", + ".tar.bz2", + ".tbz2", + ".tar.zst", + ".tzst", + ".7z", + ".rar", + ".xz", + ".bz2", + ".zst", + ".lz4", + ".gz", + ].some((extension) => name.endsWith(extension)); +} + +export function formatBytes(bytes) { + const value = Number(bytes || 0); + if (!value) return "-"; + const units = ["B", "KB", "MB", "GB"]; + let size = value; + let unit = 0; + while (size >= 1024 && unit < units.length - 1) { + size /= 1024; + unit += 1; + } + return `${size.toFixed(size >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`; +} + +export function formatTime(timestamp) { + const value = Number(timestamp || 0); + if (!value) return "-"; + return new Date(value * 1000).toLocaleString(); +} + +export function fileToBase64(file, onProgress) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error("Could not read file.")); + reader.onprogress = (event) => { + if (event.lengthComputable && typeof onProgress === "function") { + onProgress(event.loaded / Math.max(event.total, 1)); + } + }; + reader.onload = () => { + const dataUrl = String(reader.result || ""); + resolve(dataUrl.split(",", 2)[1] || ""); + }; + reader.readAsDataURL(file); + }); +} + +export function base64ToBlob(data, mime) { + const binary = atob(data || ""); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return new Blob([bytes], { type: mime || "application/octet-stream" }); +} diff --git a/capsules/library/src/navigation.js b/capsules/library/src/navigation.js new file mode 100644 index 00000000..8bd21574 --- /dev/null +++ b/capsules/library/src/navigation.js @@ -0,0 +1,152 @@ +const LIBRARY_HISTORY_SCHEMA = "elastos.library.history/v1"; +const LIBRARY_HISTORY_GUARD_SCHEMA = "elastos.library.history.guard/v1"; + +export function createLibraryNavigation({ + backButton, + forwardButton, + loadCurrentFolder, + parentUri, + rootForUri, + searchInput, + setStatus, + state, + syncRouteChrome = () => {}, + upButton, +}) { + function syncNavigationButtons() { + backButton.disabled = state.backStack.length === 0; + forwardButton.disabled = state.forwardStack.length === 0; + const root = rootForUri(state.currentUri); + upButton.disabled = !root || state.currentUri === root.uri; + } + + function browserHistoryAvailable() { + return !!(window.history && typeof window.history.pushState === "function" && typeof window.history.replaceState === "function"); + } + + function createHistoryEntry(uri) { + return { key: `library:${++state.historyKeyCounter}`, uri }; + } + + function libraryHistoryState(entry) { + return { + schema: LIBRARY_HISTORY_SCHEMA, + key: entry.key, + uri: entry.uri, + }; + } + + function libraryHistoryGuardState(uri) { + return { + schema: LIBRARY_HISTORY_GUARD_SCHEMA, + uri, + }; + } + + function syncNavigationStacksFromHistory() { + if (state.historyIndex < 0) return; + state.backStack = state.historyEntries.slice(0, state.historyIndex).map((entry) => entry.uri); + state.forwardStack = state.historyEntries.slice(state.historyIndex + 1).map((entry) => entry.uri).reverse(); + syncNavigationButtons(); + } + + function installBrowserHistory() { + if (!browserHistoryAvailable() || !state.currentUri) return; + const entry = createHistoryEntry(state.currentUri); + state.historyEntries = [entry]; + state.historyIndex = 0; + window.history.replaceState(libraryHistoryGuardState(state.currentUri), "", window.location.href); + window.history.pushState(libraryHistoryState(entry), "", window.location.href); + syncNavigationStacksFromHistory(); + } + + function pushBrowserHistory(uri) { + if (!browserHistoryAvailable() || !uri || state.historyIndex < 0) return false; + state.historyEntries = state.historyEntries.slice(0, state.historyIndex + 1); + const entry = createHistoryEntry(uri); + state.historyEntries.push(entry); + state.historyIndex = state.historyEntries.length - 1; + window.history.pushState(libraryHistoryState(entry), "", window.location.href); + syncNavigationStacksFromHistory(); + return true; + } + + async function handleBrowserPopState(event) { + const data = event.state || {}; + if (data.schema === LIBRARY_HISTORY_GUARD_SCHEMA) { + const entry = state.historyEntries[state.historyIndex] || createHistoryEntry(state.currentUri); + window.history.pushState(libraryHistoryState(entry), "", window.location.href); + setStatus("Explorer kept focus."); + return; + } + if (data.schema !== LIBRARY_HISTORY_SCHEMA || !data.key || !data.uri) return; + const index = state.historyEntries.findIndex((entry) => entry.key === data.key); + if (index === -1) return; + state.historyIndex = index; + syncNavigationStacksFromHistory(); + if (state.currentUri === data.uri) return; + state.currentUri = data.uri; + state.query = ""; + searchInput.value = ""; + syncRouteChrome(); + syncNavigationButtons(); + await loadCurrentFolder({ useCache: true }); + } + + async function navigate(uri, options = {}) { + if (!uri || uri === state.currentUri) { + return; + } + const previousUri = state.currentUri; + if (options.record !== false && state.currentUri) { + if (!pushBrowserHistory(uri)) { + state.backStack.push(previousUri); + state.forwardStack = []; + } + } + state.currentUri = uri; + state.query = ""; + searchInput.value = ""; + syncRouteChrome(); + syncNavigationButtons(); + await loadCurrentFolder({ useCache: true }); + } + + async function navigateBack() { + if (browserHistoryAvailable() && state.historyIndex > 0) { + window.history.back(); + return; + } + const uri = state.backStack.pop(); + if (!uri) return; + if (state.currentUri) state.forwardStack.push(state.currentUri); + await navigate(uri, { record: false }); + } + + async function navigateForward() { + if (browserHistoryAvailable() && state.historyIndex >= 0 && state.historyIndex < state.historyEntries.length - 1) { + window.history.forward(); + return; + } + const uri = state.forwardStack.pop(); + if (!uri) return; + if (state.currentUri) state.backStack.push(state.currentUri); + await navigate(uri, { record: false }); + } + + async function navigateUp() { + const root = rootForUri(state.currentUri); + if (!root || state.currentUri === root.uri) return; + await navigate(parentUri(state.currentUri)); + } + + return { + handleBrowserPopState, + installBrowserHistory, + navigate, + navigateBack, + navigateForward, + navigateUp, + syncNavigationButtons, + }; +} diff --git a/capsules/library/src/preview.js b/capsules/library/src/preview.js new file mode 100644 index 00000000..e682600e --- /dev/null +++ b/capsules/library/src/preview.js @@ -0,0 +1,86 @@ +import { + base64ToBlob, + escapeHtml, + formatBytes, + previewKind, + shortUri, +} from "./model.js"; + +const PREVIEW_MAX_BYTES = 8 * 1024 * 1024; +const TEXT_PREVIEW_MAX_BYTES = 1 * 1024 * 1024; + +export function createLibraryPreview({ + dialog, + providerApi, + setStatus, + showProperties, + state, +}) { + function revokePreviewUrl() { + if (state.previewUrl) { + URL.revokeObjectURL(state.previewUrl); + state.previewUrl = ""; + } + } + + async function previewObject(object) { + const kind = previewKind(object); + if (!kind) { + showProperties(object); + return; + } + const maxBytes = kind === "text" ? TEXT_PREVIEW_MAX_BYTES : PREVIEW_MAX_BYTES; + if (Number(object.size || 0) > maxBytes) { + setStatus(`Preview for ${object.name} is too large. Use Open or Download.`); + showProperties(object); + return; + } + setStatus(`Loading preview for ${object.name}...`); + const data = await providerApi("read", { uri: object.uri }); + const blob = base64ToBlob(data.data, data.object?.mime || object.mime); + revokePreviewUrl(); + let previewMarkup = ""; + if (kind === "text") { + const text = await blob.text(); + previewMarkup = `
${escapeHtml(text)}
`; + } else { + state.previewUrl = URL.createObjectURL(blob); + const url = escapeHtml(state.previewUrl); + if (kind === "image") { + previewMarkup = `${escapeHtml(object.name)}`; + } else if (kind === "video") { + previewMarkup = ``; + } else if (kind === "audio") { + previewMarkup = ``; + } else if (kind === "pdf") { + previewMarkup = ``; + } + } + dialog.innerHTML = ` +
+
+

Preview

+

${escapeHtml(object.name)}

+
+
${previewMarkup}
+
+
Type
${escapeHtml(object.mime || "application/octet-stream")}
+
Size
${escapeHtml(formatBytes(object.size))}
+
Source
${escapeHtml(shortUri(object.uri))}
+
+
+ + +
+
+ `; + dialog.dataset.previewUri = object.uri; + dialog.classList.remove("hidden"); + setStatus(`Previewing ${object.name}.`); + } + + return { + previewObject, + revokePreviewUrl, + }; +} diff --git a/capsules/library/src/realtime.js b/capsules/library/src/realtime.js new file mode 100644 index 00000000..2adea651 --- /dev/null +++ b/capsules/library/src/realtime.js @@ -0,0 +1,85 @@ +export function createLibraryRealtime({ + loadCurrentFolder, + parentUri, + showError, + state, +}) { + function startLibraryEventStream() { + if (!("EventSource" in window) || state.eventSource || !state.homeToken) { + return; + } + window.clearTimeout(state.eventReconnectTimer); + const url = "/api/provider/object/events/stream?home_token=" + encodeURIComponent(state.homeToken); + const source = new EventSource(url); + state.eventSource = source; + source.addEventListener("library-events", (event) => { + try { + handleLibraryEventsPayload(JSON.parse(event.data || "{}")); + } catch (error) { + console.warn("Library event stream returned invalid payload", error); + } + }); + source.onerror = () => { + source.close(); + if (state.eventSource === source) { + state.eventSource = null; + } + state.eventReconnectTimer = window.setTimeout(startLibraryEventStream, 2_000); + }; + } + + function stopLibraryEventStream() { + if (state.eventSource) { + state.eventSource.close(); + state.eventSource = null; + } + window.clearTimeout(state.eventReconnectTimer); + window.clearTimeout(state.eventRefreshTimer); + } + + function handleLibraryEventsPayload(payload) { + if (payload?.schema !== "elastos.library.events/v1") { + return; + } + const events = Array.isArray(payload.events) ? payload.events : []; + if (events.some(eventTouchesCurrentFolder)) { + scheduleLibraryEventRefresh(); + } + } + + function scheduleLibraryEventRefresh() { + window.clearTimeout(state.eventRefreshTimer); + state.eventRefreshTimer = window.setTimeout(() => { + loadCurrentFolder().catch(showError); + }, 180); + } + + function eventTouchesCurrentFolder(event) { + if (!state.currentUri) return false; + return eventUris(event).some((uri) => uriTouchesFolder(uri, state.currentUri)); + } + + function eventUris(event) { + const details = event && typeof event.details === "object" ? event.details : {}; + return [ + event?.uri, + details.old_uri, + details.original_uri, + details.source_uri, + details.trash_uri, + details.target_uri, + details.object?.uri, + ].filter(Boolean).map(String); + } + + function uriTouchesFolder(uri, folderUri) { + const folder = String(folderUri || "").replace(/\/+$/, ""); + const value = String(uri || "").replace(/\/+$/, ""); + return value === folder || value.startsWith(folder + "/") || parentUri(value) === folder; + } + + return { + startLibraryEventStream, + stopLibraryEventStream, + }; +} diff --git a/capsules/library/src/render.js b/capsules/library/src/render.js new file mode 100644 index 00000000..1f6da4cc --- /dev/null +++ b/capsules/library/src/render.js @@ -0,0 +1,238 @@ +import { + escapeHtml, + formatBytes, + formatTime, + iconFor, + inTrash, + isWebSpaceUri, + isBlockedObject, + isDirectory, + shortUri, + visibilityContract, +} from "./model.js"; + +const LARGE_RENDER_THRESHOLD = 240; +const INITIAL_RENDER_LIMIT = 120; +const RENDER_CHUNK_SIZE = 180; +const OBJECT_NODE_CACHE_LIMIT = 3_000; + +export function iconPlaceholder(src, className) { + const safeSrc = escapeHtml(src); + return ``; +} + +export function createLibraryRenderer({ + elements, + isSelected, + perf, + selectedObjects, + state, + visibleObjects, +}) { + function renderContent() { + const startedAt = performance.now(); + const job = ++state.contentRenderJob; + elements.content.dataset.view = state.view === "list" ? "list" : "grid"; + elements.sortSelect.value = state.sort; + const objects = visibleObjects(); + if (!objects.length) { + elements.content.dataset.empty = "true"; + const copy = emptyStateCopy(state); + const empty = document.createElement("div"); + empty.className = "empty"; + empty.innerHTML = `

${escapeHtml(copy.title)}

${escapeHtml(copy.body)}

`; + elements.content.replaceChildren(empty); + perf.contentRenderCount += 1; + perf.lastContentRender = { + durationMs: performance.now() - startedAt, + objectCount: 0, + view: state.view, + }; + return; + } + elements.content.dataset.empty = "false"; + const chunked = objects.length > LARGE_RENDER_THRESHOLD; + const firstLimit = chunked ? Math.min(INITIAL_RENDER_LIMIT, objects.length) : objects.length; + const fragment = document.createDocumentFragment(); + if (state.view === "list") { + fragment.appendChild(renderListHeader()); + } + for (const object of objects.slice(0, firstLimit)) { + fragment.appendChild(renderObject(object)); + } + elements.content.replaceChildren(fragment); + pruneObjectNodeCache(objects); + perf.contentRenderCount += 1; + perf.lastContentRender = { + durationMs: performance.now() - startedAt, + objectCount: objects.length, + initialRenderedCount: firstLimit, + renderedCount: firstLimit, + complete: !chunked, + chunked, + view: state.view, + }; + if (chunked) { + renderContentChunks(objects, firstLimit, job, startedAt); + } + } + + function emptyStateCopy(state) { + if (state.query) { + return { title: "No matching objects", body: "Try another search." }; + } + if (isWebSpaceUri(state.currentUri)) { + return { + title: "No objects in this space", + body: "Localhost is your signed local object space. Elastos and mounted spaces resolve through providers and show only the actions they actually support.", + }; + } + return { title: "This folder is empty", body: "Upload a file or create a folder." }; + } + + function renderContentChunks(objects, startIndex, job, startedAt) { + window.requestAnimationFrame(() => { + if (job !== state.contentRenderJob) return; + const fragment = document.createDocumentFragment(); + const nextIndex = Math.min(startIndex + RENDER_CHUNK_SIZE, objects.length); + for (const object of objects.slice(startIndex, nextIndex)) { + fragment.appendChild(renderObject(object)); + } + elements.content.appendChild(fragment); + if (nextIndex < objects.length) { + renderContentChunks(objects, nextIndex, job, startedAt); + return; + } + perf.lastContentRender = { + ...(perf.lastContentRender || {}), + durationMs: perf.lastContentRender?.durationMs || 0, + completeDurationMs: performance.now() - startedAt, + renderedCount: objects.length, + complete: true, + }; + }); + } + + function renderListHeader() { + const header = document.createElement("div"); + header.className = "explore-table-headers"; + header.innerHTML = ` + Name + Modified + Size + Type + `; + return header; + } + + function renderObject(object) { + const signature = objectRenderSignature(object); + const cached = state.objectNodeCache.get(object.uri); + if (cached && cached.signature === signature && !cached.node.querySelector(".rename-input")) { + perf.objectNodeCacheHits += 1; + cached.node.dataset.selected = isSelected(object.uri) ? "true" : "false"; + state.objectNodeCache.delete(object.uri); + state.objectNodeCache.set(object.uri, cached); + return cached.node; + } + perf.objectNodeCacheMisses += 1; + const item = document.createElement("article"); + item.className = "item"; + item.dataset.uri = object.uri; + item.dataset.kind = object.kind; + item.dataset.blocked = isBlockedObject(object) ? "true" : "false"; + item.dataset.selected = isSelected(object.uri) ? "true" : "false"; + item.draggable = !isBlockedObject(object); + item.tabIndex = 0; + const visibility = visibilityContract(object); + const inPublicFolder = visibility.placement === "public_folder"; + const badges = [ + isBlockedObject(object) ? 'Blocked' : "", + inPublicFolder && !object.published ? 'Public folder' : "", + object.published ? 'Published' : "", + inTrash(object) ? 'Trash' : "", + ].join(""); + const badgesMarkup = badges ? `${badges}` : ""; + item.innerHTML = ` + ${iconPlaceholder(iconFor(object), "file-icon")} + ${escapeHtml(object.name)} + ${escapeHtml(formatTime(object.modified_at))} + ${escapeHtml(isDirectory(object) ? "-" : formatBytes(object.size))} + ${escapeHtml(isDirectory(object) ? "Folder" : object.mime || "File")} + ${badgesMarkup} + `; + state.objectNodeCache.set(object.uri, { signature, node: item }); + return item; + } + + function objectRenderSignature(object) { + return [ + object.uri, + object.kind, + object.name, + object.mime || "", + object.size || 0, + object.modified_at || 0, + object.revision || "", + object.content_cid || "", + object.published_cid || "", + object.availability || "", + object.metadata?.visibility?.placement || "", + object.metadata?.visibility?.effective_access || "", + object.published ? 1 : 0, + object.shared ? 1 : 0, + object.blocked_reason || "", + inTrash(object) ? 1 : 0, + isBlockedObject(object) ? 1 : 0, + ].join("\u0000"); + } + + function pruneObjectNodeCache(visible) { + if (state.objectNodeCache.size <= OBJECT_NODE_CACHE_LIMIT) return; + const visibleUris = new Set(visible.map((object) => object.uri)); + for (const uri of state.objectNodeCache.keys()) { + if (state.objectNodeCache.size <= OBJECT_NODE_CACHE_LIMIT) break; + if (!visibleUris.has(uri)) state.objectNodeCache.delete(uri); + } + } + + function renderFooter() { + const visible = visibleObjects().length; + const selected = selectedObjects().length; + const prefix = selected ? `${selected} selected · ` : ""; + elements.footerLeft.textContent = `${prefix}${visible} object${visible === 1 ? "" : "s"}`; + elements.footerRight.textContent = shortUri(state.currentUri); + } + + function scheduleContentRender() { + if (state.contentRenderFrame) return; + state.contentRenderFrame = window.requestAnimationFrame(() => { + state.contentRenderFrame = 0; + renderContent(); + renderFooter(); + }); + } + + function syncViewButtons() { + elements.gridButton.classList.toggle("layout-toggle-segment-active", state.view !== "list"); + elements.listButton.classList.toggle("layout-toggle-segment-active", state.view === "list"); + elements.gridButton.setAttribute("aria-pressed", state.view !== "list" ? "true" : "false"); + elements.listButton.setAttribute("aria-pressed", state.view === "list" ? "true" : "false"); + } + + function syncContentViewMode() { + elements.content.dataset.view = state.view === "list" ? "list" : "grid"; + elements.content.querySelector(".explore-table-headers")?.remove(); + if (state.view === "list" && visibleObjects().length) { + elements.content.prepend(renderListHeader()); + } + } + + return { + renderContent, + renderFooter, + scheduleContentRender, + syncContentViewMode, + syncViewButtons, + }; +} diff --git a/capsules/library/src/selection.js b/capsules/library/src/selection.js new file mode 100644 index 00000000..f994dc7d --- /dev/null +++ b/capsules/library/src/selection.js @@ -0,0 +1,103 @@ +export function createLibrarySelection({ + content, + renderFooter, + state, + visibleObjects, +}) { + function objectByUri(uri) { + return state.objectsByUri.get(uri); + } + + function selectedObjects() { + return Array.from(state.selectedUris) + .map((uri) => state.objectsByUri.get(uri)) + .filter(Boolean); + } + + function isSelected(uri) { + return state.selectedUris.has(uri); + } + + function selectOnly(uri) { + state.selectedUris.clear(); + if (uri) state.selectedUris.add(uri); + state.selectionAnchorUri = uri || ""; + syncSelectionDom(); + } + + function toggleSelected(uri) { + if (!uri) return; + if (state.selectedUris.has(uri)) { + state.selectedUris.delete(uri); + } else { + state.selectedUris.add(uri); + } + state.selectionAnchorUri = uri; + syncSelectionDom(); + } + + function selectRangeTo(uri, extend = false) { + if (!uri) return; + const visible = visibleObjects(); + const anchorUri = state.selectionAnchorUri || Array.from(state.selectedUris)[0] || uri; + const anchorIndex = visible.findIndex((object) => object.uri === anchorUri); + const targetIndex = visible.findIndex((object) => object.uri === uri); + if (anchorIndex < 0 || targetIndex < 0) { + selectOnly(uri); + return; + } + if (!extend) state.selectedUris.clear(); + const start = Math.min(anchorIndex, targetIndex); + const end = Math.max(anchorIndex, targetIndex); + for (const object of visible.slice(start, end + 1)) { + state.selectedUris.add(object.uri); + } + state.selectionAnchorUri = anchorUri; + syncSelectionDom(); + } + + function selectAllVisible() { + const visible = visibleObjects(); + state.selectedUris = new Set(visible.map((object) => object.uri)); + state.selectionAnchorUri = visible[0]?.uri || ""; + syncSelectionDom(); + } + + function clearSelection(render = true) { + state.selectedUris.clear(); + state.selectionAnchorUri = ""; + if (render) { + syncSelectionDom(); + } + } + + function syncSelectionDom() { + for (const item of content.querySelectorAll(".item[data-uri]")) { + item.dataset.selected = state.selectedUris.has(item.dataset.uri) ? "true" : "false"; + } + renderFooter(); + } + + function prepareDragSelection(uri, item) { + if (!isSelected(uri)) { + state.selectedUris.clear(); + state.selectedUris.add(uri); + state.selectionAnchorUri = uri; + if (item) item.dataset.selected = "true"; + syncSelectionDom(); + } + } + + return { + clearSelection, + isSelected, + objectByUri, + prepareDragSelection, + selectAllVisible, + selectedObjects, + selectOnly, + selectRangeTo, + syncSelectionDom, + toggleSelected, + }; +} diff --git a/capsules/library/src/state.js b/capsules/library/src/state.js new file mode 100644 index 00000000..0406945a --- /dev/null +++ b/capsules/library/src/state.js @@ -0,0 +1,184 @@ +export const MUTATING_PROVIDER_OPS = new Set([ + "write", + "mkdir", + "rename", + "move", + "copy", + "trash", + "restore", + "delete_permanently", + "empty_trash", + "extract_archive", + "compress_archive", + "publish", + "unpublish", + "repair", + "share", +]); + +export function createLibraryState({ queryParams, storage, perfTarget }) { + const rawMode = queryParams.get("mode") || ""; + const state = { + homeToken: queryParams.get("home_token") || "", + mode: ["attach", "archive-open", "archive-create"].includes(rawMode) ? rawMode : "browse", + returnTarget: queryParams.get("returnTarget") || "", + initialUri: queryParams.get("uri") || "", + initialObjectUri: queryParams.get("objectUri") || "", + initialAction: queryParams.get("action") || "", + initialActionHandled: false, + roots: [], + currentUri: "", + currentObject: null, + objects: [], + objectsByUri: new Map(), + objectsVersion: 0, + visibleCacheKey: "", + visibleCache: [], + folderCache: new Map(), + folderPrefetches: new Map(), + rootPrefetchStarted: false, + contentRenderFrame: 0, + contentRenderJob: 0, + objectNodeCache: new Map(), + selectedUris: new Set(), + selectionAnchorUri: "", + view: storage.getItem("library.view") || "grid", + sort: storage.getItem("library.sort") || "name", + sortOrder: storage.getItem("library.sortOrder") || "asc", + sidebarOrder: readStoredStringArray(storage.getItem("library.sidebarOrder")), + showHidden: storage.getItem("library.showHidden") === "true", + query: "", + loading: false, + loadSeq: 0, + eventSource: null, + eventReconnectTimer: null, + eventRefreshTimer: null, + uploads: [], + previewUrl: "", + draftCounter: 0, + backStack: [], + forwardStack: [], + historyEntries: [], + historyIndex: -1, + historyKeyCounter: 0, + clipboard: { + op: "", + uris: [], + }, + }; + const perf = perfTarget || {}; + Object.assign(perf, { + iconFetchCount: 0, + renderPlacesCount: 0, + contentRenderCount: 0, + menuRenderCount: 0, + uploadRenderCount: 0, + uploadRenderScheduledCount: 0, + lastContentRender: null, + lastMenuRender: null, + folderCacheHits: 0, + folderCacheSize: 0, + objectNodeCacheHits: 0, + objectNodeCacheMisses: 0, + }); + return { state, perf }; +} + +function readStoredStringArray(value) { + try { + const parsed = JSON.parse(value || "[]"); + return Array.isArray(parsed) + ? parsed.filter((entry) => typeof entry === "string" && entry) + : []; + } catch { + return []; + } +} + +export function setLibraryObjects(state, objects) { + state.objects = Array.isArray(objects) ? objects : []; + state.objectsByUri = new Map(state.objects.map((object) => [object.uri, object])); + state.objectsVersion += 1; + invalidateVisibleCache(state); +} + +export function cacheFolderListing(state, perf, uri, objects, object = null) { + const safeObjects = Array.isArray(objects) ? objects : []; + const cached = { + object, + objects: safeObjects, + signature: `${folderListingSignature(safeObjects)}\u0000${folderObjectSignature(object)}`, + }; + state.folderCache.set(uri, cached); + perf.folderCacheSize = state.folderCache.size; + return cached; +} + +function folderObjectSignature(object) { + if (!object) return ""; + const metadata = object.metadata || {}; + return [ + object.uri || "", + metadata.readonly === false ? "writable" : "readonly", + metadata.access_policy || "", + metadata.webspace_kind || "", + ].join("\u0001"); +} + +export function visibleObjectsForState(state) { + const cacheKey = [ + state.objectsVersion, + state.query, + state.showHidden ? "hidden" : "visible", + state.sort, + state.sortOrder, + ].join("\u0000"); + if (state.visibleCacheKey === cacheKey) return state.visibleCache; + const query = state.query.trim().toLowerCase(); + const objects = state.objects.filter((object) => { + if (!state.showHidden && String(object.name || "").startsWith(".")) return false; + if (!query) return true; + return [object.name, object.uri, object.mime, object.content_cid || "", object.published_cid || ""] + .join("\n") + .toLowerCase() + .includes(query); + }); + objects.sort((left, right) => { + if (left.kind !== right.kind) { + return left.kind === "directory" ? -1 : 1; + } + let result = 0; + if (state.sort === "modified") result = Number(left.modified_at || 0) - Number(right.modified_at || 0); + else if (state.sort === "size") result = Number(left.size || 0) - Number(right.size || 0); + else if (state.sort === "type") result = String(left.mime || "").localeCompare(String(right.mime || "")); + else result = String(left.name || "").localeCompare(String(right.name || ""), undefined, { sensitivity: "base" }); + return state.sortOrder === "desc" ? -result : result; + }); + state.visibleCacheKey = cacheKey; + state.visibleCache = objects; + return objects; +} + +function invalidateVisibleCache(state) { + state.visibleCacheKey = ""; + state.visibleCache = []; +} + +function folderListingSignature(objects) { + return JSON.stringify((Array.isArray(objects) ? objects : []).map((object) => [ + object.uri, + object.revision, + object.kind, + object.name, + object.size, + object.modified_at, + object.content_cid || "", + object.published_cid || "", + object.availability || "", + object.metadata?.visibility?.placement || "", + object.metadata?.visibility?.effective_access || "", + object.published ? 1 : 0, + object.shared ? 1 : 0, + object.blocked_reason || "", + ])); +} diff --git a/capsules/library/src/uploads.js b/capsules/library/src/uploads.js new file mode 100644 index 00000000..b449cea5 --- /dev/null +++ b/capsules/library/src/uploads.js @@ -0,0 +1,54 @@ +import { escapeHtml } from "./model.js"; + +export function createLibraryUploads({ container, perf, state }) { + let uploadRenderFrame = 0; + + function cancelUploadRender() { + if (!uploadRenderFrame) return; + window.cancelAnimationFrame(uploadRenderFrame); + uploadRenderFrame = 0; + } + + function scheduleUploadRender() { + if (uploadRenderFrame) return; + perf.uploadRenderScheduledCount += 1; + uploadRenderFrame = window.requestAnimationFrame(() => { + uploadRenderFrame = 0; + renderUploads(); + }); + } + + function renderUploads() { + cancelUploadRender(); + perf.uploadRenderCount += 1; + if (!state.uploads.length) { + container.classList.add("hidden"); + container.innerHTML = ""; + return; + } + container.classList.remove("hidden"); + container.innerHTML = state.uploads.map((upload) => ` +
+
+
${escapeHtml(upload.name)}
+
${escapeHtml(upload.status)}
+
+
+
+
+
+ `).join(""); + } + + function setUploadProgress(id, patch) { + const upload = state.uploads.find((entry) => entry.id === id); + if (!upload) return; + Object.assign(upload, patch); + scheduleUploadRender(); + } + + return { + renderUploads, + setUploadProgress, + }; +} diff --git a/capsules/object-provider/Cargo.lock b/capsules/object-provider/Cargo.lock new file mode 100644 index 00000000..30491701 --- /dev/null +++ b/capsules/object-provider/Cargo.lock @@ -0,0 +1,7385 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "acto" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598381761ee991bf2f1455f700380e2191fb370dc9df1ee764f348b7f089d8b6" +dependencies = [ + "parking_lot", + "pin-project-lite", + "rustc_version", + "smol_str", + "sync_wrapper", + "tokio", + "tracing", +] + +[[package]] +name = "actor-helper" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db99f3032635124f4ad5639cfdb8fc571c84fd9c6f351e05dab7c6558ca2b157" +dependencies = [ + "anyhow", + "flume", + "futures-executor", + "futures-util", + "tokio", +] + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-compat" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64", + "http", + "log", + "url", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http", + "http-body", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base256emoji" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" +dependencies = [ + "const-str", + "match-lookup", +] + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96eb4cdd6cf1b31d671e9efe75c5d1ec614776856cefbe109ca373554a6d514f" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cap-fs-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "cap-net-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20a158160765c6a7d0d8c072a53d772e4cb243f38b04bfcf6b4939cfbe7482e7" +dependencies = [ + "cap-primitives", + "cap-std", + "rustix 1.1.4", + "smallvec", +] + +[[package]] +name = "cap-primitives" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes", + "ipnet", + "maybe-owned", + "rustix 1.1.4", + "rustix-linux-procfs", + "windows-sys 0.59.0", + "winx", +] + +[[package]] +name = "cap-rand" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d" +dependencies = [ + "ambient-authority", + "rand 0.8.6", +] + +[[package]] +name = "cap-std" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes", + "rustix 1.1.4", +] + +[[package]] +name = "cap-time-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80" +dependencies = [ + "ambient-authority", + "cap-primitives", + "iana-time-zone", + "once_cell", + "rustix 1.1.4", + "winx", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cid" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a304f95f84d169a6f31c4d0a30d784643aaa0bbc9c1e449a2c23e963ec4971" +dependencies = [ + "multibase", + "multihash", + "unsigned-varint", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-str" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "cranelift-assembler-x64" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de2be1bdbf929c2a2242cbbe15d6583c56f1cc723c6c8452d0179362de28c9d5" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a0336914de11298290783a95a9a7154b894da601659eb5f8f8bc62d1bea98f8" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb972cba51a52c1b2a329fec993b911e4d1f9cfab3795811a319b6746c28e014" +dependencies = [ + "cranelift-entity", +] + +[[package]] +name = "cranelift-bitset" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "642c920666bfed9aebca39d8c6e7cb76f09314cc7a4074b1db5edcccdde771b9" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-codegen" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1231caaeee3d2363d9b2dba9d6c1f7ff835b8ede6612fba98120af73df44bd" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.15.5", + "log", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "smallvec", + "target-lexicon", + "wasmtime-internal-math", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb83e89be8b413e4f7a4215a02d5c5f3e6f04b1060f5db293dd1007b2871dcf5" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d14f8068a98f0a85ffa63dc5fe73cb486a955adbe7311465d13cde54c656d5f" + +[[package]] +name = "cranelift-control" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c070aee9312b9736028e99b58d45e1099683386082af38529d5e2ce8c76648f3" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d619bb3d14251e96dc9b6a846d6955d78048a168cc3876eb2b789b855c1c22" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", +] + +[[package]] +name = "cranelift-frontend" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2350fcff24d78be5e4201e1eeb4b306e474b9f21e452722b21ffc4f773e8d49a" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bdc2b14d7491c53c2989b967b4c07511374733abbc01a895fb01ea31e97bfc8" + +[[package]] +name = "cranelift-native" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e98dbe1326d0001a17b3b0675e3adafcfbd0e7f25f1f845a2f1bb9ce3029f359" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.123.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36d7af563cd300c8a1e4e64387929b40e32867112143f0a0e1ce90f977ce4a41" + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f9200d1d13637f15a6acb71e758f64624048d85b31a5fdbfd8eca1e2687d0b7" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.11.0-rc.10", + "fiat-crypto 0.3.0", + "rand_core 0.9.5", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn", +] + +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02c1d73e9668ea6b6a28172aa55f3ebec38507131ce179051c8033b5c6037653" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afa94b64bfc6549e6e4b5a3216f22593224174083da7a90db47e951c4fb31725" +dependencies = [ + "block-buffer 0.11.0", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "distributed-topic-tracker" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0712d81d0381dc9d2ad9536326d0dde52595d4df73c61a9fd910a9afbf237ef" +dependencies = [ + "actor-helper", + "anyhow", + "chrono", + "ed25519-dalek 3.0.0-pre.1", + "ed25519-dalek-hpke", + "futures-lite", + "iroh", + "iroh-gossip", + "mainline", + "postcard", + "rand 0.9.4", + "serde", + "sha2 0.10.9", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "dlopen2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b4f5f101177ff01b8ec4ecc81eead416a8aa42819a2869311b3420fa114ffa" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519" +version = "3.0.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890" +dependencies = [ + "pkcs8 0.11.0-rc.10", + "serde", + "signature 3.0.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad207ed88a133091f83224265eac21109930db09bedcad05d5252f2af2de20a1" +dependencies = [ + "curve25519-dalek 5.0.0-pre.1", + "ed25519 3.0.0-rc.4", + "rand_core 0.9.5", + "serde", + "sha2 0.11.0-rc.2", + "signature 3.0.0", + "subtle", + "zeroize", +] + +[[package]] +name = "ed25519-dalek-hpke" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3553f25a4e38b5ca64b26c54f34e1dd5092d18a5dea03797ac235baab53eaf8f" +dependencies = [ + "ed25519-dalek 3.0.0-pre.1", + "hpke", + "rand 0.9.4", + "x25519-dalek", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "elastos-auth" +version = "0.2.0" +dependencies = [ + "chrono", + "hex", + "k256", + "serde", + "sha2 0.10.9", + "sha3", +] + +[[package]] +name = "elastos-common" +version = "0.2.0" +dependencies = [ + "hex", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 1.0.69", +] + +[[package]] +name = "elastos-compute" +version = "0.2.0" +dependencies = [ + "async-trait", + "dirs", + "elastos-common", + "libc", + "tokio", + "tracing", + "uuid", + "wasmtime", + "wasmtime-wasi", +] + +[[package]] +name = "elastos-crosvm" +version = "0.2.0" +dependencies = [ + "async-trait", + "elastos-common", + "elastos-compute", + "libc", + "nix", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "elastos-identity" +version = "0.2.0" +dependencies = [ + "aes-gcm", + "anyhow", + "aws-lc-rs", + "base64", + "bs58", + "ciborium", + "ed25519-dalek 2.2.0", + "hex", + "hkdf", + "p256", + "rand 0.8.6", + "serde", + "serde_json", + "sha2 0.10.9", + "tracing", + "uuid", + "zeroize", +] + +[[package]] +name = "elastos-namespace" +version = "0.2.0" +dependencies = [ + "async-trait", + "base64", + "cid", + "ed25519-dalek 2.2.0", + "elastos-common", + "hex", + "multihash", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "tokio", + "tracing", +] + +[[package]] +name = "elastos-runtime" +version = "0.2.0" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "bincode", + "ed25519-dalek 2.2.0", + "elastos-auth", + "elastos-common", + "elastos-compute", + "elastos-namespace", + "elastos-storage", + "flate2", + "hex", + "rand 0.8.6", + "serde", + "serde_json", + "sha2 0.10.9", + "tar", + "tempfile", + "tokio", + "toml", + "tracing", + "uuid", +] + +[[package]] +name = "elastos-server" +version = "0.2.0" +dependencies = [ + "aes-gcm", + "anyhow", + "argon2", + "async-trait", + "axum", + "axum-server", + "base64", + "bs58", + "cid", + "clap", + "data-encoding", + "dirs", + "distributed-topic-tracker", + "ed25519-dalek 2.2.0", + "ed25519-dalek 3.0.0-pre.1", + "elastos-common", + "elastos-compute", + "elastos-crosvm", + "elastos-identity", + "elastos-namespace", + "elastos-runtime", + "elastos-storage", + "elastos-tls", + "flate2", + "futures-lite", + "getrandom 0.2.17", + "hex", + "hkdf", + "iroh", + "iroh-gossip", + "libc", + "qrcodegen", + "rand 0.8.6", + "reqwest 0.12.28", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "tar", + "tempfile", + "tokio", + "toml", + "tower-http 0.5.2", + "tracing", + "tracing-subscriber", + "url", + "zip", +] + +[[package]] +name = "elastos-storage" +version = "0.2.0" +dependencies = [ + "async-trait", + "dirs", + "elastos-common", + "futures", + "hex", + "lru 0.18.0", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "elastos-tls" +version = "0.2.0" +dependencies = [ + "anyhow", + "axum-server", + "rcgen", + "time", + "tokio", + "tokio-rustls", + "tracing", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "enum-assoc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fastbloom" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" +dependencies = [ + "getrandom 0.3.4", + "libm", + "rand 0.9.4", + "siphasher", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.8", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-err" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" +dependencies = [ + "autocfg", + "tokio", +] + +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.0", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "fxprof-processed-profile" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27d12c0aed7f1e24276a241aadc4cb8ea9f83000f34bc062b7cc2d51e3b0fabd" +dependencies = [ + "bitflags", + "debugid", + "fxhash", + "serde", + "serde_json", +] + +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +dependencies = [ + "fallible-iterator", + "indexmap", + "stable_deref_trait", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", + "serde", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin 0.9.8", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "http", + "idna", + "ipnet", + "once_cell", + "rand 0.9.4", + "ring", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.4", + "resolv-conf", + "rustls", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hpke" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65d16b699dd1a1fa2d851c970b0c971b388eeeb40f744252b8de48860980c8f" +dependencies = [ + "aead", + "aes-gcm", + "chacha20poly1305", + "digest 0.10.7", + "generic-array", + "hkdf", + "hmac", + "p256", + "rand_core 0.9.5", + "sha2 0.10.9", + "subtle", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "igd-next" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516893339c97f6011282d5825ac94fc1c7aad5cad26bdc2d0cee068c0bf97f97" +dependencies = [ + "async-trait", + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.9.4", + "tokio", + "url", + "xmltree", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "io-extras" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +dependencies = [ + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iroh" +version = "0.96.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5236da4d5681f317ec393c8fe2b7e3d360d31c6bb40383991d0b7429ca5ad117" +dependencies = [ + "backon", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "ed25519-dalek 3.0.0-pre.1", + "futures-util", + "getrandom 0.3.4", + "hickory-resolver", + "http", + "igd-next", + "iroh-base", + "iroh-metrics", + "iroh-quinn", + "iroh-quinn-proto", + "iroh-quinn-udp", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netwatch", + "papaya", + "pin-project", + "pkarr", + "pkcs8 0.11.0-rc.10", + "portmapper", + "rand 0.9.4", + "reqwest 0.12.28", + "rustc-hash", + "rustls", + "rustls-pki-types", + "rustls-webpki", + "serde", + "smallvec", + "strum", + "swarm-discovery", + "sync_wrapper", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", + "webpki-roots", +] + +[[package]] +name = "iroh-base" +version = "0.96.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c99d836a1c99e037e98d1bf3ef209c3a4df97555a00ce9510eb78eccdf5567" +dependencies = [ + "curve25519-dalek 5.0.0-pre.1", + "data-encoding", + "derive_more", + "digest 0.11.0-rc.10", + "ed25519-dalek 3.0.0-pre.1", + "n0-error", + "rand_core 0.9.5", + "serde", + "sha2 0.11.0-rc.2", + "url", + "zeroize", + "zeroize_derive", +] + +[[package]] +name = "iroh-gossip" +version = "0.96.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d04f83254c847ac61a9b2215b95a36d598d87af033ca12a546cd1c6a2e06dab" +dependencies = [ + "blake3", + "bytes", + "data-encoding", + "derive_more", + "ed25519-dalek 3.0.0-pre.1", + "futures-concurrency", + "futures-lite", + "futures-util", + "hex", + "indexmap", + "iroh", + "iroh-base", + "iroh-metrics", + "irpc", + "n0-error", + "n0-future", + "postcard", + "rand 0.9.4", + "serde", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "iroh-metrics" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "761b45ba046134b11eb3e432fa501616b45c4bf3a30c21717578bc07aa6461dd" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "postcard", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab063c2bfd6c3d5a33a913d4fdb5252f140db29ec67c704f20f3da7e8f92dbf" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "iroh-quinn" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "034ed21f34c657a123d39525d948c885aacba59508805e4dd67d71f022e7151b" +dependencies = [ + "bytes", + "cfg_aliases", + "iroh-quinn-proto", + "iroh-quinn-udp", + "pin-project-lite", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "iroh-quinn-proto" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de99ad8adc878ee0e68509ad256152ce23b8bbe45f5539d04e179630aca40a9" +dependencies = [ + "bytes", + "derive_more", + "enum-assoc", + "fastbloom", + "getrandom 0.3.4", + "identity-hash", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "iroh-quinn-udp" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f981dadd5a072a9e0efcd24bdcc388e570073f7e51b33505ceb1ef4668c80c86" +dependencies = [ + "cfg_aliases", + "libc", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "iroh-relay" +version = "0.96.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd2b63e654b9dec799a73372cdc79b529ca6c7248c0c8de7da78a02e3a46f03c" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "getrandom 0.3.4", + "hickory-resolver", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-metrics", + "iroh-quinn", + "iroh-quinn-proto", + "lru 0.16.4", + "n0-error", + "n0-future", + "num_enum", + "pin-project", + "pkarr", + "postcard", + "rand 0.9.4", + "reqwest 0.12.28", + "rustls", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "vergen-gitcl", + "webpki-roots", + "ws_stream_wasm", + "z32", +] + +[[package]] +name = "irpc" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bbc84aaeab13a6d7502bae4f40f2517b643924842e0230ea0bf807477cc208" +dependencies = [ + "futures-util", + "irpc-derive", + "n0-error", + "n0-future", + "serde", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "irpc-derive" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58148196d2230183c9679431ac99b57e172000326d664e8456fa2cd27af6505a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "ittapi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" +dependencies = [ + "anyhow", + "ittapi-sys", + "log", +] + +[[package]] +name = "ittapi-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" +dependencies = [ + "cc", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2 0.10.9", + "signature 2.2.0", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cc46bac87ef8093eed6f272babb833b6443374399985ac8ed28471ee0918545" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "mainline" +version = "6.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25fd96e1eb0cff23f7048801d1561336c2e3c9f2b468d0271f27ad3463f583bf" +dependencies = [ + "crc", + "document-features", + "dyn-clone", + "ed25519-dalek 3.0.0-pre.1", + "flume", + "futures-lite", + "getrandom 0.4.2", + "lru 0.16.4", + "serde", + "serde_bencode", + "serde_bytes", + "sha1_smol", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "match-lookup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix 1.1.4", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "multibase" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" +dependencies = [ + "base-x", + "base256emoji", + "data-encoding", + "data-encoding-macro", +] + +[[package]] +name = "multihash" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c63b00ad74d57e8c9aa870b5fccebf2fd64a308a5aee9f1bb88e4aea19447" +dependencies = [ + "unsigned-varint", +] + +[[package]] +name = "n0-error" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4782b4baf92d686d161c15460c83d16ebcfd215918763903e9619842665cae" +dependencies = [ + "anyhow", + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03755949235714b2b307e5ae89dd8c1c2531fb127d9b8b7b4adf9c876cd3ed18" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38795f7932e6e9d1c6e989270ef5b3ff24ebb910e2c9d4bed2d28d8bae3007dc" +dependencies = [ + "derive_more", + "n0-error", + "n0-future", +] + +[[package]] +name = "netdev" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b0a0096d9613ee878dba89bbe595f079d373e3f1960d882e4f2f78ff9c30a0a" +dependencies = [ + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "libc", + "mac-addr", + "netlink-packet-core", + "netlink-packet-route 0.29.0", + "netlink-sys", + "objc2-core-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.59.0", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" +dependencies = [ + "bitflags", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-packet-route" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" +dependencies = [ + "bitflags", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netwatch" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "454b8c0759b2097581f25ed5180b4a1d14c324fde6d0734932a288e044d06232" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more", + "iroh-quinn-udp", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route 0.28.0", + "netlink-proto", + "netlink-sys", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows", + "windows-result", + "wmi", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "ntimestamp" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c50f94c405726d3e0095e89e72f75ce7f6587b94a8bd8dc8054b73f65c0fd68c" +dependencies = [ + "base32", + "document-features", + "getrandom 0.2.17", + "httpdate", + "js-sys", + "once_cell", + "serde", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "crc32fast", + "hashbrown 0.15.5", + "indexmap", + "memchr", +] + +[[package]] +name = "object-provider" +version = "0.1.0" +dependencies = [ + "der 0.8.0-rc.10", + "ed25519 3.0.0-rc.4", + "elastos-server", + "pkcs8 0.11.0-rc.10", + "serde_json", + "spki 0.8.0-rc.4", + "tempfile", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "papaya" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +dependencies = [ + "equivalent", + "seize", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkarr" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f950360d31be432c0c9467fba5024a94f55128e7f32bc9d32db140369f24c77" +dependencies = [ + "async-compat", + "base32", + "bytes", + "cfg_aliases", + "document-features", + "dyn-clone", + "ed25519-dalek 3.0.0-pre.1", + "futures-buffered", + "futures-lite", + "getrandom 0.4.2", + "log", + "lru 0.16.4", + "ntimestamp", + "reqwest 0.13.4", + "self_cell", + "serde", + "sha1_smol", + "simple-dns", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b226d2cc389763951db8869584fd800cbbe2962bf454e2edeb5172b31ee99774" +dependencies = [ + "der 0.8.0-rc.10", + "spki 0.8.0-rc.4", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "serde", +] + +[[package]] +name = "portmapper" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d2a8825353ace3285138da3378b1e21860d60351942f7aa3b99b13b41f80318" +dependencies = [ + "base64", + "bytes", + "derive_more", + "futures-lite", + "futures-util", + "hyper-util", + "igd-next", + "iroh-metrics", + "libc", + "n0-error", + "netwatch", + "num_enum", + "rand 0.9.4", + "serde", + "smallvec", + "socket2", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.12+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulley-interpreter" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "329f575a931601f71fbcb3b31d32d16273da5ba7f532fc10be2e432e710b02de" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-math", +] + +[[package]] +name = "pulley-macros" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bccae89ed67a40989e780105fab43e6c71a077b9fc8ae4c805ff5f73d2a79c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "qrcodegen" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regalloc2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5216b1837de2149f8bc8e6d5f88a9326b63b8c836ed58ce4a0a29ec736a59734" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.15.5", + "log", + "rustc-hash", + "smallvec", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.1.4", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bencode" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70dfc7b7438b99896e7f8992363ab8e2c4ba26aa5ec675d32d1c3c2c33d413e" +dependencies = [ + "serde", + "serde_bytes", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1e3878ab0f98e35b2df35fe53201d088299b41a6bb63e3e34dada2ac4abd924" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.11.0-rc.10", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple-dns" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee851d0e5e7af3721faea1843e8015e820a234f81fda3dea9247e15bac9a86a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "smol_str" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fad6c857cbab2627dcf01ec85a623ca4e7dcb5691cbaa3d7fb7653671f0d09c9" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8baeff88f34ed0691978ec34440140e1572b68c7dd4a495fd14a3dc1944daa80" +dependencies = [ + "base64ct", + "der 0.8.0-rc.10", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swarm-discovery" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5ab62937edac8b23fa40e55a358ea1924245b17fc1eb20d14929c8f11be98d" +dependencies = [ + "acto", + "hickory-proto", + "rand 0.9.4", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-interface" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" +dependencies = [ + "bitflags", + "cap-fs-ext", + "cap-std", + "fd-lock", + "io-lifetimes", + "rustix 0.38.44", + "windows-sys 0.59.0", + "winx", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "js-sys", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1b6348ebfaaecd771cecb69e832961d277f59845d4220a584701f72728152b7" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.3.4", + "http", + "httparse", + "rand 0.9.4", + "ring", + "rustls-pki-types", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "unsigned-varint" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vergen" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "vergen-lib 9.1.0", +] + +[[package]] +name = "vergen-gitcl" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9dfc1de6eb2e08a4ddf152f1b179529638bedc0ea95e6d667c014506377aefe" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib 0.1.6", +] + +[[package]] +name = "vergen-lib" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b07e6010c0f3e59fcb164e0163834597da68d1f864e2b8ca49f74de01e9c166" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + +[[package]] +name = "vergen-lib" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.236.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "724fccfd4f3c24b7e589d333fc0429c68042897a7e8a5f8694f31792471841e7" +dependencies = [ + "leb128fmt", + "wasmparser 0.236.1", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a879a421bd17c528b74721b2abf4c62e8f1d1889c2ba8c3c50d02deaf2ce395" +dependencies = [ + "leb128fmt", + "wasmparser 0.251.0", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.236.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wasmparser" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437970b35b1a85cfde9c74b2398352d8d653f3bd8e3a3db0c063ea8f5b4b36ff" +dependencies = [ + "bitflags", + "indexmap", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.236.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2df225df06a6df15b46e3f73ca066ff92c2e023670969f7d50ce7d5e695abbb1" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.236.1", +] + +[[package]] +name = "wasmtime" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507d213104e83a7519d91af444a8b19c04281f2eef162d448ee7a894ac1c827d" +dependencies = [ + "addr2line", + "anyhow", + "async-trait", + "bitflags", + "bumpalo", + "cc", + "cfg-if", + "encoding_rs", + "fxprof-processed-profile", + "gimli", + "hashbrown 0.15.5", + "indexmap", + "ittapi", + "libc", + "log", + "mach2", + "memfd", + "object", + "once_cell", + "postcard", + "pulley-interpreter", + "rayon", + "rustix 1.1.4", + "semver", + "serde", + "serde_derive", + "serde_json", + "smallvec", + "target-lexicon", + "wasm-encoder 0.236.1", + "wasmparser 0.236.1", + "wasmtime-environ", + "wasmtime-internal-asm-macros", + "wasmtime-internal-cache", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-math", + "wasmtime-internal-slab", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wasmtime-internal-winch", + "wat", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-environ" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9784b325c3b85562ac6d7f81c8348c42af1f137d98dd4fc6631860e4e68bb655" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "indexmap", + "log", + "object", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasm-encoder 0.236.1", + "wasmparser 0.236.1", + "wasmprinter", + "wasmtime-internal-component-util", +] + +[[package]] +name = "wasmtime-internal-asm-macros" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcaa9336cd5ba934ba734dfdfe35f5245c3c74b4e34f9af9e114fad892d81b3d" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "wasmtime-internal-cache" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e297746bc001fd919f8f9bb3d28ed1a01fb1ff10a5ccd9720e649b5807bf2b68" +dependencies = [ + "anyhow", + "base64", + "directories-next", + "log", + "postcard", + "rustix 1.1.4", + "serde", + "serde_derive", + "sha2 0.10.9", + "toml", + "windows-sys 0.60.2", + "zstd", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91aba228ec4f646cb9514be55538c842822cb96f2c306f75d664eea6b6f2e9eb" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser 0.236.1", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f4a2387b0aea544aa2317e295583be54fed852e0d1a31c0070984bfd6a507" + +[[package]] +name = "wasmtime-internal-cranelift" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7d938ae501275f44e7e5532ae4bb720542b429357014d33842e128c46fb9b54" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools", + "log", + "object", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.236.1", + "wasmtime-environ", + "wasmtime-internal-math", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1443b0914ff848ee7920e0f232368168e2819b739c54f3c352f0559b6164343" +dependencies = [ + "anyhow", + "cc", + "cfg-if", + "libc", + "rustix 1.1.4", + "wasmtime-internal-asm-macros", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "861d6f2a1652e95ca10b02552934b3bd460d7416b285fe10d7ca8c0a2b90dc3e" +dependencies = [ + "cc", + "object", + "rustix 1.1.4", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1caeb3140c46319fecf09d93dc38a373eb535fd478e401a9fb2ac2da30fe5f6" +dependencies = [ + "anyhow", + "cfg-if", + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-internal-math" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c631615929951a4076aae64da7d6cad88668d292f19672606392c24ae9c5a00" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmtime-internal-slab" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b28104d57b5bdb5d8facb3a8418463ec6c2cb40bb4adf9833b727ebf6a254eb" + +[[package]] +name = "wasmtime-internal-unwinder" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd89f2db7377869aeaf66b71f56def8df54b9482e4f4e5533ccec2505f5c691" +dependencies = [ + "anyhow", + "cfg-if", + "cranelift-codegen", + "log", + "object", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9cdb9c2e3965ee15629d067203cb800e9822664d04335dadc6fe1788d4fc335" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasmtime-internal-winch" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53f693c8db710f20b927bcee025acd345acf599d055b63f122613d52f5553a5f" +dependencies = [ + "anyhow", + "cranelift-codegen", + "gimli", + "object", + "target-lexicon", + "wasmparser 0.236.1", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "winch-codegen", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6c97d4e849494d290e05573298bd372e12be86b2074502dc5e02f4ef7628002" +dependencies = [ + "anyhow", + "bitflags", + "heck", + "indexmap", + "wit-parser 0.236.1", +] + +[[package]] +name = "wasmtime-wasi" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eabc75a6afeac11870ee5402268ec0f61fc394728d6dcbe5091a57dc6eb5a57" +dependencies = [ + "anyhow", + "async-trait", + "bitflags", + "bytes", + "cap-fs-ext", + "cap-net-ext", + "cap-rand", + "cap-std", + "cap-time-ext", + "fs-set-times", + "futures", + "io-extras", + "io-lifetimes", + "rustix 1.1.4", + "system-interface", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "wasmtime", + "wasmtime-wasi-io", + "wiggle", + "windows-sys 0.60.2", +] + +[[package]] +name = "wasmtime-wasi-io" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367b77d382241c7f9b3cde3c8cfc1c0d800f04d665622779a724b71d0a2a2028" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "futures", + "wasmtime", +] + +[[package]] +name = "wast" +version = "35.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" +dependencies = [ + "leb128", +] + +[[package]] +name = "wast" +version = "251.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc7467dda0a96142eb2c980329dfb62480b1e1d3622fdeb1a44e2bca6ceed74" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder 0.251.0", +] + +[[package]] +name = "wat" +version = "1.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b1086c9e85b95bd6a229a928bc6c6d0662e42af0250c88d067b418831ea4d4" +dependencies = [ + "wast 251.0.0", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "wiggle" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aed7ef247a05956b0a25e7905fdb709ae89e506547af42897e40301b0658d07" +dependencies = [ + "anyhow", + "async-trait", + "bitflags", + "thiserror 2.0.18", + "tracing", + "wasmtime", + "wiggle-macro", +] + +[[package]] +name = "wiggle-generate" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d5550c5f49730d0a8babd089771ff7412e598cf8f7bbe3b647b8e2147a11b" +dependencies = [ + "anyhow", + "heck", + "proc-macro2", + "quote", + "syn", + "witx", +] + +[[package]] +name = "wiggle-macro" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602175405f0c04fd47439ad6afc5e151c4864883259254d3676562bfb00a7ce8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wiggle-generate", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "winch-codegen" +version = "36.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4332c8656af179fb8fc3ae5114c738c29399ee97b638d431725201c17f99294e" +dependencies = [ + "anyhow", + "cranelift-assembler-x64", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.236.1", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "wasmtime-internal-math", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.244.0", + "wasm-metadata", + "wasmparser 0.244.0", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-parser" +version = "0.236.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e4833a20cd6e85d6abfea0e63a399472d6f88c6262957c17f546879a80ba15" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.236.1", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.244.0", +] + +[[package]] +name = "witx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" +dependencies = [ + "anyhow", + "log", + "thiserror 1.0.69", + "wast 35.0.2", +] + +[[package]] +name = "wmi" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.18", + "windows", + "windows-core", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "z32" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2164e798d9e3d84ee2c91139ace54638059a3b23e361f5c11781c2c6459bde0f" + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.18", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/capsules/object-provider/Cargo.toml b/capsules/object-provider/Cargo.toml new file mode 100644 index 00000000..ac09540f --- /dev/null +++ b/capsules/object-provider/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "object-provider" +version = "0.1.0" +edition = "2021" +description = "ElastOS object provider capsule - principal-root object authority" +license = "MIT" + +[[bin]] +name = "object-provider" +path = "src/main.rs" + +[dependencies] +elastos-server = { path = "../../elastos/crates/elastos-server" } +serde_json = "1.0" +# Keep the distributed-topic-tracker / ed25519-dalek 3 pre-release chain pinned +# to the same rc versions as the main workspace. Without these direct pins, +# Cargo can resolve stable ed25519/pkcs8 versions that do not compile with +# ed25519-dalek 3.0.0-pre.1. +ed25519 = "=3.0.0-rc.4" +pkcs8 = "=0.11.0-rc.10" +der = "=0.8.0-rc.10" +spki = "=0.8.0-rc.4" + +[dev-dependencies] +tempfile = "3" + +[profile.release] +opt-level = "s" +lto = true + +[workspace] diff --git a/capsules/object-provider/capsule.json b/capsules/object-provider/capsule.json new file mode 100644 index 00000000..cf61b9ed --- /dev/null +++ b/capsules/object-provider/capsule.json @@ -0,0 +1,28 @@ +{ + "schema": "elastos.capsule/v1", + "name": "object-provider", + "version": "0.1.0", + "description": "Principal-root object authority for Explorer/Library", + "author": "elastos", + "role": "provider", + "type": "microvm", + "entrypoint": "rootfs.ext4", + "provides": "elastos://object/*", + "authority": { + "reason": "Owns principal-root object reads, writes, moves, metadata, and events without exposing raw host filesystem access to app capsules.", + "capabilities": [ + { + "resource": "elastos://object/*", + "actions": ["read", "write", "delete"], + "operations": ["roots", "list", "stat", "read", "download", "write", "mkdir", "rename", "move", "copy", "trash", "restore", "delete_permanently", "empty_trash", "status", "events", "publish", "unpublish", "repair", "share"] + } + ], + "audit_events": ["object.provider.requested", "object.provider.completed", "object.provider.failed"] + }, + "capabilities": ["localhost://Users/*"], + "resources": { + "memory_mb": 64, + "gpu": false + }, + "permissions": {} +} diff --git a/capsules/object-provider/src/main.rs b/capsules/object-provider/src/main.rs new file mode 100644 index 00000000..e1db6641 --- /dev/null +++ b/capsules/object-provider/src/main.rs @@ -0,0 +1,182 @@ +//! ElastOS Object Provider Capsule +//! +//! Principal-root object authority. Library remains the app capsule; Runtime +//! injects the authenticated principal and forwards typed operations to this +//! provider process. + +use serde_json::{json, Value}; +use std::io::{self, BufRead, Write}; +use std::path::PathBuf; + +const PROVIDER_VERSION: &str = match option_env!("ELASTOS_RELEASE_VERSION") { + Some(version) => version, + None => concat!(env!("CARGO_PKG_VERSION"), "-dev"), +}; + +const PROVIDER_ID: &str = "object-provider"; + +struct ObjectProviderProcess { + data_dir: Option, +} + +impl ObjectProviderProcess { + fn new() -> Self { + Self { data_dir: None } + } + + fn handle(&mut self, request: Value) -> Value { + match request.get("op").and_then(Value::as_str) { + Some("init") => self.init(request.get("config").cloned().unwrap_or(Value::Null)), + Some("shutdown") => response_ok(json!({ + "provider": PROVIDER_ID, + })), + Some(_) => { + let Some(data_dir) = &self.data_dir else { + return response_error("not_initialized", "object-provider is not initialized"); + }; + elastos_server::library::handle_object_provider_raw_request(data_dir, &request) + } + None => response_error("invalid_request", "provider request missing op"), + } + } + + fn init(&mut self, config: Value) -> Value { + let base_path = config + .get("base_path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let Some(base_path) = base_path else { + return response_error( + "invalid_config", + "object-provider requires config.base_path", + ); + }; + let data_dir = PathBuf::from(base_path); + if !data_dir.is_absolute() { + return response_error("invalid_config", "config.base_path must be absolute"); + } + self.data_dir = Some(data_dir.clone()); + response_ok(json!({ + "provider": PROVIDER_ID, + "protocol_version": "1.0", + "version": PROVIDER_VERSION, + "base_path": data_dir, + })) + } +} + +fn response_ok(data: Value) -> Value { + json!({ + "status": "ok", + "data": data, + }) +} + +fn response_error(code: &str, message: &str) -> Value { + json!({ + "status": "error", + "code": code, + "message": message, + }) +} + +fn main() { + eprintln!("object-provider: starting v{PROVIDER_VERSION}"); + let stdin = io::stdin(); + let mut stdout = io::stdout(); + let mut provider = ObjectProviderProcess::new(); + + for line in stdin.lock().lines() { + let line = match line { + Ok(line) => line, + Err(err) => { + eprintln!("object-provider read error: {err}"); + break; + } + }; + if line.trim().is_empty() { + continue; + } + + let request = match serde_json::from_str::(&line) { + Ok(request) => request, + Err(err) => response_error("invalid_request", &err.to_string()), + }; + let is_shutdown = request.get("op").and_then(Value::as_str) == Some("shutdown"); + let response = provider.handle(request); + writeln!(stdout, "{}", serde_json::to_string(&response).unwrap()).unwrap(); + stdout.flush().unwrap(); + if is_shutdown { + break; + } + } + + eprintln!("object-provider: exiting"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_requests_before_init() { + let mut provider = ObjectProviderProcess::new(); + + let response = provider.handle(json!({ + "op": "list", + "principal_id": "alice", + "uri": "localhost://Users/alice/Documents" + })); + + assert_eq!(response["status"], "error"); + assert_eq!(response["code"], "not_initialized"); + } + + #[test] + fn init_requires_absolute_base_path() { + let mut provider = ObjectProviderProcess::new(); + + let missing = provider.handle(json!({ + "op": "init", + "config": {} + })); + assert_eq!(missing["status"], "error"); + assert_eq!(missing["code"], "invalid_config"); + + let relative = provider.handle(json!({ + "op": "init", + "config": { + "base_path": "relative/path" + } + })); + assert_eq!(relative["status"], "error"); + assert_eq!(relative["code"], "invalid_config"); + } + + #[test] + fn initialized_raw_provider_refuses_runtime_coordinated_content_ops() { + let dir = tempfile::tempdir().unwrap(); + let mut provider = ObjectProviderProcess::new(); + + let init = provider.handle(json!({ + "op": "init", + "config": { + "base_path": dir.path().to_string_lossy().to_string() + } + })); + assert_eq!(init["status"], "ok"); + + let response = provider.handle(json!({ + "op": "publish", + "principal_id": "alice", + "uri": "localhost://Users/alice/Documents/file.txt" + })); + assert_eq!(response["status"], "error"); + assert_eq!(response["code"], "library_error"); + assert!(response["message"] + .as_str() + .unwrap() + .contains("Runtime content coordinator")); + } +} diff --git a/capsules/operator-drive-adapter/Cargo.lock b/capsules/operator-drive-adapter/Cargo.lock new file mode 100644 index 00000000..ce2eda7e --- /dev/null +++ b/capsules/operator-drive-adapter/Cargo.lock @@ -0,0 +1,458 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "operator-drive-adapter" +version = "0.1.0" +dependencies = [ + "base64", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/capsules/operator-drive-adapter/Cargo.toml b/capsules/operator-drive-adapter/Cargo.toml new file mode 100644 index 00000000..ccb8dec8 --- /dev/null +++ b/capsules/operator-drive-adapter/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "operator-drive-adapter" +version = "0.1.0" +edition = "2021" +description = "ElastOS operator WebSpace resolver adapter capsule" +license = "MIT" + +[[bin]] +name = "operator-drive-adapter" +path = "src/main.rs" + +[dependencies] +base64 = "0.22" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[dev-dependencies] +tempfile = "3" + +[profile.release] +opt-level = "s" +lto = true + +[workspace] diff --git a/capsules/operator-drive-adapter/capsule.json b/capsules/operator-drive-adapter/capsule.json new file mode 100644 index 00000000..133e1238 --- /dev/null +++ b/capsules/operator-drive-adapter/capsule.json @@ -0,0 +1,32 @@ +{ + "schema": "elastos.capsule/v1", + "name": "operator-drive-adapter", + "version": "0.1.0", + "description": "Operator WebSpace resolver adapter for Runtime-mediated metadata, byte read, and mutable write-back tests", + "author": "elastos", + "role": "provider", + "type": "microvm", + "entrypoint": "rootfs.ext4", + "provides": "operator-drive-adapter://*", + "authority": { + "reason": "Attaches an operator-managed resolver namespace to localhost://WebSpaces without exposing resolver credentials, host paths, or raw backend APIs to app capsules.", + "capabilities": [ + { + "resource": "operator-drive-adapter://*", + "actions": ["read", "write"], + "operations": ["status", "metadata_index", "read_bytes", "write_bytes"] + } + ], + "audit_events": [ + "operator_drive_adapter.status", + "operator_drive_adapter.metadata_index", + "operator_drive_adapter.read_bytes", + "operator_drive_adapter.write_bytes" + ] + }, + "resources": { + "memory_mb": 64, + "gpu": false + }, + "permissions": {} +} diff --git a/capsules/operator-drive-adapter/src/main.rs b/capsules/operator-drive-adapter/src/main.rs new file mode 100644 index 00000000..18337588 --- /dev/null +++ b/capsules/operator-drive-adapter/src/main.rs @@ -0,0 +1,1734 @@ +//! ElastOS Operator Drive Adapter Capsule +//! +//! This is a real WebSpace resolver adapter package, not a Library UI helper. +//! It only accepts metadata/read/write calls when Runtime injects an explicit +//! provider invocation envelope, and it stores bytes in a provider-owned local +//! fixture namespace for deterministic development and release tests. + +use base64::Engine as _; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::fs; +use std::io::{self, BufRead, Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +const PROVIDER_VERSION: &str = match option_env!("ELASTOS_RELEASE_VERSION") { + Some(version) => version, + None => concat!(env!("CARGO_PKG_VERSION"), "-dev"), +}; +const ADAPTER_SCHEMA: &str = "elastos.webspace.operator-drive-adapter/v1"; +const TARGET_PREFIX: &str = "operator://drive"; +const PROVIDER_NAME: &str = "operator-drive-adapter"; +const RESOLVER_NAME: &str = "operator-drive"; +const SEED_BRIEF_TARGET: &str = "operator://drive/Projects/Brief.md"; +const SEED_BRIEF_BYTES: &[u8] = b"# Operator Brief\n\nAdapter-backed bytes.\n"; +const ENDPOINT_REQUEST_SCHEMA: &str = "elastos.webspace.operator-endpoint.request/v1"; +const DEFAULT_ENDPOINT_TIMEOUT_MS: u64 = 5_000; + +#[derive(Debug, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] +enum Request { + Init { + #[serde(default)] + config: ProviderConfig, + }, + Status, + MetadataIndex { + #[serde(default)] + schema: Option, + mount: String, + resolver: String, + handle_uri: String, + target_uri: String, + #[serde(default, rename = "_runtime_invocation")] + runtime_invocation: Option, + }, + ReadBytes { + #[serde(default)] + schema: Option, + mount: String, + resolver: String, + handle_uri: String, + target_uri: String, + #[serde(default, rename = "_runtime_invocation")] + runtime_invocation: Option, + }, + WriteBytes { + #[serde(default)] + schema: Option, + mount: String, + resolver: String, + handle_uri: String, + target_uri: String, + data: String, + #[serde(default)] + if_head: Option, + #[serde(default, rename = "_runtime_invocation")] + runtime_invocation: Option, + }, + Shutdown, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct ProviderConfig { + base_path: String, + extra: Value, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +enum Response { + Ok { + #[serde(skip_serializing_if = "Option::is_none")] + data: Option, + }, + Error { + code: String, + message: String, + }, +} + +impl Response { + fn ok(data: Value) -> Self { + Self::Ok { data: Some(data) } + } + + fn empty_ok() -> Self { + Self::Ok { data: None } + } + + fn error(code: &str, message: impl Into) -> Self { + Self::Error { + code: code.to_string(), + message: message.into(), + } + } +} + +#[derive(Debug)] +struct OperatorDriveAdapter { + root: PathBuf, + endpoint: Option, +} + +impl Default for OperatorDriveAdapter { + fn default() -> Self { + Self { + root: std::env::temp_dir().join("elastos-operator-drive-adapter"), + endpoint: None, + } + } +} + +#[derive(Debug, Clone)] +struct OperatorEndpoint { + host: String, + port: u16, + path: String, + authorization: Option, + timeout: Duration, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct OperatorEndpointConfig { + url: String, + #[serde(default)] + authorization: Option, + #[serde(default)] + timeout_ms: Option, +} + +#[derive(Debug)] +struct EndpointFailure { + code: String, + message: String, +} + +impl EndpointFailure { + fn new(code: &str, message: impl Into) -> Self { + Self { + code: code.to_string(), + message: message.into(), + } + } +} + +impl OperatorDriveAdapter { + fn handle(&mut self, request: Request) -> Response { + match request { + Request::Init { config } => self.init(config), + Request::Status => self.status(), + Request::MetadataIndex { + schema, + mount, + resolver, + handle_uri, + target_uri, + runtime_invocation, + } => self.metadata_index( + schema, + mount, + resolver, + handle_uri, + target_uri, + runtime_invocation, + ), + Request::ReadBytes { + schema, + mount, + resolver, + handle_uri, + target_uri, + runtime_invocation, + } => self.read_bytes( + schema, + mount, + resolver, + handle_uri, + target_uri, + runtime_invocation, + ), + Request::WriteBytes { + schema, + mount, + resolver, + handle_uri, + target_uri, + data, + if_head, + runtime_invocation, + } => self.write_bytes( + schema, + mount, + resolver, + handle_uri, + target_uri, + data, + if_head, + runtime_invocation, + ), + Request::Shutdown => Response::empty_ok(), + } + } + + fn init(&mut self, config: ProviderConfig) -> Response { + let root = config + .extra + .get("operator_drive_root") + .and_then(Value::as_str) + .map(PathBuf::from) + .or_else(|| { + if config.base_path.trim().is_empty() { + None + } else { + Some(PathBuf::from(config.base_path).join("operator-drive-adapter")) + } + }) + .unwrap_or_else(|| self.root.clone()); + self.root = root; + self.endpoint = match operator_endpoint_from_extra(&config.extra) { + Ok(endpoint) => endpoint, + Err(err) => return Response::error("invalid_endpoint_config", err), + }; + if let Err(err) = fs::create_dir_all(self.object_root()) { + return Response::error( + "init_failed", + format!("cannot initialize adapter store: {err}"), + ); + } + Response::ok(json!({ + "schema": ADAPTER_SCHEMA, + "provider": PROVIDER_NAME, + "resolver": RESOLVER_NAME, + "version": PROVIDER_VERSION, + "configured": true, + "endpoint": endpoint_summary(self.endpoint.as_ref()), + "capabilities": ["metadata_index", "read_bytes", "write_bytes"], + "authority_boundary": "Runtime provider invocation only; no app-visible resolver credentials or host paths" + })) + } + + fn status(&self) -> Response { + Response::ok(json!({ + "schema": ADAPTER_SCHEMA, + "provider": PROVIDER_NAME, + "resolver": RESOLVER_NAME, + "version": PROVIDER_VERSION, + "configured": true, + "state": "connected", + "endpoint": endpoint_summary(self.endpoint.as_ref()), + "capabilities": ["metadata_index", "read_bytes", "write_bytes"], + "target_prefix": TARGET_PREFIX, + "blocked_authority": [ + "resolver_credentials", + "host_paths", + "raw_backend_sdk", + "carrier_tickets", + "kubo_ipfs_handles" + ], + "contract": { + "schema": "elastos.webspace.adapter/v1", + "operations": ["metadata_index", "read_bytes", "write_bytes"], + "requires_runtime_invocation": true, + "credential_policy": "provider-owned; never serialized to apps" + } + })) + } + + fn metadata_index( + &self, + schema: Option, + mount: String, + resolver: String, + handle_uri: String, + target_uri: String, + runtime_invocation: Option, + ) -> Response { + if let Err(err) = validate_adapter_request( + schema.as_deref(), + &mount, + &resolver, + &handle_uri, + &target_uri, + runtime_invocation.as_ref(), + "metadata_index", + ) { + return Response::error("invalid_request", err); + } + let parts = match target_parts(&target_uri) { + Ok(parts) => parts, + Err(err) => return Response::error("invalid_target", err), + }; + if let Some(endpoint) = &self.endpoint { + return self.metadata_index_via_endpoint(endpoint, mount, handle_uri, target_uri); + } + let mut entries = Vec::new(); + if parts.is_empty() { + entries.push(index_entry( + "Projects", + "directory", + "operator://drive/Projects", + true, + )); + entries.push(index_entry( + "Projects/Brief.md", + "file", + SEED_BRIEF_TARGET, + true, + )); + entries.push(index_entry( + "Writable", + "directory", + "operator://drive/Writable", + false, + )); + } else if parts == ["Projects"] { + entries.push(index_entry("Brief.md", "file", SEED_BRIEF_TARGET, true)); + } + if let Err(err) = self.collect_stored_entries(&parts, &mut entries) { + return Response::error("index_failed", err); + } + entries.sort_by(|left, right| { + left.get("path") + .and_then(Value::as_str) + .cmp(&right.get("path").and_then(Value::as_str)) + }); + entries.dedup_by(|left, right| left.get("path") == right.get("path")); + Response::ok(json!({ + "schema": "elastos.webspace.adapter.metadata-index/v1", + "resolver": RESOLVER_NAME, + "mount": mount, + "handle_uri": handle_uri, + "target_uri": target_uri, + "entries": entries, + "receipt": { + "schema": "elastos.webspace.adapter.metadata-index-receipt/v1", + "resolver": RESOLVER_NAME, + "provider": PROVIDER_NAME, + "entry_count": entries.len(), + "credential_exposed": false + } + })) + } + + fn read_bytes( + &self, + schema: Option, + mount: String, + resolver: String, + handle_uri: String, + target_uri: String, + runtime_invocation: Option, + ) -> Response { + if let Err(err) = validate_adapter_request( + schema.as_deref(), + &mount, + &resolver, + &handle_uri, + &target_uri, + runtime_invocation.as_ref(), + "read_bytes", + ) { + return Response::error("invalid_request", err); + } + if let Err(err) = target_parts(&target_uri) { + return Response::error("invalid_target", err); + } + if let Some(endpoint) = &self.endpoint { + return self.read_bytes_via_endpoint(endpoint, mount, handle_uri, target_uri); + } + match self.read_target_bytes(&target_uri) { + Ok(bytes) => Response::ok(json!({ + "schema": "elastos.webspace.adapter.read-bytes/v1", + "data": base64::engine::general_purpose::STANDARD.encode(&bytes), + "mime": mime_for_target(&target_uri), + "receipt": { + "schema": "elastos.webspace.adapter.read-bytes-receipt/v1", + "resolver": RESOLVER_NAME, + "provider": PROVIDER_NAME, + "target_uri": target_uri, + "handle_uri": handle_uri, + "bytes": bytes.len(), + "credential_exposed": false + } + })), + Err(err) => Response::error("read_failed", err), + } + } + + #[allow(clippy::too_many_arguments)] + fn write_bytes( + &self, + schema: Option, + mount: String, + resolver: String, + handle_uri: String, + target_uri: String, + data: String, + if_head: Option, + runtime_invocation: Option, + ) -> Response { + if let Err(err) = validate_adapter_request( + schema.as_deref(), + &mount, + &resolver, + &handle_uri, + &target_uri, + runtime_invocation.as_ref(), + "write_bytes", + ) { + return Response::error("invalid_request", err); + } + if !target_uri.starts_with("operator://drive/Writable/") { + return Response::error( + "readonly", + "operator adapter writes are limited to operator://drive/Writable/*", + ); + } + if target_uri.contains("/Conflict/") || if_head.as_deref() == Some("head:conflict") { + return Response::error( + "conflict", + "operator adapter rejected stale mutable fork write", + ); + } + let bytes = match base64::engine::general_purpose::STANDARD.decode(data.trim()) { + Ok(bytes) => bytes, + Err(err) => { + return Response::error("invalid_data", format!("data must be base64: {err}")) + } + }; + if let Some(endpoint) = &self.endpoint { + return self.write_bytes_via_endpoint( + endpoint, + mount, + handle_uri, + target_uri, + data, + if_head, + bytes.len(), + ); + } + let path = match self.target_path(&target_uri) { + Ok(path) => path, + Err(err) => return Response::error("invalid_target", err), + }; + if let Some(parent) = path.parent() { + if let Err(err) = fs::create_dir_all(parent) { + return Response::error( + "write_failed", + format!("cannot create parent directory: {err}"), + ); + } + } + if let Err(err) = fs::write(&path, &bytes) { + return Response::error( + "write_failed", + format!("cannot write adapter object: {err}"), + ); + } + Response::ok(json!({ + "schema": "elastos.webspace.adapter.write-bytes/v1", + "receipt": { + "schema": "elastos.webspace.adapter.write-bytes-receipt/v1", + "resolver": RESOLVER_NAME, + "provider": PROVIDER_NAME, + "target_uri": target_uri, + "handle_uri": handle_uri, + "bytes_accepted": bytes.len(), + "credential_exposed": false + } + })) + } + + fn metadata_index_via_endpoint( + &self, + endpoint: &OperatorEndpoint, + mount: String, + handle_uri: String, + target_uri: String, + ) -> Response { + let remote = match post_operator_endpoint( + endpoint, + "metadata_index", + json!({ + "mount": mount, + "resolver": RESOLVER_NAME, + "handle_uri": handle_uri, + "target_uri": target_uri + }), + ) { + Ok(data) => data, + Err(err) => return Response::error(&err.code, err.message), + }; + let entries = match sanitize_endpoint_entries(&remote) { + Ok(entries) => entries, + Err(err) => return Response::error("invalid_endpoint_response", err), + }; + Response::ok(json!({ + "schema": "elastos.webspace.adapter.metadata-index/v1", + "resolver": RESOLVER_NAME, + "mount": mount, + "handle_uri": handle_uri, + "target_uri": target_uri, + "entries": entries, + "receipt": { + "schema": "elastos.webspace.adapter.metadata-index-receipt/v1", + "resolver": RESOLVER_NAME, + "provider": PROVIDER_NAME, + "entry_count": entries.len(), + "federation_backend": "operator_private_http", + "credential_exposed": false, + "endpoint_authority_exposed": false + } + })) + } + + fn read_bytes_via_endpoint( + &self, + endpoint: &OperatorEndpoint, + mount: String, + handle_uri: String, + target_uri: String, + ) -> Response { + let remote = match post_operator_endpoint( + endpoint, + "read_bytes", + json!({ + "mount": mount, + "resolver": RESOLVER_NAME, + "handle_uri": handle_uri, + "target_uri": target_uri + }), + ) { + Ok(data) => data, + Err(err) => return Response::error(&err.code, err.message), + }; + let data = match remote.get("data").and_then(Value::as_str) { + Some(data) => data.to_string(), + None => { + return Response::error( + "invalid_endpoint_response", + "operator endpoint read response must include base64 data", + ) + } + }; + let bytes = match base64::engine::general_purpose::STANDARD.decode(data.trim()) { + Ok(bytes) => bytes, + Err(err) => { + return Response::error( + "invalid_endpoint_response", + format!("operator endpoint returned invalid base64 data: {err}"), + ) + } + }; + let mime = remote + .get("mime") + .and_then(Value::as_str) + .unwrap_or_else(|| mime_for_target(&target_uri)); + Response::ok(json!({ + "schema": "elastos.webspace.adapter.read-bytes/v1", + "data": data, + "mime": mime, + "receipt": { + "schema": "elastos.webspace.adapter.read-bytes-receipt/v1", + "resolver": RESOLVER_NAME, + "provider": PROVIDER_NAME, + "target_uri": target_uri, + "handle_uri": handle_uri, + "bytes": bytes.len(), + "federation_backend": "operator_private_http", + "credential_exposed": false, + "endpoint_authority_exposed": false + } + })) + } + + #[allow(clippy::too_many_arguments)] + fn write_bytes_via_endpoint( + &self, + endpoint: &OperatorEndpoint, + mount: String, + handle_uri: String, + target_uri: String, + data: String, + if_head: Option, + byte_count: usize, + ) -> Response { + let remote = match post_operator_endpoint( + endpoint, + "write_bytes", + json!({ + "mount": mount, + "resolver": RESOLVER_NAME, + "handle_uri": handle_uri, + "target_uri": target_uri, + "data": data, + "if_head": if_head + }), + ) { + Ok(data) => data, + Err(err) => return Response::error(&err.code, err.message), + }; + Response::ok(json!({ + "schema": "elastos.webspace.adapter.write-bytes/v1", + "receipt": { + "schema": "elastos.webspace.adapter.write-bytes-receipt/v1", + "resolver": RESOLVER_NAME, + "provider": PROVIDER_NAME, + "target_uri": target_uri, + "handle_uri": handle_uri, + "bytes_accepted": byte_count, + "remote_head": remote.get("head").cloned().unwrap_or(Value::Null), + "federation_backend": "operator_private_http", + "credential_exposed": false, + "endpoint_authority_exposed": false + } + })) + } + + fn object_root(&self) -> PathBuf { + self.root.join("objects") + } + + fn target_path(&self, target_uri: &str) -> Result { + let parts = target_parts(target_uri)?; + if parts.is_empty() { + return Err("target URI must reference a file".to_string()); + } + Ok(parts + .into_iter() + .fold(self.object_root(), |path, part| path.join(part))) + } + + fn read_target_bytes(&self, target_uri: &str) -> Result, String> { + if target_uri == SEED_BRIEF_TARGET { + return Ok(SEED_BRIEF_BYTES.to_vec()); + } + let path = self.target_path(target_uri)?; + fs::read(path).map_err(|err| format!("operator adapter target is unavailable: {err}")) + } + + fn collect_stored_entries( + &self, + prefix: &[String], + entries: &mut Vec, + ) -> Result<(), String> { + let root = prefix + .iter() + .fold(self.object_root(), |path, part| path.join(part)); + if !root.exists() { + return Ok(()); + } + collect_stored_entries_at(&self.object_root(), &root, prefix.len(), entries) + } +} + +fn validate_adapter_request( + schema: Option<&str>, + mount: &str, + resolver: &str, + handle_uri: &str, + target_uri: &str, + runtime_invocation: Option<&Value>, + op: &str, +) -> Result<(), String> { + let op_schema = op.replace('_', "-"); + let expected_schema = format!("elastos.webspace.adapter.{op_schema}-request/v1"); + if schema.unwrap_or(expected_schema.as_str()) != expected_schema { + return Err(format!("adapter request schema mismatch for {op}")); + } + require_non_empty(mount, "mount")?; + if resolver != RESOLVER_NAME { + return Err(format!("resolver must be {RESOLVER_NAME}")); + } + if !handle_uri.starts_with("localhost://WebSpaces/") { + return Err("handle_uri must be a localhost WebSpaces handle".to_string()); + } + target_parts(target_uri)?; + require_runtime_invocation(runtime_invocation, op) +} + +fn require_runtime_invocation(invocation: Option<&Value>, op: &str) -> Result<(), String> { + let Some(invocation) = invocation.and_then(Value::as_object) else { + return Err("operator adapter requires Runtime provider invocation".to_string()); + }; + if invocation.get("schema").and_then(Value::as_str) != Some("elastos.provider.invocation/v1") { + return Err("runtime invocation schema is unsupported".to_string()); + } + if invocation.get("source").and_then(Value::as_str) != Some("webspace-provider") { + return Err("runtime invocation source must be webspace-provider".to_string()); + } + if invocation.get("target").and_then(Value::as_str) != Some(PROVIDER_NAME) { + return Err("runtime invocation target must be operator-drive-adapter".to_string()); + } + if invocation.get("op").and_then(Value::as_str) != Some(op) { + return Err("runtime invocation op mismatch".to_string()); + } + Ok(()) +} + +fn require_non_empty(value: &str, field: &str) -> Result<(), String> { + if value.trim().is_empty() { + Err(format!("{field} is required")) + } else { + Ok(()) + } +} + +fn target_parts(target_uri: &str) -> Result, String> { + let rest = target_uri + .strip_prefix(TARGET_PREFIX) + .ok_or_else(|| format!("target_uri must start with {TARGET_PREFIX}"))?; + let rest = rest.strip_prefix('/').unwrap_or(rest); + if rest.is_empty() { + return Ok(Vec::new()); + } + rest.split('/') + .map(|segment| { + if segment.is_empty() + || segment == "." + || segment == ".." + || segment.contains('\\') + || segment.contains('\0') + { + Err("target_uri contains an unsafe path segment".to_string()) + } else { + Ok(segment.to_string()) + } + }) + .collect() +} + +fn index_entry(path: &str, kind: &str, target_uri: &str, readonly: bool) -> Value { + json!({ + "path": path, + "kind": kind, + "target_uri": target_uri, + "resolver_state": "indexed", + "readonly": readonly, + "description": "Operator drive adapter entry." + }) +} + +fn operator_endpoint_from_extra(extra: &Value) -> Result, String> { + let Some(raw) = extra.get("operator_endpoint") else { + return Ok(None); + }; + if raw.is_null() { + return Ok(None); + } + let config: OperatorEndpointConfig = serde_json::from_value(raw.clone()) + .map_err(|err| format!("invalid operator_endpoint config: {err}"))?; + parse_operator_endpoint(config) +} + +fn parse_operator_endpoint( + config: OperatorEndpointConfig, +) -> Result, String> { + let url = config.url.trim(); + if url.is_empty() { + return Ok(None); + } + if url.contains('?') || url.contains('#') { + return Err("operator endpoint URL must not contain query or fragment".to_string()); + } + let rest = url.strip_prefix("http://").ok_or_else(|| { + "operator endpoint backend only supports operator-private http:// loopback URLs".to_string() + })?; + let (authority, raw_path) = rest.split_once('/').unwrap_or((rest, "")); + if authority.contains('@') { + return Err("operator endpoint URL must not contain userinfo credentials".to_string()); + } + let (host, port) = match authority.rsplit_once(':') { + Some((host, port)) => { + let port = port + .parse::() + .map_err(|_| "operator endpoint URL has an invalid port".to_string())?; + (host.to_string(), port) + } + None => (authority.to_string(), 80), + }; + if host != "127.0.0.1" && host != "localhost" { + return Err( + "operator endpoint backend must use a loopback host owned by the operator service" + .to_string(), + ); + } + let path = if raw_path.is_empty() { + "/".to_string() + } else { + format!("/{raw_path}") + }; + let authorization = config + .authorization + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let timeout_ms = config + .timeout_ms + .unwrap_or(DEFAULT_ENDPOINT_TIMEOUT_MS) + .clamp(100, 30_000); + Ok(Some(OperatorEndpoint { + host, + port, + path, + authorization, + timeout: Duration::from_millis(timeout_ms), + })) +} + +fn endpoint_summary(endpoint: Option<&OperatorEndpoint>) -> Value { + match endpoint { + Some(endpoint) => json!({ + "schema": "elastos.webspace.operator-endpoint.summary/v1", + "configured": true, + "mode": "operator_private_http", + "scheme": "http", + "loopback_only": true, + "authorization_configured": endpoint.authorization.is_some(), + "credential_exposed": false, + "endpoint_authority_exposed": false, + "note": "Endpoint URL and credentials are provider-owned and redacted from app-visible status." + }), + None => json!({ + "schema": "elastos.webspace.operator-endpoint.summary/v1", + "configured": false, + "mode": "deterministic_local_store", + "credential_exposed": false, + "endpoint_authority_exposed": false + }), + } +} + +fn post_operator_endpoint( + endpoint: &OperatorEndpoint, + op: &str, + request: Value, +) -> Result { + let envelope = json!({ + "schema": ENDPOINT_REQUEST_SCHEMA, + "op": op, + "provider": PROVIDER_NAME, + "resolver": RESOLVER_NAME, + "target_prefix": TARGET_PREFIX, + "request": request + }); + let body = serde_json::to_string(&envelope).map_err(|err| { + EndpointFailure::new( + "operator_endpoint_request_failed", + format!("cannot encode operator endpoint request: {err}"), + ) + })?; + let address_host = if endpoint.host == "localhost" { + "127.0.0.1" + } else { + endpoint.host.as_str() + }; + let mut addresses = format!("{address_host}:{}", endpoint.port) + .to_socket_addrs() + .map_err(|err| { + EndpointFailure::new( + "operator_endpoint_unavailable", + format!("cannot resolve operator endpoint: {err}"), + ) + })?; + let address = addresses + .find(|address| address.ip().is_loopback()) + .ok_or_else(|| { + EndpointFailure::new( + "operator_endpoint_unavailable", + "operator endpoint did not resolve to loopback", + ) + })?; + let mut stream = TcpStream::connect_timeout(&address, endpoint.timeout).map_err(|err| { + EndpointFailure::new( + "operator_endpoint_unavailable", + format!("cannot connect to operator endpoint: {err}"), + ) + })?; + let _ = stream.set_read_timeout(Some(endpoint.timeout)); + let _ = stream.set_write_timeout(Some(endpoint.timeout)); + let mut wire_request = format!( + "POST {} HTTP/1.1\r\nHost: {}:{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n", + endpoint.path, + endpoint.host, + endpoint.port, + body.len() + ); + if let Some(authorization) = &endpoint.authorization { + wire_request.push_str("Authorization: "); + wire_request.push_str(authorization); + wire_request.push_str("\r\n"); + } + wire_request.push_str("\r\n"); + wire_request.push_str(&body); + stream.write_all(wire_request.as_bytes()).map_err(|err| { + EndpointFailure::new( + "operator_endpoint_request_failed", + format!("cannot write operator endpoint request: {err}"), + ) + })?; + let mut response = String::new(); + stream.read_to_string(&mut response).map_err(|err| { + EndpointFailure::new( + "operator_endpoint_request_failed", + format!("cannot read operator endpoint response: {err}"), + ) + })?; + parse_operator_http_response(&response) +} + +fn parse_operator_http_response(response: &str) -> Result { + let (headers, body) = response.split_once("\r\n\r\n").ok_or_else(|| { + EndpointFailure::new( + "operator_endpoint_response_invalid", + "operator endpoint response is missing HTTP headers", + ) + })?; + let status_line = headers.lines().next().unwrap_or_default(); + let status_code = status_line + .split_whitespace() + .nth(1) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| { + EndpointFailure::new( + "operator_endpoint_response_invalid", + "operator endpoint response has invalid HTTP status", + ) + })?; + if !(200..300).contains(&status_code) { + return Err(EndpointFailure::new( + "operator_endpoint_http_error", + format!("operator endpoint returned HTTP {status_code}"), + )); + } + let payload: Value = serde_json::from_str(body.trim()).map_err(|err| { + EndpointFailure::new( + "operator_endpoint_response_invalid", + format!("operator endpoint response is not JSON: {err}"), + ) + })?; + match payload.get("status").and_then(Value::as_str) { + Some("ok") => Ok(payload.get("data").cloned().unwrap_or(Value::Null)), + Some("error") => Err(EndpointFailure::new( + payload + .get("code") + .and_then(Value::as_str) + .unwrap_or("operator_endpoint_error"), + payload + .get("message") + .and_then(Value::as_str) + .unwrap_or("operator endpoint returned an error"), + )), + _ => Err(EndpointFailure::new( + "operator_endpoint_response_invalid", + "operator endpoint response must use status ok or error", + )), + } +} + +fn sanitize_endpoint_entries(remote: &Value) -> Result, String> { + let entries = remote + .get("entries") + .and_then(Value::as_array) + .ok_or_else(|| "operator endpoint metadata response must include entries".to_string())?; + entries + .iter() + .map(|entry| { + let path = entry + .get("path") + .and_then(Value::as_str) + .ok_or_else(|| "operator endpoint entry missing path".to_string())?; + let kind = entry + .get("kind") + .and_then(Value::as_str) + .ok_or_else(|| "operator endpoint entry missing kind".to_string())?; + if !matches!(kind, "file" | "directory") { + return Err("operator endpoint entry kind must be file or directory".to_string()); + } + let target_uri = entry + .get("target_uri") + .and_then(Value::as_str) + .ok_or_else(|| "operator endpoint entry missing target_uri".to_string())?; + target_parts(target_uri)?; + let readonly = entry + .get("readonly") + .and_then(Value::as_bool) + .unwrap_or(true); + Ok(json!({ + "path": path, + "kind": kind, + "target_uri": target_uri, + "resolver_state": "endpoint_indexed", + "readonly": readonly, + "description": entry + .get("description") + .and_then(Value::as_str) + .unwrap_or("Operator endpoint adapter entry.") + })) + }) + .collect() +} + +fn collect_stored_entries_at( + object_root: &Path, + current: &Path, + prefix_len: usize, + entries: &mut Vec, +) -> Result<(), String> { + let dir = fs::read_dir(current).map_err(|err| format!("cannot read adapter store: {err}"))?; + for entry in dir { + let entry = entry.map_err(|err| format!("cannot read adapter store entry: {err}"))?; + let path = entry.path(); + let metadata = entry + .metadata() + .map_err(|err| format!("cannot read adapter store metadata: {err}"))?; + let relative = path + .strip_prefix(object_root) + .map_err(|_| "adapter store path escaped object root".to_string())? + .iter() + .map(|part| part.to_string_lossy().to_string()) + .collect::>(); + let display = relative + .iter() + .skip(prefix_len) + .cloned() + .collect::>() + .join("/"); + if !display.is_empty() { + let target_uri = format!("{TARGET_PREFIX}/{}", relative.join("/")); + entries.push(index_entry( + &display, + if metadata.is_dir() { + "directory" + } else { + "file" + }, + &target_uri, + false, + )); + } + if metadata.is_dir() { + collect_stored_entries_at(object_root, &path, prefix_len, entries)?; + } + } + Ok(()) +} + +fn mime_for_target(target_uri: &str) -> &'static str { + let lower = target_uri.to_ascii_lowercase(); + if lower.ends_with(".md") || lower.ends_with(".txt") { + "text/plain" + } else if lower.ends_with(".json") { + "application/json" + } else if lower.ends_with(".pdf") { + "application/pdf" + } else { + "application/octet-stream" + } +} + +fn write_response(response: &Response) -> io::Result<()> { + let mut stdout = io::stdout().lock(); + serde_json::to_writer(&mut stdout, response)?; + stdout.write_all(b"\n")?; + stdout.flush() +} + +fn main() { + let stdin = io::stdin(); + let mut adapter = OperatorDriveAdapter::default(); + for line in stdin.lock().lines() { + let line = match line { + Ok(line) => line, + Err(err) => { + let _ = write_response(&Response::error("io_error", err.to_string())); + break; + } + }; + if line.trim().is_empty() { + continue; + } + let response = match serde_json::from_str::(&line) { + Ok(request) => adapter.handle(request), + Err(err) => Response::error("invalid_request", err.to_string()), + }; + let shutdown = matches!(response, Response::Ok { .. }) + && serde_json::from_str::(&line) + .map(|request| matches!(request, Request::Shutdown)) + .unwrap_or(false); + if let Err(err) = write_response(&response) { + eprintln!("operator-drive-adapter write error: {err}"); + break; + } + if shutdown { + break; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{TcpListener, TcpStream}; + use std::sync::mpsc; + use std::thread; + + fn runtime_invocation(op: &str) -> Value { + json!({ + "schema": "elastos.provider.invocation/v1", + "source": "webspace-provider", + "target": PROVIDER_NAME, + "op": op + }) + } + + fn init_with_temp(provider: &mut OperatorDriveAdapter) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let response = provider.init(ProviderConfig { + base_path: dir.path().display().to_string(), + extra: Value::Null, + }); + assert!(matches!(response, Response::Ok { .. })); + dir + } + + fn init_with_endpoint( + provider: &mut OperatorDriveAdapter, + endpoint_url: &str, + authorization: &str, + ) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + let response = provider.init(ProviderConfig { + base_path: dir.path().display().to_string(), + extra: json!({ + "operator_endpoint": { + "url": endpoint_url, + "authorization": authorization, + "timeout_ms": 1_000 + } + }), + }); + assert!(matches!(response, Response::Ok { .. })); + dir + } + + fn spawn_operator_endpoint( + response_data: Value, + ) -> (String, mpsc::Receiver, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (sender, receiver) = mpsc::channel(); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + sender.send(request).unwrap(); + let body = json!({ + "status": "ok", + "data": response_data + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(response.as_bytes()).unwrap(); + }); + (format!("http://{address}/operator-drive"), receiver, handle) + } + + fn read_http_request(stream: &mut TcpStream) -> String { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 512]; + loop { + let read = stream.read(&mut chunk).unwrap(); + if read == 0 { + break; + } + buffer.extend_from_slice(&chunk[..read]); + if let Some(header_end) = buffer.windows(4).position(|item| item == b"\r\n\r\n") { + let header_end = header_end + 4; + let headers = String::from_utf8_lossy(&buffer[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + line.split_once(':').and_then(|(name, value)| { + if name.eq_ignore_ascii_case("content-length") { + value.trim().parse::().ok() + } else { + None + } + }) + }) + .unwrap_or(0); + if buffer.len() >= header_end + content_length { + break; + } + } + } + String::from_utf8(buffer).unwrap() + } + + fn request_body(request: &str) -> Value { + let (_, body) = request.split_once("\r\n\r\n").unwrap(); + serde_json::from_str(body).unwrap() + } + + fn spawn_filesystem_operator_endpoint( + root: PathBuf, + request_count: usize, + ) -> (String, mpsc::Receiver, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (sender, receiver) = mpsc::channel(); + let handle = thread::spawn(move || { + for _ in 0..request_count { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + let envelope = request_body(&request); + sender.send(envelope.clone()).unwrap(); + let data = filesystem_endpoint_response(&root, &envelope); + let body = json!({ + "status": "ok", + "data": data + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(response.as_bytes()).unwrap(); + } + }); + (format!("http://{address}/operator-drive"), receiver, handle) + } + + fn filesystem_endpoint_response(root: &Path, envelope: &Value) -> Value { + assert_eq!(envelope["schema"], ENDPOINT_REQUEST_SCHEMA); + assert_eq!(envelope["provider"], PROVIDER_NAME); + assert_eq!(envelope["resolver"], RESOLVER_NAME); + assert_eq!(envelope["target_prefix"], TARGET_PREFIX); + assert!(envelope.get("_runtime_invocation").is_none()); + let request = &envelope["request"]; + match envelope["op"].as_str().unwrap() { + "metadata_index" => json!({ + "entries": filesystem_endpoint_entries(root) + }), + "read_bytes" => { + let target_uri = request["target_uri"].as_str().unwrap(); + let bytes = fs::read(filesystem_endpoint_path(root, target_uri)).unwrap(); + json!({ + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + "mime": mime_for_target(target_uri) + }) + } + "write_bytes" => { + let target_uri = request["target_uri"].as_str().unwrap(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(request["data"].as_str().unwrap()) + .unwrap(); + let path = filesystem_endpoint_path(root, target_uri); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, bytes).unwrap(); + json!({ + "head": format!("fs-head:{}", target_uri.replace('/', ":")) + }) + } + op => panic!("unsupported filesystem endpoint op: {op}"), + } + } + + fn filesystem_endpoint_entries(root: &Path) -> Vec { + let mut entries = Vec::new(); + filesystem_endpoint_collect(root, root, &mut entries); + entries.sort_by(|left, right| { + left["path"] + .as_str() + .unwrap() + .cmp(right["path"].as_str().unwrap()) + }); + entries + } + + fn filesystem_endpoint_collect(root: &Path, current: &Path, entries: &mut Vec) { + for entry in fs::read_dir(current).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + let metadata = entry.metadata().unwrap(); + let relative = path + .strip_prefix(root) + .unwrap() + .iter() + .map(|part| part.to_string_lossy().to_string()) + .collect::>() + .join("/"); + if relative.is_empty() { + continue; + } + let target_uri = format!("{TARGET_PREFIX}/{relative}"); + entries.push(json!({ + "path": relative, + "kind": if metadata.is_dir() { "directory" } else { "file" }, + "target_uri": target_uri, + "readonly": !target_uri.starts_with("operator://drive/Writable/"), + "description": "Filesystem-backed operator endpoint entry." + })); + if metadata.is_dir() { + filesystem_endpoint_collect(root, &path, entries); + } + } + } + + fn filesystem_endpoint_path(root: &Path, target_uri: &str) -> PathBuf { + let parts = target_parts(target_uri).unwrap(); + assert!(!parts.is_empty()); + parts + .into_iter() + .fold(root.to_path_buf(), |path, part| path.join(part)) + } + + fn data(response: Response) -> Value { + match response { + Response::Ok { data: Some(data) } => data, + Response::Ok { data: None } => Value::Null, + Response::Error { code, message } => panic!("{code}: {message}"), + } + } + + fn error_code(response: Response) -> String { + match response { + Response::Error { code, .. } => code, + Response::Ok { .. } => panic!("expected error"), + } + } + + #[test] + fn status_exposes_contract_without_credentials() { + let provider = OperatorDriveAdapter::default(); + let data = data(provider.status()); + assert_eq!(data["schema"], ADAPTER_SCHEMA); + assert_eq!(data["provider"], PROVIDER_NAME); + assert_eq!(data["resolver"], RESOLVER_NAME); + assert_eq!(data["contract"]["requires_runtime_invocation"], true); + assert!(data["blocked_authority"] + .as_array() + .unwrap() + .iter() + .any(|item| item == "resolver_credentials")); + } + + #[test] + fn status_redacts_operator_endpoint_authority() { + let mut provider = OperatorDriveAdapter::default(); + let _dir = init_with_endpoint( + &mut provider, + "http://127.0.0.1:39999/operator-secret-path", + "Bearer endpoint-secret", + ); + let data = data(provider.status()); + assert_eq!(data["endpoint"]["configured"], true); + assert_eq!(data["endpoint"]["mode"], "operator_private_http"); + assert_eq!(data["endpoint"]["authorization_configured"], true); + assert_eq!(data["endpoint"]["credential_exposed"], false); + assert_eq!(data["endpoint"]["endpoint_authority_exposed"], false); + let serialized = data.to_string(); + assert!(!serialized.contains("endpoint-secret")); + assert!(!serialized.contains("127.0.0.1")); + assert!(!serialized.contains("39999")); + assert!(!serialized.contains("operator-secret-path")); + } + + #[test] + fn endpoint_config_rejects_non_loopback_backend() { + let mut provider = OperatorDriveAdapter::default(); + let dir = tempfile::tempdir().unwrap(); + let response = provider.init(ProviderConfig { + base_path: dir.path().display().to_string(), + extra: json!({ + "operator_endpoint": { + "url": "http://example.com/operator-drive" + } + }), + }); + assert_eq!(error_code(response), "invalid_endpoint_config"); + } + + #[test] + fn metadata_index_requires_runtime_invocation() { + let mut provider = OperatorDriveAdapter::default(); + let _dir = init_with_temp(&mut provider); + assert_eq!( + error_code(provider.metadata_index( + None, + "Operator".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/Operator".to_string(), + TARGET_PREFIX.to_string(), + None, + )), + "invalid_request" + ); + } + + #[test] + fn metadata_index_lists_seeded_operator_entries() { + let mut provider = OperatorDriveAdapter::default(); + let _dir = init_with_temp(&mut provider); + let data = data(provider.metadata_index( + None, + "Operator".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/Operator".to_string(), + TARGET_PREFIX.to_string(), + Some(runtime_invocation("metadata_index")), + )); + assert_eq!(data["schema"], "elastos.webspace.adapter.metadata-index/v1"); + assert!(data["entries"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["path"] == "Projects/Brief.md" + && entry["target_uri"] == SEED_BRIEF_TARGET)); + assert_eq!(data["receipt"]["credential_exposed"], false); + } + + #[test] + fn metadata_index_uses_operator_endpoint_backend_without_runtime_leakage() { + let (endpoint_url, request_rx, handle) = spawn_operator_endpoint(json!({ + "entries": [ + { + "path": "Projects/Federated.md", + "kind": "file", + "target_uri": "operator://drive/Projects/Federated.md", + "readonly": true, + "description": "Federated operator endpoint entry." + } + ] + })); + let mut provider = OperatorDriveAdapter::default(); + let _dir = init_with_endpoint(&mut provider, &endpoint_url, "Bearer endpoint-secret"); + let data = data(provider.metadata_index( + None, + "Operator".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/Operator".to_string(), + TARGET_PREFIX.to_string(), + Some(runtime_invocation("metadata_index")), + )); + let request = request_rx.recv().unwrap(); + handle.join().unwrap(); + assert!(request.contains("Authorization: Bearer endpoint-secret")); + assert!(request.contains(r#""schema":"elastos.webspace.operator-endpoint.request/v1""#)); + assert!(request.contains(r#""op":"metadata_index""#)); + assert!(!request.contains("_runtime_invocation")); + assert!(!request.contains("elastos.provider.invocation/v1")); + assert_eq!( + data["receipt"]["federation_backend"], + "operator_private_http" + ); + assert_eq!(data["receipt"]["credential_exposed"], false); + assert_eq!(data["receipt"]["endpoint_authority_exposed"], false); + assert!(data["entries"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["path"] == "Projects/Federated.md" + && entry["resolver_state"] == "endpoint_indexed")); + assert!(!data.to_string().contains("endpoint-secret")); + } + + #[test] + fn read_bytes_returns_seeded_brief() { + let mut provider = OperatorDriveAdapter::default(); + let _dir = init_with_temp(&mut provider); + let data = data(provider.read_bytes( + None, + "Operator".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/Operator/Projects/Brief.md".to_string(), + SEED_BRIEF_TARGET.to_string(), + Some(runtime_invocation("read_bytes")), + )); + let bytes = base64::engine::general_purpose::STANDARD + .decode(data["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(bytes, SEED_BRIEF_BYTES); + assert_eq!(data["receipt"]["credential_exposed"], false); + } + + #[test] + fn read_bytes_uses_operator_endpoint_backend() { + let (endpoint_url, request_rx, handle) = spawn_operator_endpoint(json!({ + "data": base64::engine::general_purpose::STANDARD.encode(b"federated bytes"), + "mime": "text/plain" + })); + let mut provider = OperatorDriveAdapter::default(); + let _dir = init_with_endpoint(&mut provider, &endpoint_url, "Bearer endpoint-secret"); + let data = data(provider.read_bytes( + None, + "Operator".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/Operator/Projects/Federated.md".to_string(), + "operator://drive/Projects/Federated.md".to_string(), + Some(runtime_invocation("read_bytes")), + )); + let request = request_rx.recv().unwrap(); + handle.join().unwrap(); + assert!(request.contains(r#""op":"read_bytes""#)); + assert!(!request.contains("_runtime_invocation")); + let bytes = base64::engine::general_purpose::STANDARD + .decode(data["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(bytes, b"federated bytes"); + assert_eq!( + data["receipt"]["federation_backend"], + "operator_private_http" + ); + assert_eq!(data["receipt"]["endpoint_authority_exposed"], false); + } + + #[test] + fn write_bytes_persists_under_writable_namespace() { + let mut provider = OperatorDriveAdapter::default(); + let _dir = init_with_temp(&mut provider); + let target_uri = "operator://drive/Writable/Folder/note.txt"; + let write = data(provider.write_bytes( + None, + "OperatorMutable".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/OperatorMutable/Folder/note.txt".to_string(), + target_uri.to_string(), + base64::engine::general_purpose::STANDARD.encode(b"operator bytes"), + None, + Some(runtime_invocation("write_bytes")), + )); + assert_eq!(write["schema"], "elastos.webspace.adapter.write-bytes/v1"); + assert_eq!(write["receipt"]["bytes_accepted"], 14); + + let read = data(provider.read_bytes( + None, + "OperatorMutable".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/OperatorMutable/Folder/note.txt".to_string(), + target_uri.to_string(), + Some(runtime_invocation("read_bytes")), + )); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(bytes, b"operator bytes"); + } + + #[test] + fn write_bytes_uses_operator_endpoint_backend() { + let (endpoint_url, request_rx, handle) = spawn_operator_endpoint(json!({ + "head": "remote-head-1" + })); + let mut provider = OperatorDriveAdapter::default(); + let _dir = init_with_endpoint(&mut provider, &endpoint_url, "Bearer endpoint-secret"); + let data = data(provider.write_bytes( + None, + "OperatorMutable".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/OperatorMutable/Folder/note.txt".to_string(), + "operator://drive/Writable/Folder/note.txt".to_string(), + base64::engine::general_purpose::STANDARD.encode(b"federated write"), + None, + Some(runtime_invocation("write_bytes")), + )); + let request = request_rx.recv().unwrap(); + handle.join().unwrap(); + assert!(request.contains(r#""op":"write_bytes""#)); + assert!(request.contains("ZmVkZXJhdGVkIHdyaXRl")); + assert!(!request.contains("_runtime_invocation")); + assert_eq!(data["receipt"]["remote_head"], "remote-head-1"); + assert_eq!( + data["receipt"]["federation_backend"], + "operator_private_http" + ); + assert_eq!(data["receipt"]["bytes_accepted"], 15); + assert_eq!(data["receipt"]["credential_exposed"], false); + } + + #[test] + fn operator_endpoint_backend_traverses_reads_and_writes_real_filesystem_state() { + let backend = tempfile::tempdir().unwrap(); + let projects = backend.path().join("Projects"); + fs::create_dir_all(&projects).unwrap(); + fs::write( + projects.join("Federated.md"), + b"# Federated\n\nBackend bytes.\n", + ) + .unwrap(); + let (endpoint_url, request_rx, handle) = + spawn_filesystem_operator_endpoint(backend.path().to_path_buf(), 4); + + let mut provider = OperatorDriveAdapter::default(); + let _dir = init_with_endpoint(&mut provider, &endpoint_url, "Bearer endpoint-secret"); + let index = data(provider.metadata_index( + None, + "Operator".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/Operator".to_string(), + TARGET_PREFIX.to_string(), + Some(runtime_invocation("metadata_index")), + )); + assert!(index["entries"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["path"] == "Projects/Federated.md" + && entry["resolver_state"] == "endpoint_indexed")); + assert_eq!( + index["receipt"]["federation_backend"], + "operator_private_http" + ); + + let read = data(provider.read_bytes( + None, + "Operator".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/Operator/Projects/Federated.md".to_string(), + "operator://drive/Projects/Federated.md".to_string(), + Some(runtime_invocation("read_bytes")), + )); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(bytes, b"# Federated\n\nBackend bytes.\n"); + + let write = data(provider.write_bytes( + None, + "OperatorMutable".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/OperatorMutable/Folder/note.txt".to_string(), + "operator://drive/Writable/Folder/note.txt".to_string(), + base64::engine::general_purpose::STANDARD.encode(b"backend write"), + None, + Some(runtime_invocation("write_bytes")), + )); + assert_eq!(write["receipt"]["bytes_accepted"], 13); + assert_eq!( + fs::read(backend.path().join("Writable/Folder/note.txt")).unwrap(), + b"backend write" + ); + + let reread = data(provider.read_bytes( + None, + "OperatorMutable".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/OperatorMutable/Folder/note.txt".to_string(), + "operator://drive/Writable/Folder/note.txt".to_string(), + Some(runtime_invocation("read_bytes")), + )); + let reread_bytes = base64::engine::general_purpose::STANDARD + .decode(reread["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(reread_bytes, b"backend write"); + + for _ in 0..4 { + let envelope = request_rx.recv().unwrap(); + assert_eq!(envelope["schema"], ENDPOINT_REQUEST_SCHEMA); + assert!(envelope.get("_runtime_invocation").is_none()); + assert!(!envelope.to_string().contains("endpoint-secret")); + assert!(!envelope + .to_string() + .contains(backend.path().to_string_lossy().as_ref())); + } + handle.join().unwrap(); + } + + #[test] + fn write_bytes_rejects_readonly_and_conflict_targets() { + let mut provider = OperatorDriveAdapter::default(); + let _dir = init_with_temp(&mut provider); + assert_eq!( + error_code(provider.write_bytes( + None, + "Operator".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/Operator/Projects/Brief.md".to_string(), + SEED_BRIEF_TARGET.to_string(), + base64::engine::general_purpose::STANDARD.encode(b"no"), + None, + Some(runtime_invocation("write_bytes")), + )), + "readonly" + ); + assert_eq!( + error_code(provider.write_bytes( + None, + "OperatorMutable".to_string(), + RESOLVER_NAME.to_string(), + "localhost://WebSpaces/OperatorMutable/Conflict/stale.txt".to_string(), + "operator://drive/Writable/Conflict/stale.txt".to_string(), + base64::engine::general_purpose::STANDARD.encode(b"stale"), + None, + Some(runtime_invocation("write_bytes")), + )), + "conflict" + ); + } + + #[test] + fn wire_request_rejects_hidden_credentials() { + let payload = json!({ + "op": "read_bytes", + "mount": "Operator", + "resolver": RESOLVER_NAME, + "handle_uri": "localhost://WebSpaces/Operator/Projects/Brief.md", + "target_uri": SEED_BRIEF_TARGET, + "_runtime_invocation": runtime_invocation("read_bytes"), + "resolver_credentials": "must-not-be-accepted" + }); + let err = serde_json::from_value::(payload) + .unwrap_err() + .to_string(); + assert!(err.contains("unknown field")); + } +} diff --git a/capsules/rights-provider/src/main.rs b/capsules/rights-provider/src/main.rs index 16b46cbf..2b02f9a5 100644 --- a/capsules/rights-provider/src/main.rs +++ b/capsules/rights-provider/src/main.rs @@ -157,6 +157,40 @@ impl RightsProvider { "key-provider", "decrypt-provider" ], + "contract": { + "schema": "elastos.protected-content.rights-provider/v1", + "authority_boundary": "typed rights decisions only", + "denied_to_apps": [ + "contract_sdk", + "chain_rpc", + "wallet_rpc", + "key_backend_sdk", + "raw_cek", + "provider_credentials" + ], + "operations": { + "has_access_by_content_id": { + "input": [ + "principal_id", + "session_id", + "content_id", + "right", + "reason", + "policy_ref?" + ], + "output": "allow/deny receipt when a dDRM policy backend is configured" + }, + "can_stream": { + "input": ["principal_id", "session_id", "content_id", "reason", "policy_ref?"], + "output": "allow/deny stream receipt" + }, + "can_download": { + "input": ["principal_id", "session_id", "content_id", "reason", "policy_ref?"], + "output": "allow/deny download receipt" + } + }, + "status": "fail_closed_until_policy_backend_configured" + }, })) } @@ -368,6 +402,14 @@ mod tests { .as_array() .unwrap() .contains(&json!("contract_sdk"))); + assert_eq!( + data["contract"]["schema"], + "elastos.protected-content.rights-provider/v1" + ); + assert_eq!( + data["contract"]["status"], + "fail_closed_until_policy_backend_configured" + ); } #[test] diff --git a/capsules/webspace-provider/src/main.rs b/capsules/webspace-provider/src/main.rs index f7898f2c..e4c6ee07 100644 --- a/capsules/webspace-provider/src/main.rs +++ b/capsules/webspace-provider/src/main.rs @@ -1,4 +1,8 @@ +use std::collections::BTreeMap; +use std::fs; use std::io::{self, BufRead, Write}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; use elastos_common::localhost::{parse_localhost_path, parse_localhost_uri}; use serde::{Deserialize, Serialize}; @@ -17,9 +21,47 @@ const SUPPORTED_OPS: &[&str] = &[ "list", "stat", "exists", + "mounts", + "adapters", + "register_adapter", + "unregister_adapter", + "check_adapter", + "mount", + "unmount", + "index", + "health", + "refresh", + "head", + "cache", + "cache_status", + "sync", + "sync_status", + "fork", + "write", + "delete", + "mkdir", ]; -const UNSUPPORTED_OPS: &[&str] = &["write", "delete", "mkdir"]; +const UNSUPPORTED_OPS: &[&str] = &[]; +const BUILTIN_MONIKER: &str = "Elastos"; +const MOUNT_TABLE_SCHEMA: &str = "elastos.webspace.mount-table/v1"; +const MOUNT_RECORD_SCHEMA: &str = "elastos.webspace.mount/v1"; +const INDEX_TABLE_SCHEMA: &str = "elastos.webspace.index-table/v1"; +const INDEX_ENTRY_SCHEMA: &str = "elastos.webspace.index-entry/v1"; +const HEAD_TABLE_SCHEMA: &str = "elastos.webspace.head-table/v1"; +const HEAD_RECORD_SCHEMA: &str = "elastos.webspace.object-head/v1"; +const OBJECT_TABLE_SCHEMA: &str = "elastos.webspace.object-table/v1"; +const OBJECT_RECORD_SCHEMA: &str = "elastos.webspace.object/v1"; +const ADAPTER_TABLE_SCHEMA: &str = "elastos.webspace.adapter-table/v1"; +const ADAPTER_RECORD_SCHEMA: &str = "elastos.webspace.adapter/v1"; +const DEFAULT_CACHE_POLICY: &str = "metadata-only"; +const DEFAULT_SYNC_POLICY: &str = "manual"; +const DEFAULT_EXTERNAL_RESOLVER: &str = "external"; +const DEFAULT_READONLY_ACCESS_POLICY: &str = "resolver-readonly"; +const DEFAULT_MUTABLE_ACCESS_POLICY: &str = "owner-writable"; +const DEFAULT_ADAPTER_STATE: &str = "configured"; +const PROVIDER_ID: &str = "webspace-provider"; +const ADAPTER_HEALTH_STALE_AFTER_SECS: u64 = 24 * 60 * 60; #[derive(Debug, Deserialize)] #[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] @@ -58,28 +100,168 @@ enum Request { #[serde(rename = "token")] _token: String, }, + Mounts { + #[serde(default, rename = "token")] + _token: String, + }, + Adapters { + #[serde(default, rename = "token")] + _token: String, + }, + RegisterAdapter { + resolver: String, + #[serde(default)] + label: Option, + #[serde(default)] + endpoint_uri: Option, + #[serde(default)] + provider: Option, + #[serde(default)] + state: Option, + #[serde(default)] + capabilities: Vec, + #[serde(default)] + readonly_default: Option, + #[serde(default)] + description: Option, + #[serde(default, rename = "token")] + _token: String, + }, + UnregisterAdapter { + resolver: String, + #[serde(default, rename = "token")] + _token: String, + }, + CheckAdapter { + resolver: String, + #[serde(default)] + result: Option, + #[serde(default)] + state: Option, + #[serde(default)] + error_code: Option, + #[serde(default)] + capabilities: Vec, + #[serde(default, rename = "token")] + _token: String, + }, + Health { + #[serde(default)] + moniker: Option, + #[serde(default, rename = "token")] + _token: String, + }, + Mount { + moniker: String, + target_uri: String, + #[serde(default)] + namespace_uri: Option, + #[serde(default)] + resolver: Option, + #[serde(default)] + description: Option, + #[serde(default)] + readonly: Option, + #[serde(default)] + cache_policy: Option, + #[serde(default)] + sync_policy: Option, + #[serde(default)] + access_policy: Option, + #[serde(default, rename = "token")] + _token: String, + }, + Unmount { + moniker: String, + #[serde(default, rename = "token")] + _token: String, + }, + Index { + moniker: String, + #[serde(default)] + entries: Vec, + #[serde(default, rename = "token")] + _token: String, + }, + Refresh { + path: String, + #[serde(default)] + entries: Option>, + #[serde(default, rename = "token")] + _token: String, + }, + Head { + path: String, + #[serde(default, rename = "token")] + _token: String, + }, + Cache { + path: String, + #[serde(default)] + content: Option>, + #[serde(default)] + mime: Option, + #[serde(default)] + source_receipt: Option, + #[serde(default, rename = "token")] + _token: String, + }, + CacheStatus { + path: String, + #[serde(default, rename = "token")] + _token: String, + }, + Sync { + path: String, + #[serde(default, rename = "token")] + _token: String, + }, + SyncStatus { + path: String, + #[serde(default, rename = "token")] + _token: String, + }, + Fork { + source_uri: String, + moniker: String, + #[serde(default)] + target_uri: Option, + #[serde(default)] + resolver: Option, + #[serde(default)] + description: Option, + #[serde(default)] + readonly: Option, + #[serde(default)] + cache_policy: Option, + #[serde(default)] + sync_policy: Option, + #[serde(default)] + access_policy: Option, + #[serde(default, rename = "token")] + _token: String, + }, Write { path: String, #[serde(rename = "token")] _token: String, - #[serde(rename = "content")] - _content: Vec, - #[serde(rename = "append")] - _append: bool, + content: Vec, + #[serde(default)] + append: bool, }, Delete { path: String, #[serde(rename = "token")] _token: String, - #[serde(rename = "recursive")] - _recursive: bool, + #[serde(default)] + recursive: bool, }, Mkdir { path: String, #[serde(rename = "token")] _token: String, - #[serde(rename = "parents")] - _parents: bool, + #[serde(default)] + parents: bool, }, Ping, Shutdown, @@ -104,6 +286,23 @@ struct DirEntry { is_file: bool, is_dir: bool, size: u64, + readonly: bool, + access_policy: String, + provider: String, + resolver_state: String, + resolver: String, + cache_policy: String, + sync_policy: String, + kind: String, + traversable: bool, + object_id: String, + head_id: String, + cache_state: String, + sync_state: String, + #[serde(skip_serializing_if = "Option::is_none")] + namespace_uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + target_uri: Option, } #[derive(Debug, Serialize)] @@ -113,6 +312,22 @@ struct FileStat { is_dir: bool, size: u64, readonly: bool, + access_policy: String, + provider: String, + resolver_state: String, + resolver: String, + cache_policy: String, + sync_policy: String, + kind: String, + traversable: bool, + object_id: String, + head_id: String, + cache_state: String, + sync_state: String, + #[serde(skip_serializing_if = "Option::is_none")] + namespace_uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + target_uri: Option, modified: Option, created: Option, } @@ -125,13 +340,226 @@ struct WebSpaceHandle { #[serde(skip_serializing_if = "Option::is_none")] target_uri: Option, resolver_state: String, + resolver: String, + cache_policy: String, + sync_policy: String, + readonly: bool, + access_policy: String, kind: String, traversable: bool, + size: u64, + object_id: String, + head_id: String, + cache_state: String, + sync_state: String, description: String, #[serde(skip_serializing_if = "Option::is_none")] + forked_from: Option, + #[serde(skip_serializing_if = "Option::is_none")] next_step: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WebSpaceMount { + #[serde(default = "mount_record_schema")] + schema: String, + moniker: String, + target_uri: String, + #[serde(default)] + namespace_uri: Option, + #[serde(default = "default_external_resolver")] + resolver: String, + #[serde(default = "default_true")] + readonly: bool, + #[serde(default = "default_access_policy")] + access_policy: String, + #[serde(default = "default_cache_policy")] + cache_policy: String, + #[serde(default = "default_sync_policy")] + sync_policy: String, + #[serde(default)] + description: String, + #[serde(default)] + forked_from: Option, + #[serde(default)] + created_at: u64, + #[serde(default)] + updated_at: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +struct MountTable { + #[serde(default = "mount_table_schema")] + schema: String, + #[serde(default)] + mounts: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct WebSpaceIndexInput { + path: String, + kind: String, + #[serde(default)] + target_uri: Option, + #[serde(default)] + resolver_state: Option, + #[serde(default)] + readonly: Option, + #[serde(default)] + description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WebSpaceIndexEntry { + #[serde(default = "index_entry_schema")] + schema: String, + moniker: String, + path: String, + name: String, + kind: String, + target_uri: String, + resolver: String, + resolver_state: String, + readonly: bool, + description: String, + updated_at: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +struct IndexTable { + #[serde(default = "index_table_schema")] + schema: String, + #[serde(default)] + entries: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WebSpaceHead { + #[serde(default = "head_record_schema")] + schema: String, + handle_uri: String, + #[serde(default)] + target_uri: Option, + moniker: String, + resolver: String, + object_id: String, + head_id: String, + revision: String, + readonly: bool, + access_policy: String, + cache_policy: String, + sync_policy: String, + cache_state: String, + sync_state: String, + #[serde(default)] + forked_from: Option, + #[serde(default)] + created_at: u64, + #[serde(default)] + updated_at: u64, + #[serde(default)] + last_cached_at: Option, + #[serde(default)] + last_synced_at: Option, + dirty: bool, + status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WebSpaceObject { + #[serde(default = "object_record_schema")] + schema: String, + moniker: String, + path: String, + name: String, + kind: String, + #[serde(default)] + target_uri: Option, + #[serde(default)] + mime: String, + #[serde(default)] + content: Vec, + #[serde(default)] + created_at: u64, + #[serde(default)] + updated_at: u64, + #[serde(default)] + revision: String, + #[serde(default)] + dirty: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ObjectTable { + #[serde(default = "object_table_schema")] + schema: String, + #[serde(default)] + objects: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WebSpaceAdapter { + #[serde(default = "adapter_record_schema")] + schema: String, + resolver: String, + #[serde(default)] + label: String, + #[serde(default)] + endpoint_uri: Option, + #[serde(default)] + provider: Option, + #[serde(default = "default_adapter_state")] + state: String, + #[serde(default)] + capabilities: Vec, + #[serde(default = "default_true")] + readonly_default: bool, + #[serde(default)] + description: String, + #[serde(default)] + created_at: u64, + #[serde(default)] + updated_at: u64, + #[serde(default)] + last_checked_at: Option, + #[serde(default)] + last_check_result: Option, + #[serde(default)] + last_check_error_code: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct AdapterTable { + #[serde(default = "adapter_table_schema")] + schema: String, + #[serde(default)] + adapters: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct HeadTable { + #[serde(default = "head_table_schema")] + schema: String, + #[serde(default)] + heads: Vec, +} + +#[derive(Debug, Default, Deserialize)] +struct InitConfig { + #[serde(default)] + base_path: String, +} + +#[derive(Debug, Default)] +struct ProviderState { + base_path: Option, + mounts: Vec, + index_entries: Vec, + heads: Vec, + objects: Vec, + adapters: Vec, +} + #[derive(Debug, Clone)] enum ResolvedPath { Root, @@ -139,36 +567,2267 @@ enum ResolvedPath { Meta { handle: WebSpaceHandle }, } -fn meta_path(handle: &WebSpaceHandle) -> String { - format!("{}/_meta.json", handle.handle_uri.trim_end_matches('/')) +fn mount_record_schema() -> String { + MOUNT_RECORD_SCHEMA.to_string() } -fn known_mounts() -> Vec { - vec![mount_handle( - "Elastos", - Some("elastos://".to_string()), - "Local interpreted handle into the broader elastos:// namespace.", - Some( - "List this handle to discover typed child spaces such as content, peer, did, and ai." - .to_string(), - ), - )] +fn mount_table_schema() -> String { + MOUNT_TABLE_SCHEMA.to_string() } -fn normalize_moniker(moniker: &str) -> String { - moniker - .trim() - .trim_matches('/') - .trim_end_matches("://") - .to_string() +fn index_entry_schema() -> String { + INDEX_ENTRY_SCHEMA.to_string() +} + +fn index_table_schema() -> String { + INDEX_TABLE_SCHEMA.to_string() +} + +fn head_record_schema() -> String { + HEAD_RECORD_SCHEMA.to_string() +} + +fn head_table_schema() -> String { + HEAD_TABLE_SCHEMA.to_string() +} + +fn object_record_schema() -> String { + OBJECT_RECORD_SCHEMA.to_string() +} + +fn object_table_schema() -> String { + OBJECT_TABLE_SCHEMA.to_string() +} + +fn adapter_record_schema() -> String { + ADAPTER_RECORD_SCHEMA.to_string() +} + +fn adapter_table_schema() -> String { + ADAPTER_TABLE_SCHEMA.to_string() +} + +fn default_external_resolver() -> String { + DEFAULT_EXTERNAL_RESOLVER.to_string() +} + +fn default_cache_policy() -> String { + DEFAULT_CACHE_POLICY.to_string() +} + +fn default_sync_policy() -> String { + DEFAULT_SYNC_POLICY.to_string() +} + +fn default_access_policy() -> String { + DEFAULT_READONLY_ACCESS_POLICY.to_string() +} + +fn default_adapter_state() -> String { + DEFAULT_ADAPTER_STATE.to_string() +} + +fn default_true() -> bool { + true +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +fn infer_namespace_uri(target_uri: &str) -> Option { + let (scheme, _) = target_uri.split_once("://")?; + if scheme.trim().is_empty() { + None + } else { + Some(format!("{}://", scheme.trim())) + } +} + +fn append_target_uri(target_uri: &str, parts: &[&str]) -> String { + let suffix = parts + .iter() + .map(|part| part.trim_matches('/')) + .filter(|part| !part.is_empty()) + .collect::>() + .join("/"); + if suffix.is_empty() { + target_uri.to_string() + } else { + format!("{}/{}", target_uri.trim_end_matches('/'), suffix) + } +} + +fn stable_hex(input: &str) -> String { + let mut hash = 0xcbf29ce484222325u64; + for byte in input.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + format!("{hash:016x}") +} + +fn object_id_for(handle: &WebSpaceHandle) -> String { + format!( + "object:webspace:{}", + stable_hex(handle.target_uri.as_deref().unwrap_or(&handle.handle_uri)) + ) +} + +fn head_id_for(handle_uri: &str) -> String { + format!("head:webspace:{}", stable_hex(handle_uri)) +} + +fn revision_for(handle: &WebSpaceHandle, updated_at: u64) -> String { + format!( + "rev:webspace:{}", + stable_hex(&format!( + "{}:{}:{}:{}", + handle.handle_uri, + handle.target_uri.as_deref().unwrap_or(""), + handle.resolver, + updated_at + )) + ) +} + +fn render_external_descriptor_size(handle_uri: &str, target_uri: Option<&str>, kind: &str) -> u64 { + serde_json::to_vec(&serde_json::json!({ + "handle_uri": handle_uri, + "target_uri": target_uri, + "kind": kind, + })) + .map(|bytes| bytes.len() as u64) + .unwrap_or(0) +} + +fn cache_state_for(policy: &str, last_cached_at: Option) -> String { + if policy == "none" { + "cache_disabled".to_string() + } else if last_cached_at.is_some() { + "metadata_cached".to_string() + } else { + "not_cached".to_string() + } +} + +fn sync_state_for(policy: &str, dirty: bool, last_synced_at: Option) -> String { + match (policy, dirty, last_synced_at) { + ("none", _, _) => "sync_disabled".to_string(), + ("manual", true, _) => "manual_pending".to_string(), + ("manual", false, Some(_)) => "manual_synced".to_string(), + ("manual", false, None) => "manual_idle".to_string(), + (_, true, _) => "sync_pending".to_string(), + (_, false, Some(_)) => "synced".to_string(), + _ => "sync_idle".to_string(), + } +} + +fn valid_moniker(moniker: &str) -> bool { + let trimmed = moniker.trim(); + !trimmed.is_empty() + && !trimmed.contains('/') + && !trimmed.contains('\\') + && !trimmed.contains("://") + && trimmed != "." + && trimmed != ".." +} + +impl ProviderState { + fn configure(&mut self, config: serde_json::Value) -> Result { + let config: InitConfig = + serde_json::from_value(config).map_err(|err| format!("invalid init config: {err}"))?; + self.base_path = if config.base_path.trim().is_empty() { + None + } else { + Some(PathBuf::from(config.base_path.trim())) + }; + self.mounts = self.load_mounts()?; + self.index_entries = self.load_indexes()?; + self.heads = self.load_heads()?; + self.objects = self.load_objects()?; + self.adapters = self.load_adapters()?; + Ok(init_payload(self)) + } + + fn mount_table_path(&self) -> Option { + self.base_path.as_ref().map(|base| { + base.join("ElastOS") + .join("SystemServices") + .join("WebSpaces") + .join("mounts.json") + }) + } + + fn head_table_path(&self) -> Option { + self.base_path.as_ref().map(|base| { + base.join("ElastOS") + .join("SystemServices") + .join("WebSpaces") + .join("heads.json") + }) + } + + fn index_table_path(&self) -> Option { + self.base_path.as_ref().map(|base| { + base.join("ElastOS") + .join("SystemServices") + .join("WebSpaces") + .join("indexes.json") + }) + } + + fn object_table_path(&self) -> Option { + self.base_path.as_ref().map(|base| { + base.join("ElastOS") + .join("SystemServices") + .join("WebSpaces") + .join("objects.json") + }) + } + + fn adapter_table_path(&self) -> Option { + self.base_path.as_ref().map(|base| { + base.join("ElastOS") + .join("SystemServices") + .join("WebSpaces") + .join("adapters.json") + }) + } + + fn load_mounts(&self) -> Result, String> { + let Some(path) = self.mount_table_path() else { + return Ok(Vec::new()); + }; + if !path.exists() { + return Ok(Vec::new()); + } + let bytes = fs::read(&path).map_err(|err| { + format!( + "failed to read WebSpace mount table {}: {err}", + path.display() + ) + })?; + let table: MountTable = serde_json::from_slice(&bytes) + .map_err(|err| format!("invalid WebSpace mount table {}: {err}", path.display()))?; + table + .mounts + .into_iter() + .map(normalize_mount_record) + .collect() + } + + fn save_mounts(&self) -> Result<(), String> { + let Some(path) = self.mount_table_path() else { + return Err( + "webspace-provider was not initialized with a persistent base_path".to_string(), + ); + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create WebSpace mount table directory {}: {err}", + parent.display() + ) + })?; + } + let table = MountTable { + schema: MOUNT_TABLE_SCHEMA.to_string(), + mounts: self.mounts.clone(), + }; + let bytes = serde_json::to_vec_pretty(&table) + .map_err(|err| format!("failed to serialize WebSpace mount table: {err}"))?; + write_json_atomic(&path, &bytes) + } + + fn load_indexes(&self) -> Result, String> { + let Some(path) = self.index_table_path() else { + return Ok(Vec::new()); + }; + if !path.exists() { + return Ok(Vec::new()); + } + let bytes = fs::read(&path).map_err(|err| { + format!( + "failed to read WebSpace index table {}: {err}", + path.display() + ) + })?; + let table: IndexTable = serde_json::from_slice(&bytes) + .map_err(|err| format!("invalid WebSpace index table {}: {err}", path.display()))?; + table + .entries + .into_iter() + .map(normalize_index_record) + .collect() + } + + fn save_indexes(&self) -> Result<(), String> { + let Some(path) = self.index_table_path() else { + return Err( + "webspace-provider was not initialized with a persistent base_path".to_string(), + ); + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create WebSpace index table directory {}: {err}", + parent.display() + ) + })?; + } + let table = IndexTable { + schema: INDEX_TABLE_SCHEMA.to_string(), + entries: self.index_entries.clone(), + }; + let bytes = serde_json::to_vec_pretty(&table) + .map_err(|err| format!("failed to serialize WebSpace index table: {err}"))?; + write_json_atomic(&path, &bytes) + } + + fn load_heads(&self) -> Result, String> { + let Some(path) = self.head_table_path() else { + return Ok(Vec::new()); + }; + if !path.exists() { + return Ok(Vec::new()); + } + let bytes = fs::read(&path).map_err(|err| { + format!( + "failed to read WebSpace head table {}: {err}", + path.display() + ) + })?; + let table: HeadTable = serde_json::from_slice(&bytes) + .map_err(|err| format!("invalid WebSpace head table {}: {err}", path.display()))?; + Ok(table.heads.into_iter().map(normalize_head_record).collect()) + } + + fn save_heads(&self) -> Result<(), String> { + let Some(path) = self.head_table_path() else { + return Err( + "webspace-provider was not initialized with a persistent base_path".to_string(), + ); + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create WebSpace head table directory {}: {err}", + parent.display() + ) + })?; + } + let table = HeadTable { + schema: HEAD_TABLE_SCHEMA.to_string(), + heads: self.heads.clone(), + }; + let bytes = serde_json::to_vec_pretty(&table) + .map_err(|err| format!("failed to serialize WebSpace head table: {err}"))?; + write_json_atomic(&path, &bytes) + } + + fn load_objects(&self) -> Result, String> { + let Some(path) = self.object_table_path() else { + return Ok(Vec::new()); + }; + if !path.exists() { + return Ok(Vec::new()); + } + let bytes = fs::read(&path).map_err(|err| { + format!( + "failed to read WebSpace object table {}: {err}", + path.display() + ) + })?; + let table: ObjectTable = serde_json::from_slice(&bytes) + .map_err(|err| format!("invalid WebSpace object table {}: {err}", path.display()))?; + table + .objects + .into_iter() + .map(normalize_object_record) + .collect() + } + + fn save_objects(&self) -> Result<(), String> { + let Some(path) = self.object_table_path() else { + return Err( + "webspace-provider was not initialized with a persistent base_path".to_string(), + ); + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create WebSpace object table directory {}: {err}", + parent.display() + ) + })?; + } + let table = ObjectTable { + schema: OBJECT_TABLE_SCHEMA.to_string(), + objects: self.objects.clone(), + }; + let bytes = serde_json::to_vec_pretty(&table) + .map_err(|err| format!("failed to serialize WebSpace object table: {err}"))?; + write_json_atomic(&path, &bytes) + } + + fn load_adapters(&self) -> Result, String> { + let Some(path) = self.adapter_table_path() else { + return Ok(Vec::new()); + }; + if !path.exists() { + return Ok(Vec::new()); + } + let bytes = fs::read(&path).map_err(|err| { + format!( + "failed to read WebSpace adapter table {}: {err}", + path.display() + ) + })?; + let table: AdapterTable = serde_json::from_slice(&bytes) + .map_err(|err| format!("invalid WebSpace adapter table {}: {err}", path.display()))?; + table + .adapters + .into_iter() + .map(normalize_adapter_record) + .collect() + } + + fn save_adapters(&self) -> Result<(), String> { + let Some(path) = self.adapter_table_path() else { + return Err( + "webspace-provider was not initialized with a persistent base_path".to_string(), + ); + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| { + format!( + "failed to create WebSpace adapter table directory {}: {err}", + parent.display() + ) + })?; + } + let table = AdapterTable { + schema: ADAPTER_TABLE_SCHEMA.to_string(), + adapters: self.adapters.clone(), + }; + let bytes = serde_json::to_vec_pretty(&table) + .map_err(|err| format!("failed to serialize WebSpace adapter table: {err}"))?; + write_json_atomic(&path, &bytes) + } + + fn upsert_head_for_handle( + &mut self, + handle: &WebSpaceHandle, + status: &str, + dirty: bool, + ) -> Result { + self.upsert_lifecycle_head_for_handle(handle, status, dirty, false, false) + } + + fn upsert_lifecycle_head_for_handle( + &mut self, + handle: &WebSpaceHandle, + status: &str, + dirty: bool, + refresh_cache: bool, + mark_synced: bool, + ) -> Result { + let now = now_unix_secs(); + let existing = self + .heads + .iter() + .find(|head| head.handle_uri == handle.handle_uri) + .cloned(); + let created_at = existing.as_ref().map(|head| head.created_at).unwrap_or(now); + let last_cached_at = if handle.cache_policy == "none" { + None + } else if refresh_cache { + Some(now) + } else { + existing + .as_ref() + .and_then(|head| head.last_cached_at) + .or(Some(now)) + }; + let last_synced_at = if handle.sync_policy == "none" { + None + } else if mark_synced { + Some(now) + } else { + existing.as_ref().and_then(|head| head.last_synced_at) + }; + let dirty = if mark_synced { false } else { dirty }; + let head = WebSpaceHead { + schema: HEAD_RECORD_SCHEMA.to_string(), + handle_uri: handle.handle_uri.clone(), + target_uri: handle.target_uri.clone(), + moniker: handle.moniker.clone(), + resolver: handle.resolver.clone(), + object_id: object_id_for(handle), + head_id: head_id_for(&handle.handle_uri), + revision: revision_for(handle, now), + readonly: handle.readonly, + access_policy: handle.access_policy.clone(), + cache_policy: handle.cache_policy.clone(), + sync_policy: handle.sync_policy.clone(), + cache_state: cache_state_for(&handle.cache_policy, last_cached_at), + sync_state: sync_state_for(&handle.sync_policy, dirty, last_synced_at), + forked_from: handle.forked_from.clone(), + created_at, + updated_at: now, + last_cached_at, + last_synced_at, + dirty, + status: status.to_string(), + }; + if self.head_table_path().is_some() { + self.heads + .retain(|existing| existing.handle_uri != head.handle_uri); + self.heads.push(head.clone()); + self.heads + .sort_by(|left, right| left.handle_uri.cmp(&right.handle_uri)); + self.save_heads()?; + } + Ok(head) + } + + fn refresh_handle( + &mut self, + path: String, + entries: Option>, + ) -> Result { + let handle = handle_from_resolved_path(resolve_path(self, &rooted_webspace_path(&path))?)?; + let indexed_entries = if let Some(entries) = entries { + if handle.moniker == BUILTIN_MONIKER { + return Err( + "built-in Elastos WebSpace does not accept external resolver indexes" + .to_string(), + ); + } + let mount_root = format!("localhost://WebSpaces/{}", handle.moniker); + if handle.handle_uri != mount_root { + return Err(format!( + "resolver index refresh must target the mounted WebSpace root: {mount_root}" + )); + } + let refreshed = self.replace_index(&handle.moniker, entries)?; + Some(refreshed) + } else { + None + }; + let head = self.upsert_lifecycle_head_for_handle( + &handle, + "resolver_refreshed", + false, + true, + false, + )?; + Ok(serde_json::json!({ + "schema": "elastos.webspace.refresh-receipt/v1", + "action": "refreshed", + "handle_uri": handle.handle_uri, + "head": head, + "index_entry_count": indexed_entries.as_ref().map(|entries| entries.len()), + "entries": indexed_entries, + "byte_materialized": false, + "note": "Resolver metadata was refreshed. Remote bytes still require a resolver/cache worker." + })) + } + + fn cache_handle( + &mut self, + path: String, + content: Option>, + mime: Option, + source_receipt: Option, + ) -> Result { + let handle = handle_from_resolved_path(resolve_path(self, &rooted_webspace_path(&path))?)?; + if let Some(content) = content { + return self.cache_content_handle(handle, content, mime, source_receipt); + } + let dirty = self + .heads + .iter() + .find(|head| head.handle_uri == handle.handle_uri) + .map(|head| head.dirty) + .unwrap_or(false); + let head = + self.upsert_lifecycle_head_for_handle(&handle, "metadata_cached", dirty, true, false)?; + Ok(serde_json::json!({ + "schema": "elastos.webspace.cache-receipt/v1", + "action": "metadata_cached", + "handle_uri": handle.handle_uri, + "head": head, + "content_cached": false, + "note": "Metadata cache was refreshed. Content bytes remain resolver-owned." + })) + } + + fn cache_content_handle( + &mut self, + handle: WebSpaceHandle, + content: Vec, + mime: Option, + source_receipt: Option, + ) -> Result { + if handle.traversable { + return Err(format!( + "WebSpace content cache requires a file handle, not directory: {}", + handle.handle_uri + )); + } + if handle.kind == "metadata" { + return Err("WebSpace metadata handles cannot be byte-cached".to_string()); + } + let record = self + .mount_by_moniker(&handle.moniker) + .cloned() + .ok_or_else(|| format!("unknown WebSpace moniker: {}", handle.moniker))?; + if record.moniker == BUILTIN_MONIKER { + return Err( + "built-in Elastos WebSpace content is resolved by Runtime content providers, not cached here" + .to_string(), + ); + } + let parts = handle_index_parts(&handle); + if parts.is_empty() { + return Err("WebSpace content cache requires a child object path".to_string()); + } + if parts.iter().any(|part| part == "_meta.json") { + return Err("WebSpace metadata files cannot be byte-cached".to_string()); + } + let refs = parts.iter().map(String::as_str).collect::>(); + if let Some(object) = self.exact_object(&record.moniker, &refs) { + if object.kind == "directory" { + return Err(format!( + "cannot byte-cache over WebSpace directory: {}", + object.path + )); + } + } + if let Some(entry) = self.exact_index_entry(&record.moniker, &refs) { + if entry.kind == "directory" { + return Err(format!( + "cannot byte-cache over indexed WebSpace directory: {}", + entry.path + )); + } + } + let now = now_unix_secs(); + let existing = self.exact_object(&record.moniker, &refs); + let object = WebSpaceObject { + schema: OBJECT_RECORD_SCHEMA.to_string(), + moniker: record.moniker.clone(), + path: normalized_index_path(&refs)?, + name: parts.last().cloned().unwrap_or_default(), + kind: "file".to_string(), + target_uri: handle + .target_uri + .clone() + .or_else(|| Some(append_target_uri(&record.target_uri, &refs))), + mime: mime + .map(|mime| mime.trim().to_string()) + .filter(|mime| !mime.is_empty()) + .unwrap_or_else(|| "application/octet-stream".to_string()), + content, + created_at: existing + .as_ref() + .map(|object| object.created_at) + .unwrap_or(now), + updated_at: now, + revision: String::new(), + dirty: false, + }; + let object = self.upsert_object(object)?; + let cached = materialized_object_handle(&record, &object); + let mut head = self.upsert_lifecycle_head_for_handle( + &cached, + "materialized_cached", + false, + true, + false, + )?; + head.cache_state = "content_cached".to_string(); + self.heads + .retain(|existing| existing.handle_uri != head.handle_uri); + self.heads.push(head.clone()); + self.heads + .sort_by(|left, right| left.handle_uri.cmp(&right.handle_uri)); + self.save_heads()?; + Ok(serde_json::json!({ + "schema": "elastos.webspace.cache-receipt/v1", + "action": "content_cached", + "handle_uri": cached.handle_uri, + "object": cached, + "head": head, + "content_cached": true, + "dirty": false, + "source_receipt": source_receipt, + "note": "Resolver bytes were cached as clean provider-owned local content. This does not grant mutable sync authority." + })) + } + + fn sync_handle(&mut self, path: String) -> Result { + let handle = handle_from_resolved_path(resolve_path(self, &rooted_webspace_path(&path))?)?; + let head = + self.upsert_lifecycle_head_for_handle(&handle, "metadata_synced", false, true, true)?; + Ok(serde_json::json!({ + "schema": "elastos.webspace.sync-receipt/v1", + "action": "metadata_synced", + "handle_uri": handle.handle_uri, + "head": head, + "content_synced": false, + "note": "Provider-owned metadata/fork head is synced. Content-byte sync requires a resolver/sync worker." + })) + } + + fn mutable_mount_for_path(&self, path: &str) -> Result<(WebSpaceMount, Vec), String> { + let rooted = rooted_webspace_path(path); + let (root, rest) = parse_localhost_uri(&rooted) + .or_else(|| parse_localhost_path(&rooted)) + .ok_or_else(|| format!("invalid WebSpace path: {path}"))?; + if root != "WebSpaces" { + return Err(format!( + "WebSpace path must be under localhost://WebSpaces: {path}" + )); + } + let raw_parts = rest + .trim_matches('/') + .split('/') + .filter(|part| !part.is_empty()) + .collect::>(); + let Some(moniker) = raw_parts.first().map(|part| normalize_moniker(part)) else { + return Err("WebSpace mutation requires a mounted WebSpace moniker".to_string()); + }; + if moniker == BUILTIN_MONIKER { + return Err("built-in Elastos WebSpace is resolver-owned and read-only".to_string()); + } + let record = self + .mount_by_moniker(&moniker) + .cloned() + .ok_or_else(|| format!("unknown WebSpace moniker: {moniker}"))?; + if record.readonly { + return Err(format!( + "WebSpace {} is resolver-owned/read-only; fork or mount it as mutable first", + record.moniker + )); + } + let object_parts = raw_parts + .iter() + .skip(1) + .map(|part| part.trim_matches('/')) + .filter(|part| !part.is_empty()) + .collect::>(); + if object_parts.is_empty() { + return Err("WebSpace mutation requires a child object path".to_string()); + } + if object_parts.iter().any(|part| *part == "_meta.json") { + return Err( + "WebSpace metadata files are provider-owned and cannot be mutated".to_string(), + ); + } + let normalized = normalized_index_parts(&object_parts.join("/"))?; + Ok((record, normalized)) + } + + fn directory_exists_for_parts( + &self, + record: &WebSpaceMount, + parts: &[String], + ) -> Result { + if parts.is_empty() { + return Ok(true); + } + let refs = parts.iter().map(String::as_str).collect::>(); + if let Some(object) = self.exact_object(&record.moniker, &refs) { + return Ok(object.kind == "directory"); + } + if let Some(entry) = self.exact_index_entry(&record.moniker, &refs) { + return Ok(entry.kind == "directory"); + } + Ok(self.has_object_children(&record.moniker, &refs) + || self.has_index_children(&record.moniker, &refs)) + } + + fn ensure_parent_directory( + &self, + record: &WebSpaceMount, + parts: &[String], + ) -> Result<(), String> { + if parts.is_empty() { + return Ok(()); + } + let refs = parts.iter().map(String::as_str).collect::>(); + if let Some(object) = self.exact_object(&record.moniker, &refs) { + if object.kind == "directory" { + return Ok(()); + } + return Err(format!( + "WebSpace parent is a materialized file, not a directory: {}", + parts.join("/") + )); + } + if let Some(entry) = self.exact_index_entry(&record.moniker, &refs) { + if entry.kind == "directory" { + return Ok(()); + } + return Err(format!( + "WebSpace parent is an indexed file, not a directory: {}", + parts.join("/") + )); + } + if self.has_object_children(&record.moniker, &refs) + || self.has_index_children(&record.moniker, &refs) + { + return Ok(()); + } + Err(format!( + "WebSpace parent directory does not exist locally or in the resolver index: {}", + parts.join("/") + )) + } + + fn ensure_parent_objects( + &mut self, + record: &WebSpaceMount, + parent_parts: &[String], + ) -> Result<(), String> { + for depth in 1..=parent_parts.len() { + let current = parent_parts[..depth].to_vec(); + if self.directory_exists_for_parts(record, ¤t)? { + continue; + } + let refs = current.iter().map(String::as_str).collect::>(); + let now = now_unix_secs(); + let object = WebSpaceObject { + schema: OBJECT_RECORD_SCHEMA.to_string(), + moniker: record.moniker.clone(), + path: normalized_index_path(&refs)?, + name: current.last().cloned().unwrap_or_default(), + kind: "directory".to_string(), + target_uri: Some(append_target_uri(&record.target_uri, &refs)), + mime: "inode/directory".to_string(), + content: Vec::new(), + created_at: now, + updated_at: now, + revision: String::new(), + dirty: true, + }; + self.upsert_object(object)?; + } + Ok(()) + } + + fn write_handle( + &mut self, + path: String, + content: Vec, + append: bool, + ) -> Result { + let (record, parts) = self.mutable_mount_for_path(&path)?; + let parent_parts = parts[..parts.len().saturating_sub(1)].to_vec(); + self.ensure_parent_directory(&record, &parent_parts)?; + let refs = parts.iter().map(String::as_str).collect::>(); + if let Some(object) = self.exact_object(&record.moniker, &refs) { + if object.kind == "directory" { + return Err(format!( + "cannot write bytes over WebSpace directory: {}", + object.path + )); + } + } + if let Some(entry) = self.exact_index_entry(&record.moniker, &refs) { + if entry.kind == "directory" { + return Err(format!( + "cannot write bytes over indexed WebSpace directory: {}", + entry.path + )); + } + } + let existing = self.exact_object(&record.moniker, &refs); + let now = now_unix_secs(); + let mut bytes = if append { + existing + .as_ref() + .map(|object| object.content.clone()) + .unwrap_or_default() + } else { + Vec::new() + }; + bytes.extend(content); + let object = WebSpaceObject { + schema: OBJECT_RECORD_SCHEMA.to_string(), + moniker: record.moniker.clone(), + path: normalized_index_path(&refs)?, + name: parts.last().cloned().unwrap_or_default(), + kind: "file".to_string(), + target_uri: Some(append_target_uri(&record.target_uri, &refs)), + mime: "application/octet-stream".to_string(), + content: bytes, + created_at: existing + .as_ref() + .map(|object| object.created_at) + .unwrap_or(now), + updated_at: now, + revision: String::new(), + dirty: true, + }; + let object = self.upsert_object(object)?; + let handle = materialized_object_handle(&record, &object); + let head = self.upsert_head_for_handle(&handle, "materialized_local", true)?; + Ok(serde_json::json!({ + "schema": "elastos.webspace.write-receipt/v1", + "action": if append { "appended" } else { "written" }, + "handle_uri": handle.handle_uri, + "object": handle, + "head": head, + "byte_materialized": true, + })) + } + + fn mkdir_handle(&mut self, path: String, parents: bool) -> Result { + let (record, parts) = self.mutable_mount_for_path(&path)?; + let parent_parts = parts[..parts.len().saturating_sub(1)].to_vec(); + if parents { + self.ensure_parent_objects(&record, &parent_parts)?; + } else { + self.ensure_parent_directory(&record, &parent_parts)?; + } + let refs = parts.iter().map(String::as_str).collect::>(); + if let Some(object) = self.exact_object(&record.moniker, &refs) { + if object.kind != "directory" { + return Err(format!( + "WebSpace object already exists as a file: {}", + object.path + )); + } + let handle = materialized_object_handle(&record, &object); + return Ok(serde_json::json!({ + "schema": "elastos.webspace.mkdir-receipt/v1", + "action": "exists", + "handle_uri": handle.handle_uri, + "object": handle, + })); + } + if let Some(entry) = self.exact_index_entry(&record.moniker, &refs) { + if entry.kind != "directory" { + return Err(format!( + "WebSpace indexed object already exists as a file: {}", + entry.path + )); + } + } + let now = now_unix_secs(); + let object = WebSpaceObject { + schema: OBJECT_RECORD_SCHEMA.to_string(), + moniker: record.moniker.clone(), + path: normalized_index_path(&refs)?, + name: parts.last().cloned().unwrap_or_default(), + kind: "directory".to_string(), + target_uri: Some(append_target_uri(&record.target_uri, &refs)), + mime: "inode/directory".to_string(), + content: Vec::new(), + created_at: now, + updated_at: now, + revision: String::new(), + dirty: true, + }; + let object = self.upsert_object(object)?; + let handle = materialized_object_handle(&record, &object); + let head = self.upsert_head_for_handle(&handle, "materialized_directory", true)?; + Ok(serde_json::json!({ + "schema": "elastos.webspace.mkdir-receipt/v1", + "action": "created", + "handle_uri": handle.handle_uri, + "object": handle, + "head": head, + })) + } + + fn delete_handle( + &mut self, + path: String, + recursive: bool, + ) -> Result { + let (record, parts) = self.mutable_mount_for_path(&path)?; + let refs = parts.iter().map(String::as_str).collect::>(); + let handle = self + .exact_object(&record.moniker, &refs) + .map(|object| materialized_object_handle(&record, &object)) + .unwrap_or_else(|| materialized_virtual_folder_handle(&record, &refs)); + let removed = self.remove_objects(&record.moniker, &parts, recursive)?; + let head = self.upsert_head_for_handle(&handle, "materialized_deleted", true)?; + Ok(serde_json::json!({ + "schema": "elastos.webspace.delete-receipt/v1", + "action": "deleted", + "handle_uri": handle.handle_uri, + "removed_count": removed.len(), + "removed": removed, + "head": head, + })) + } + + fn health_report(&self, moniker: Option) -> Result { + let handles = known_mounts(self); + let filtered = if let Some(moniker) = moniker { + let moniker = normalize_moniker(&moniker); + let matched = handles + .into_iter() + .filter(|handle| handle.moniker == moniker) + .collect::>(); + if matched.is_empty() { + return Err(format!("unknown WebSpace moniker: {moniker}")); + } + matched + } else { + handles + }; + let included_monikers = filtered + .iter() + .map(|handle| handle.moniker.as_str()) + .collect::>(); + let includes_moniker = |moniker: &str| { + included_monikers + .iter() + .any(|candidate| *candidate == moniker) + }; + let mounts = filtered + .iter() + .map(|handle| self.health_for_handle(handle)) + .collect::>(); + let index_entry_count = self + .index_entries + .iter() + .filter(|entry| includes_moniker(&entry.moniker)) + .count(); + let head_count = self + .heads + .iter() + .filter(|head| includes_moniker(&head.moniker)) + .count(); + let dirty_head_count = self + .heads + .iter() + .filter(|head| includes_moniker(&head.moniker) && head.dirty) + .count(); + let object_count = self + .objects + .iter() + .filter(|object| includes_moniker(&object.moniker)) + .count(); + let user_mount_count = self + .mounts + .iter() + .filter(|mount| includes_moniker(&mount.moniker)) + .count(); + let live_adapter_count = mounts + .iter() + .filter(|mount| mount["live_adapter"].as_bool().unwrap_or(false)) + .count(); + let configured_adapter_count = self.adapters.len(); + let connected_adapter_count = self + .adapters + .iter() + .filter(|adapter| adapter.state == "connected") + .count(); + let checked_adapter_count = self + .adapters + .iter() + .filter(|adapter| adapter.last_checked_at.is_some()) + .count(); + let state = if mounts.iter().any(|mount| { + matches!( + mount["state"].as_str(), + Some("dirty") | Some("mounted_no_index") + ) + }) { + "attention" + } else { + "metadata_ready" + }; + Ok(serde_json::json!({ + "schema": "elastos.webspace.health/v1", + "provider": PROVIDER_ID, + "state": state, + "persistent": self.mount_table_path().is_some(), + "mount_count": mounts.len(), + "user_mount_count": user_mount_count, + "index_entry_count": index_entry_count, + "head_count": head_count, + "dirty_head_count": dirty_head_count, + "object_count": object_count, + "live_adapter_count": live_adapter_count, + "configured_adapter_count": configured_adapter_count, + "connected_adapter_count": connected_adapter_count, + "checked_adapter_count": checked_adapter_count, + "adapters": self.adapter_summary_table(), + "mounts": mounts, + "note": "Health reports resolver metadata readiness, dirty heads, registered adapter state, and safe adapter liveness checks. Remote byte availability still requires connected resolver/cache workers." + })) + } + + fn health_for_handle(&self, handle: &WebSpaceHandle) -> serde_json::Value { + let index_entry_count = self + .index_entries + .iter() + .filter(|entry| entry.moniker == handle.moniker) + .count(); + let head_count = self + .heads + .iter() + .filter(|head| head.moniker == handle.moniker) + .count(); + let dirty_head_count = self + .heads + .iter() + .filter(|head| head.moniker == handle.moniker && head.dirty) + .count(); + let object_count = self + .objects + .iter() + .filter(|object| object.moniker == handle.moniker) + .count(); + let (live_adapter, adapter_state, adapter) = self.adapter_state_for(&handle.resolver); + let adapter_next_step = adapter_next_step( + &handle.resolver, + &adapter_state, + index_entry_count, + live_adapter, + ); + let state = if dirty_head_count > 0 { + "dirty" + } else if live_adapter || index_entry_count > 0 || head_count > 0 || object_count > 0 { + "metadata_ready" + } else { + "mounted_no_index" + }; + serde_json::json!({ + "schema": "elastos.webspace.resolver-health/v1", + "moniker": handle.moniker, + "handle_uri": handle.handle_uri, + "target_uri": handle.target_uri, + "resolver": handle.resolver, + "resolver_state": handle.resolver_state, + "state": state, + "live_adapter": live_adapter, + "adapter_state": adapter_state, + "adapter": adapter.as_ref().map(adapter_public_summary), + "readonly": handle.readonly, + "access_policy": handle.access_policy, + "cache_policy": handle.cache_policy, + "sync_policy": handle.sync_policy, + "index_entry_count": index_entry_count, + "head_count": head_count, + "dirty_head_count": dirty_head_count, + "object_count": object_count, + "cache_state": handle.cache_state, + "sync_state": handle.sync_state, + "next_step": adapter_next_step + }) + } + + fn user_mounts(&self) -> Vec { + self.mounts.clone() + } + + fn adapter_by_resolver(&self, resolver: &str) -> Option<&WebSpaceAdapter> { + let resolver = normalize_resolver_id(resolver); + self.adapters + .iter() + .find(|adapter| adapter.resolver == resolver) + } + + fn adapter_state_for(&self, resolver: &str) -> (bool, String, Option) { + let resolver = normalize_resolver_id(resolver); + if resolver == "builtin" { + return (true, "builtin".to_string(), None); + } + let Some(adapter) = self.adapter_by_resolver(&resolver).cloned() else { + return (false, "not_registered".to_string(), None); + }; + let live = adapter.state == "connected"; + (live, adapter.state.clone(), Some(adapter)) + } + + fn adapter_summary_table(&self) -> serde_json::Value { + serde_json::json!({ + "schema": ADAPTER_TABLE_SCHEMA, + "builtin": builtin_adapter_summary(), + "adapters": self.adapters.iter().map(adapter_public_summary).collect::>(), + "configured_adapter_count": self.adapters.len(), + "connected_adapter_count": self.adapters.iter().filter(|adapter| adapter.state == "connected").count(), + "checked_adapter_count": self.adapters.iter().filter(|adapter| adapter.last_checked_at.is_some()).count(), + "note": "Registered adapters describe resolver availability, liveness receipts, and operator policy. They do not expose credentials and do not by themselves grant remote byte access." + }) + } + + fn upsert_adapter( + &mut self, + resolver: String, + label: Option, + endpoint_uri: Option, + provider: Option, + state: Option, + capabilities: Vec, + readonly_default: Option, + description: Option, + ) -> Result { + let resolver = normalize_resolver_id(&resolver); + let now = now_unix_secs(); + let existing = self + .adapters + .iter() + .find(|adapter| adapter.resolver == resolver) + .cloned(); + let created_at = existing + .as_ref() + .map(|adapter| adapter.created_at) + .unwrap_or(now); + let adapter = normalize_adapter_record(WebSpaceAdapter { + schema: ADAPTER_RECORD_SCHEMA.to_string(), + resolver, + label: label + .or_else(|| existing.as_ref().map(|adapter| adapter.label.clone())) + .unwrap_or_default(), + endpoint_uri: endpoint_uri.or_else(|| { + existing + .as_ref() + .and_then(|adapter| adapter.endpoint_uri.clone()) + }), + provider: provider.or_else(|| { + existing + .as_ref() + .and_then(|adapter| adapter.provider.clone()) + }), + state: state + .or_else(|| existing.as_ref().map(|adapter| adapter.state.clone())) + .unwrap_or_else(default_adapter_state), + capabilities: if capabilities.is_empty() { + existing + .as_ref() + .map(|adapter| adapter.capabilities.clone()) + .unwrap_or_default() + } else { + capabilities + }, + readonly_default: readonly_default + .or_else(|| existing.as_ref().map(|adapter| adapter.readonly_default)) + .unwrap_or(true), + description: description + .or_else(|| existing.as_ref().map(|adapter| adapter.description.clone())) + .unwrap_or_default(), + created_at, + updated_at: now, + last_checked_at: existing + .as_ref() + .and_then(|adapter| adapter.last_checked_at), + last_check_result: existing + .as_ref() + .and_then(|adapter| adapter.last_check_result.clone()), + last_check_error_code: existing + .as_ref() + .and_then(|adapter| adapter.last_check_error_code.clone()), + })?; + self.adapters + .retain(|existing| existing.resolver != adapter.resolver); + self.adapters.push(adapter.clone()); + self.adapters + .sort_by(|left, right| left.resolver.cmp(&right.resolver)); + self.save_adapters()?; + Ok(adapter) + } + + fn check_adapter( + &mut self, + resolver: String, + result: Option, + state: Option, + error_code: Option, + capabilities: Vec, + ) -> Result { + let resolver = normalize_resolver_id(&resolver); + if resolver == "builtin" { + return Err("built-in WebSpace adapter health is provider-owned".to_string()); + } + let index = self + .adapters + .iter() + .position(|adapter| adapter.resolver == resolver) + .ok_or_else(|| format!("unknown WebSpace adapter resolver: {resolver}"))?; + let now = now_unix_secs(); + let previous = adapter_public_summary(&self.adapters[index]); + let mut adapter = self.adapters[index].clone(); + let result = normalize_adapter_check_result( + result + .as_deref() + .unwrap_or_else(|| default_check_result_for_state(&adapter.state)), + )?; + let next_state = match state { + Some(state) => normalize_adapter_state(&state)?, + None => adapter_state_for_check_result(&result, &adapter.state).to_string(), + }; + let next_error_code = if result == "failed" { + normalize_optional_error_code(error_code)? + .or_else(|| Some("adapter_unavailable".to_string())) + } else { + None + }; + adapter.state = next_state; + if !capabilities.is_empty() { + adapter.capabilities = normalize_adapter_capabilities(capabilities); + } + adapter.last_checked_at = Some(now); + adapter.last_check_result = Some(result); + adapter.last_check_error_code = next_error_code; + adapter.updated_at = now; + let adapter = normalize_adapter_record(adapter)?; + self.adapters[index] = adapter.clone(); + self.adapters + .sort_by(|left, right| left.resolver.cmp(&right.resolver)); + self.save_adapters()?; + Ok(serde_json::json!({ + "schema": "elastos.webspace.adapter-health-receipt/v1", + "action": "checked", + "previous": previous, + "adapter": adapter_public_summary(&adapter), + "byte_traversal_enabled": false, + "note": "Adapter health is a safe resolver-readiness receipt. It does not expose credentials and does not by itself grant remote byte access." + })) + } + + fn unregister_adapter(&mut self, resolver: &str) -> Result { + let resolver = normalize_resolver_id(resolver); + if resolver == "builtin" { + return Err("built-in WebSpace adapter cannot be unregistered".to_string()); + } + let index = self + .adapters + .iter() + .position(|adapter| adapter.resolver == resolver) + .ok_or_else(|| format!("unknown WebSpace adapter resolver: {resolver}"))?; + let adapter = self.adapters.remove(index); + self.save_adapters()?; + Ok(adapter) + } + + fn mount_by_moniker(&self, moniker: &str) -> Option<&WebSpaceMount> { + self.mounts.iter().find(|mount| mount.moniker == moniker) + } + + fn replace_index( + &mut self, + moniker: &str, + entries: Vec, + ) -> Result, String> { + let record = self + .mount_by_moniker(moniker) + .cloned() + .ok_or_else(|| format!("unknown WebSpace moniker: {moniker}"))?; + let now = now_unix_secs(); + let mut normalized = entries + .into_iter() + .map(|entry| normalize_index_input(&record, entry, now)) + .collect::, _>>()?; + normalized.sort_by(|left, right| left.path.cmp(&right.path)); + normalized.dedup_by(|left, right| left.path == right.path); + self.index_entries + .retain(|entry| entry.moniker != record.moniker); + self.index_entries.extend(normalized.clone()); + self.index_entries.sort_by(|left, right| { + left.moniker + .cmp(&right.moniker) + .then_with(|| left.path.cmp(&right.path)) + }); + self.save_indexes()?; + Ok(normalized) + } + + fn exact_index_entry(&self, moniker: &str, parts: &[&str]) -> Option { + let path = normalized_index_path(parts).ok()?; + self.index_entries + .iter() + .find(|entry| entry.moniker == moniker && entry.path == path) + .cloned() + } + + fn has_index_children(&self, moniker: &str, parts: &[&str]) -> bool { + immediate_index_children(&self.index_entries, moniker, parts) + .next() + .is_some() + } + + fn exact_object(&self, moniker: &str, parts: &[&str]) -> Option { + let path = normalized_index_path(parts).ok()?; + self.objects + .iter() + .find(|object| object.moniker == moniker && object.path == path) + .cloned() + } + + fn object_for_handle(&self, handle: &WebSpaceHandle) -> Option { + let parts = handle_index_parts(handle); + let refs = parts.iter().map(String::as_str).collect::>(); + self.exact_object(&handle.moniker, &refs) + } + + fn has_object_children(&self, moniker: &str, parts: &[&str]) -> bool { + immediate_object_children(&self.objects, moniker, parts) + .next() + .is_some() + } + + fn upsert_object(&mut self, object: WebSpaceObject) -> Result { + let object = normalize_object_record(object)?; + self.objects.retain(|existing| { + !(existing.moniker == object.moniker && existing.path == object.path) + }); + self.objects.push(object.clone()); + self.objects.sort_by(|left, right| { + left.moniker + .cmp(&right.moniker) + .then_with(|| left.path.cmp(&right.path)) + }); + self.save_objects()?; + Ok(object) + } + + fn remove_objects( + &mut self, + moniker: &str, + parts: &[String], + recursive: bool, + ) -> Result, String> { + let refs = parts.iter().map(String::as_str).collect::>(); + let path = normalized_index_path(&refs)?; + let child_prefix = format!("{path}/"); + let has_children = self + .objects + .iter() + .any(|object| object.moniker == moniker && object.path.starts_with(&child_prefix)); + if has_children && !recursive { + return Err(format!( + "WebSpace directory is not empty; retry delete with recursive=true: {path}" + )); + } + let mut removed = Vec::new(); + self.objects.retain(|object| { + let matched = object.moniker == moniker + && (object.path == path || object.path.starts_with(&child_prefix)); + if matched { + removed.push(object.clone()); + false + } else { + true + } + }); + if removed.is_empty() { + return Err(format!( + "WebSpace object is resolver-owned or does not exist locally: {path}" + )); + } + self.save_objects()?; + Ok(removed) + } + + fn upsert_mount( + &mut self, + moniker: String, + target_uri: String, + namespace_uri: Option, + resolver: Option, + description: Option, + readonly: Option, + cache_policy: Option, + sync_policy: Option, + access_policy: Option, + ) -> Result { + self.upsert_mount_with_fork( + moniker, + target_uri, + namespace_uri, + resolver, + description, + readonly, + cache_policy, + sync_policy, + access_policy, + None, + ) + } + + #[allow(clippy::too_many_arguments)] + fn upsert_mount_with_fork( + &mut self, + moniker: String, + target_uri: String, + namespace_uri: Option, + resolver: Option, + description: Option, + readonly: Option, + cache_policy: Option, + sync_policy: Option, + access_policy: Option, + forked_from: Option, + ) -> Result { + let moniker = moniker.trim().to_string(); + if moniker == BUILTIN_MONIKER { + return Err(format!( + "{BUILTIN_MONIKER} is a built-in WebSpace and cannot be remounted" + )); + } + if !valid_moniker(&moniker) { + return Err("WebSpace moniker must be a non-empty single path segment".to_string()); + } + let target_uri = target_uri.trim().trim_end_matches('/').to_string(); + if !target_uri.contains("://") { + return Err("WebSpace target_uri must be a scheme-qualified URI".to_string()); + } + let now = now_unix_secs(); + let readonly = readonly.unwrap_or(true); + let existing_created_at = self + .mounts + .iter() + .find(|mount| mount.moniker == moniker) + .map(|mount| mount.created_at) + .unwrap_or(now); + let record = normalize_mount_record(WebSpaceMount { + schema: MOUNT_RECORD_SCHEMA.to_string(), + moniker, + namespace_uri: namespace_uri.or_else(|| infer_namespace_uri(&target_uri)), + target_uri, + resolver: resolver.unwrap_or_else(default_external_resolver), + readonly, + access_policy: normalized_access_policy(access_policy.as_deref(), readonly), + cache_policy: cache_policy.unwrap_or_else(default_cache_policy), + sync_policy: sync_policy.unwrap_or_else(default_sync_policy), + description: description.unwrap_or_default(), + forked_from, + created_at: existing_created_at, + updated_at: now, + })?; + self.mounts.retain(|mount| mount.moniker != record.moniker); + self.mounts.push(record.clone()); + self.mounts + .sort_by(|left, right| left.moniker.cmp(&right.moniker)); + self.save_mounts()?; + Ok(record) + } + + #[allow(clippy::too_many_arguments)] + fn fork_mount( + &mut self, + source_uri: String, + moniker: String, + target_uri: Option, + resolver: Option, + description: Option, + readonly: Option, + cache_policy: Option, + sync_policy: Option, + access_policy: Option, + ) -> Result<(WebSpaceMount, WebSpaceHead), String> { + let source_uri = rooted_webspace_path(&source_uri); + let source = handle_from_resolved_path(resolve_path(self, &source_uri)?)?; + let moniker = moniker.trim().to_string(); + if self.mounts.iter().any(|mount| mount.moniker == moniker) || moniker == BUILTIN_MONIKER { + return Err(format!( + "WebSpace fork target moniker already exists: {moniker}" + )); + } + let target_uri = target_uri + .or_else(|| source.target_uri.clone()) + .unwrap_or_else(|| source.handle_uri.clone()); + let resolver = resolver.or_else(|| Some(source.resolver.clone())); + let description = description.or_else(|| { + Some(format!( + "Mutable fork of {}. Bytes are not copied until a resolver/sync worker materializes this head.", + source.handle_uri + )) + }); + let record = self.upsert_mount_with_fork( + moniker.clone(), + target_uri, + source.namespace_uri.clone(), + resolver, + description, + Some(readonly.unwrap_or(false)), + cache_policy.or_else(|| Some(source.cache_policy.clone())), + sync_policy.or_else(|| Some(source.sync_policy.clone())), + access_policy.or_else(|| Some(DEFAULT_MUTABLE_ACCESS_POLICY.to_string())), + Some(source.handle_uri.clone()), + )?; + let handle = mount_handle_from_record(&record); + let head = self.upsert_head_for_handle(&handle, "forked_metadata_only", true)?; + Ok((record, head)) + } + + fn unmount(&mut self, moniker: &str) -> Result { + let moniker = moniker.trim(); + if moniker == BUILTIN_MONIKER { + return Err(format!( + "{BUILTIN_MONIKER} is a built-in WebSpace and cannot be unmounted" + )); + } + let index = self + .mounts + .iter() + .position(|mount| mount.moniker == moniker) + .ok_or_else(|| format!("unknown WebSpace moniker: {moniker}"))?; + let record = self.mounts.remove(index); + self.save_mounts()?; + self.index_entries + .retain(|entry| entry.moniker != record.moniker); + if self.index_table_path().is_some() { + self.save_indexes()?; + } + self.heads.retain(|head| head.moniker != record.moniker); + if self.head_table_path().is_some() { + self.save_heads()?; + } + self.objects + .retain(|object| object.moniker != record.moniker); + if self.object_table_path().is_some() { + self.save_objects()?; + } + Ok(record) + } +} + +fn normalize_mount_record(mut record: WebSpaceMount) -> Result { + record.schema = MOUNT_RECORD_SCHEMA.to_string(); + record.moniker = record.moniker.trim().to_string(); + if record.moniker == BUILTIN_MONIKER { + return Err(format!( + "{BUILTIN_MONIKER} is reserved for the built-in WebSpace" + )); + } + if !valid_moniker(&record.moniker) { + return Err(format!("invalid WebSpace moniker: {}", record.moniker)); + } + record.target_uri = record.target_uri.trim().trim_end_matches('/').to_string(); + if !record.target_uri.contains("://") { + return Err(format!( + "WebSpace {} has invalid target_uri: {}", + record.moniker, record.target_uri + )); + } + if record + .namespace_uri + .as_deref() + .unwrap_or("") + .trim() + .is_empty() + { + record.namespace_uri = infer_namespace_uri(&record.target_uri); + } + record.resolver = normalized_non_empty(&record.resolver, DEFAULT_EXTERNAL_RESOLVER); + record.access_policy = normalized_access_policy(Some(&record.access_policy), record.readonly); + record.cache_policy = normalized_non_empty(&record.cache_policy, DEFAULT_CACHE_POLICY); + record.sync_policy = normalized_non_empty(&record.sync_policy, DEFAULT_SYNC_POLICY); + if record.description.trim().is_empty() { + record.description = format!( + "Mounted WebSpace {} mapped to {}.", + record.moniker, record.target_uri + ); + } else { + record.description = record.description.trim().to_string(); + } + if record.created_at == 0 { + record.created_at = now_unix_secs(); + } + if record.updated_at == 0 { + record.updated_at = record.created_at; + } + Ok(record) +} + +fn normalized_access_policy(value: Option<&str>, readonly: bool) -> String { + let fallback = if readonly { + DEFAULT_READONLY_ACCESS_POLICY + } else { + DEFAULT_MUTABLE_ACCESS_POLICY + }; + let trimmed = value.unwrap_or("").trim(); + if trimmed.is_empty() { + fallback.to_string() + } else { + trimmed.to_string() + } +} + +fn normalized_index_parts(path: &str) -> Result, String> { + let trimmed = path.trim().trim_matches('/'); + if trimmed.is_empty() { + return Err("WebSpace index path must not be empty".to_string()); + } + let mut parts = Vec::new(); + for part in trimmed.split('/').filter(|part| !part.is_empty()) { + let part = part.trim(); + if part == "." || part == ".." || part.contains('\\') || part.contains("://") { + return Err(format!("invalid WebSpace index path segment: {part}")); + } + parts.push(part.to_string()); + } + if parts.is_empty() { + Err("WebSpace index path must not be empty".to_string()) + } else { + Ok(parts) + } +} + +fn normalized_index_path(parts: &[&str]) -> Result { + let joined = parts + .iter() + .map(|part| part.trim_matches('/')) + .filter(|part| !part.is_empty()) + .collect::>() + .join("/"); + normalized_index_parts(&joined).map(|parts| parts.join("/")) +} + +fn normalize_index_kind(kind: &str) -> Result { + match kind.trim().to_ascii_lowercase().as_str() { + "directory" | "folder" => Ok("directory".to_string()), + "file" | "object" => Ok("file".to_string()), + other => Err(format!( + "WebSpace index kind must be directory or file, got {other}" + )), + } +} + +fn normalize_index_input( + mount: &WebSpaceMount, + input: WebSpaceIndexInput, + now: u64, +) -> Result { + let parts = normalized_index_parts(&input.path)?; + let path = parts.join("/"); + let name = parts + .last() + .cloned() + .ok_or_else(|| "WebSpace index path must not be empty".to_string())?; + let kind = normalize_index_kind(&input.kind)?; + let target_uri = input + .target_uri + .map(|target| target.trim().trim_end_matches('/').to_string()) + .filter(|target| !target.is_empty()) + .unwrap_or_else(|| { + append_target_uri( + &mount.target_uri, + &parts.iter().map(String::as_str).collect::>(), + ) + }); + if !target_uri.contains("://") { + return Err(format!( + "WebSpace index target_uri must be scheme-qualified: {target_uri}" + )); + } + normalize_index_record(WebSpaceIndexEntry { + schema: INDEX_ENTRY_SCHEMA.to_string(), + moniker: mount.moniker.clone(), + path, + name, + kind, + target_uri, + resolver: mount.resolver.clone(), + resolver_state: input + .resolver_state + .unwrap_or_else(|| "indexed".to_string()), + readonly: input.readonly.unwrap_or(mount.readonly), + description: input.description.unwrap_or_default(), + updated_at: now, + }) +} + +fn normalize_index_record(mut record: WebSpaceIndexEntry) -> Result { + if !valid_moniker(&record.moniker) || record.moniker == BUILTIN_MONIKER { + return Err(format!( + "invalid WebSpace index moniker: {}", + record.moniker + )); + } + let parts = normalized_index_parts(&record.path)?; + record.schema = INDEX_ENTRY_SCHEMA.to_string(); + record.path = parts.join("/"); + record.name = parts.last().cloned().unwrap_or_else(|| record.path.clone()); + record.kind = normalize_index_kind(&record.kind)?; + record.target_uri = record.target_uri.trim().trim_end_matches('/').to_string(); + if !record.target_uri.contains("://") { + return Err(format!( + "WebSpace index target_uri must be scheme-qualified: {}", + record.target_uri + )); + } + if record.resolver.trim().is_empty() { + record.resolver = default_external_resolver(); + } + if record.resolver_state.trim().is_empty() { + record.resolver_state = "indexed".to_string(); + } + if record.description.trim().is_empty() { + record.description = format!( + "Indexed {} from the {} WebSpace resolver.", + record.kind, record.moniker + ); + } + if record.updated_at == 0 { + record.updated_at = now_unix_secs(); + } + Ok(record) +} + +fn normalize_head_record(mut record: WebSpaceHead) -> WebSpaceHead { + record.schema = HEAD_RECORD_SCHEMA.to_string(); + if record.object_id.trim().is_empty() { + record.object_id = format!( + "object:webspace:{}", + stable_hex(record.target_uri.as_deref().unwrap_or(&record.handle_uri)) + ); + } + if record.head_id.trim().is_empty() { + record.head_id = head_id_for(&record.handle_uri); + } + if record.revision.trim().is_empty() { + record.revision = format!( + "rev:webspace:{}", + stable_hex(&format!("{}:{}", record.handle_uri, record.updated_at)) + ); + } + record.access_policy = normalized_access_policy(Some(&record.access_policy), record.readonly); + record.cache_policy = normalized_non_empty(&record.cache_policy, DEFAULT_CACHE_POLICY); + record.sync_policy = normalized_non_empty(&record.sync_policy, DEFAULT_SYNC_POLICY); + record.cache_state = normalized_non_empty( + &record.cache_state, + &cache_state_for(&record.cache_policy, record.last_cached_at), + ); + record.sync_state = normalized_non_empty( + &record.sync_state, + &sync_state_for(&record.sync_policy, record.dirty, record.last_synced_at), + ); + record.status = normalized_non_empty(&record.status, "metadata_only"); + record +} + +fn normalize_object_record(mut record: WebSpaceObject) -> Result { + if !valid_moniker(&record.moniker) || record.moniker == BUILTIN_MONIKER { + return Err(format!( + "invalid WebSpace object moniker: {}", + record.moniker + )); + } + let parts = normalized_index_parts(&record.path)?; + record.schema = OBJECT_RECORD_SCHEMA.to_string(); + record.path = parts.join("/"); + record.name = parts.last().cloned().unwrap_or_else(|| record.path.clone()); + record.kind = normalize_index_kind(&record.kind)?; + if record.kind == "directory" { + record.content.clear(); + record.mime = "inode/directory".to_string(); + } else if record.mime.trim().is_empty() { + record.mime = "application/octet-stream".to_string(); + } else { + record.mime = record.mime.trim().to_string(); + } + record.target_uri = record + .target_uri + .map(|target_uri| target_uri.trim().trim_end_matches('/').to_string()) + .filter(|target_uri| !target_uri.is_empty()); + if record.created_at == 0 { + record.created_at = now_unix_secs(); + } + if record.updated_at == 0 { + record.updated_at = record.created_at; + } + if record.revision.trim().is_empty() { + record.revision = object_revision(&record); + } + Ok(record) +} + +fn object_revision(object: &WebSpaceObject) -> String { + format!( + "rev:webspace-object:{}", + stable_hex(&format!( + "{}:{}:{}:{}:{}", + object.moniker, + object.path, + object.kind, + object.updated_at, + object.content.len() + )) + ) +} + +fn normalized_non_empty(value: &str, fallback: &str) -> String { + let trimmed = value.trim(); + if trimmed.is_empty() { + fallback.to_string() + } else { + trimmed.to_string() + } +} + +fn write_json_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, bytes).map_err(|err| { + format!( + "failed to write WebSpace mount table {}: {err}", + tmp.display() + ) + })?; + fs::rename(&tmp, path).map_err(|err| { + format!( + "failed to replace WebSpace mount table {} from {}: {err}", + path.display(), + tmp.display() + ) + }) +} + +fn meta_path(handle: &WebSpaceHandle) -> String { + format!("{}/_meta.json", handle.handle_uri.trim_end_matches('/')) +} + +fn known_mounts(state: &ProviderState) -> Vec { + let mut handles = vec![mount_handle( + "Elastos", + Some("elastos://".to_string()), + "Local interpreted handle into the broader elastos:// namespace.", + Some( + "List this handle to discover typed child spaces such as content, peer, did, and ai." + .to_string(), + ), + )]; + handles.extend(state.user_mounts().iter().map(mount_handle_from_record)); + handles +} + +fn normalize_moniker(moniker: &str) -> String { + moniker + .trim() + .trim_matches('/') + .trim_end_matches("://") + .to_string() +} + +fn normalize_resolver_id(resolver: &str) -> String { + resolver.trim().to_ascii_lowercase() +} + +fn valid_resolver_id(resolver: &str) -> bool { + let trimmed = resolver.trim(); + !trimmed.is_empty() + && !trimmed.contains('/') + && !trimmed.contains('\\') + && !trimmed.contains("://") + && !trimmed.chars().any(char::is_control) +} + +fn normalize_adapter_state(state: &str) -> Result { + match state.trim().to_ascii_lowercase().as_str() { + "" => Ok(DEFAULT_ADAPTER_STATE.to_string()), + "configured" | "connected" | "unavailable" | "disabled" => { + Ok(state.trim().to_ascii_lowercase()) + } + other => Err(format!( + "WebSpace adapter state must be configured, connected, unavailable, or disabled; got {other}" + )), + } +} + +fn normalize_adapter_check_result(result: &str) -> Result { + match result.trim().to_ascii_lowercase().as_str() { + "" => Ok("unknown".to_string()), + "ok" | "failed" | "skipped" | "unknown" => Ok(result.trim().to_ascii_lowercase()), + other => Err(format!( + "WebSpace adapter check result must be ok, failed, skipped, or unknown; got {other}" + )), + } +} + +fn default_check_result_for_state(state: &str) -> &'static str { + match state { + "connected" => "ok", + "unavailable" => "failed", + "disabled" => "skipped", + _ => "unknown", + } +} + +fn adapter_state_for_check_result<'a>(result: &str, current_state: &'a str) -> &'a str { + match result { + "ok" => "connected", + "failed" => "unavailable", + "skipped" => "disabled", + _ => current_state, + } +} + +fn normalize_optional_error_code(value: Option) -> Result, String> { + let Some(value) = value else { + return Ok(None); + }; + let value = value.trim().to_ascii_lowercase(); + if value.is_empty() { + return Ok(None); + } + if value.len() > 96 + || !value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')) + { + return Err( + "WebSpace adapter error_code must be a short opaque code, not a credential or message" + .to_string(), + ); + } + Ok(Some(value)) +} + +fn normalize_adapter_capabilities(capabilities: Vec) -> Vec { + let mut capabilities = capabilities + .into_iter() + .map(|capability| capability.trim().to_ascii_lowercase()) + .filter(|capability| !capability.is_empty()) + .filter(|capability| { + capability + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')) + }) + .collect::>(); + if capabilities.is_empty() { + capabilities.push("metadata_index".to_string()); + } + capabilities.sort(); + capabilities.dedup(); + capabilities +} + +fn normalize_optional_uri(value: Option) -> Result, String> { + let Some(value) = value else { + return Ok(None); + }; + let trimmed = value.trim().trim_end_matches('/').to_string(); + if trimmed.is_empty() { + return Ok(None); + } + if !trimmed.contains("://") && !trimmed.starts_with("provider:") { + return Err(format!( + "WebSpace adapter endpoint_uri must be scheme-qualified or provider-qualified: {trimmed}" + )); + } + Ok(Some(trimmed)) +} + +fn normalize_optional_label(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn normalize_optional_provider(value: Option) -> Option { + value + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) +} + +fn normalize_adapter_record(mut record: WebSpaceAdapter) -> Result { + record.schema = ADAPTER_RECORD_SCHEMA.to_string(); + record.resolver = normalize_resolver_id(&record.resolver); + if record.resolver == "builtin" { + return Err( + "built-in WebSpace adapter is provider-owned and cannot be registered".to_string(), + ); + } + if !valid_resolver_id(&record.resolver) { + return Err(format!( + "invalid WebSpace adapter resolver id: {}", + record.resolver + )); + } + record.label = normalize_optional_label(Some(record.label)) + .unwrap_or_else(|| record.resolver.replace('-', " ")); + record.endpoint_uri = normalize_optional_uri(record.endpoint_uri)?; + record.provider = normalize_optional_provider(record.provider); + record.state = normalize_adapter_state(&record.state)?; + record.capabilities = normalize_adapter_capabilities(record.capabilities); + record.last_check_result = match record.last_check_result.take() { + Some(result) => Some(normalize_adapter_check_result(&result)?), + None => None, + }; + record.last_check_error_code = normalize_optional_error_code(record.last_check_error_code)?; + if record.description.trim().is_empty() { + record.description = format!( + "External WebSpace resolver adapter for {}.", + record.resolver + ); + } else { + record.description = record.description.trim().to_string(); + } + if record.created_at == 0 { + record.created_at = now_unix_secs(); + } + if record.updated_at == 0 { + record.updated_at = record.created_at; + } + Ok(record) +} + +fn redact_endpoint_uri(value: Option<&str>) -> Option { + let value = value?.trim(); + if value.is_empty() { + return None; + } + let Some((scheme, rest)) = value.split_once("://") else { + return Some(value.to_string()); + }; + let (authority, suffix) = rest.split_once('/').unwrap_or((rest, "")); + let redacted_authority = authority + .rsplit_once('@') + .map(|(_, host)| format!("redacted@{host}")) + .unwrap_or_else(|| authority.to_string()); + if suffix.is_empty() { + Some(format!("{scheme}://{redacted_authority}")) + } else { + Some(format!("{scheme}://{redacted_authority}/{suffix}")) + } +} + +fn builtin_adapter_summary() -> serde_json::Value { + serde_json::json!({ + "schema": ADAPTER_RECORD_SCHEMA, + "resolver": "builtin", + "label": "Built-in ElastOS resolver", + "state": "connected", + "live": true, + "health": { + "schema": "elastos.webspace.adapter-health/v1", + "status": "healthy", + "last_checked_at": serde_json::Value::Null, + "last_result": "ok", + "stale": false, + "next": "Built-in resolver health is provider-owned." + }, + "capabilities": ["metadata_index", "read_descriptor", "local_runtime"], + "readonly_default": true, + "description": "Provider-owned resolver for localhost://WebSpaces/Elastos typed handles." + }) +} + +fn adapter_public_summary(adapter: &WebSpaceAdapter) -> serde_json::Value { + serde_json::json!({ + "schema": ADAPTER_RECORD_SCHEMA, + "resolver": adapter.resolver.as_str(), + "label": adapter.label.as_str(), + "endpoint_uri": redact_endpoint_uri(adapter.endpoint_uri.as_deref()), + "provider": adapter.provider.as_deref(), + "state": adapter.state.as_str(), + "live": adapter.state == "connected", + "health": adapter_health_summary(adapter), + "capabilities": adapter.capabilities.clone(), + "readonly_default": adapter.readonly_default, + "description": adapter.description.as_str(), + "created_at": adapter.created_at, + "updated_at": adapter.updated_at, + }) +} + +fn adapter_health_summary(adapter: &WebSpaceAdapter) -> serde_json::Value { + let now = now_unix_secs(); + let stale = adapter + .last_checked_at + .map(|checked| now.saturating_sub(checked) > ADAPTER_HEALTH_STALE_AFTER_SECS) + .unwrap_or(false); + let result = adapter.last_check_result.as_deref().unwrap_or("unknown"); + let status = if adapter.state == "disabled" { + "disabled" + } else if adapter.state == "unavailable" || result == "failed" { + "unavailable" + } else if stale { + "stale" + } else if result == "ok" { + "healthy" + } else if adapter.state == "connected" { + "connected_unverified" + } else if adapter.state == "configured" { + "configured_unchecked" + } else { + "unknown" + }; + serde_json::json!({ + "schema": "elastos.webspace.adapter-health/v1", + "status": status, + "last_checked_at": adapter.last_checked_at, + "last_result": adapter.last_check_result.as_deref().unwrap_or("unknown"), + "last_error_code": adapter.last_check_error_code.as_deref(), + "stale": stale, + "stale_after_seconds": ADAPTER_HEALTH_STALE_AFTER_SECS, + "next": adapter_health_next_step(status) + }) +} + +fn adapter_health_next_step(status: &str) -> &'static str { + match status { + "healthy" => "Adapter is recently checked; resolver traversal still depends on the adapter implementing requested capabilities.", + "connected_unverified" => "Adapter is marked connected but has no recorded health check; run check_adapter from the adapter/operator plane.", + "configured_unchecked" => "Adapter is configured but unchecked; start the adapter and record check_adapter before relying on live traversal.", + "stale" => "Adapter health is stale; refresh check_adapter before claiming live traversal.", + "unavailable" => "Adapter reported unavailable; inspect adapter/provider health before refresh/cache/sync.", + "disabled" => "Adapter is disabled by policy.", + _ => "Inspect adapter registration before live traversal.", + } +} + +fn adapter_next_step( + resolver: &str, + adapter_state: &str, + index_entry_count: usize, + live_adapter: bool, +) -> String { + if resolver == "builtin" { + return "Built-in resolver metadata is available.".to_string(); + } + if live_adapter { + return "Resolver adapter is connected; refresh/cache/sync can use its provider surface when the adapter implements those capabilities.".to_string(); + } + match adapter_state { + "configured" => { + "Start or connect the registered resolver adapter, then refresh/cache/sync this WebSpace.".to_string() + } + "unavailable" => { + "Registered resolver adapter is unavailable; inspect adapter health before refreshing this WebSpace.".to_string() + } + "disabled" => { + "Registered resolver adapter is disabled by policy; enable or replace it before live traversal.".to_string() + } + "not_registered" if index_entry_count == 0 => { + "Register the named resolver adapter or run a resolver index/refresh for metadata-only traversal.".to_string() + } + "not_registered" => { + "Indexed metadata is available; register/connect the resolver adapter before live byte traversal.".to_string() + } + _ => "Inspect resolver adapter registration before live byte traversal.".to_string(), + } +} + +fn rooted_webspace_path(target: &str) -> String { + if target.starts_with("localhost://") { + target.to_string() + } else { + format!("localhost://WebSpaces/{}", target.trim_matches('/')) + } } -fn resolve_handle(moniker: &str) -> Result { +fn resolve_handle(state: &ProviderState, moniker: &str) -> Result { let normalized = normalize_moniker(moniker); if normalized.is_empty() { return Err("missing WebSpace moniker".to_string()); } - resolve_handle_segments(std::slice::from_ref(&normalized.as_str())) + resolve_handle_segments(state, std::slice::from_ref(&normalized.as_str())) } fn mount_handle( @@ -183,13 +2842,178 @@ fn mount_handle( namespace_uri, target_uri: None, resolver_state: "mounted".to_string(), + resolver: "builtin".to_string(), + cache_policy: DEFAULT_CACHE_POLICY.to_string(), + sync_policy: DEFAULT_SYNC_POLICY.to_string(), + readonly: true, + access_policy: DEFAULT_READONLY_ACCESS_POLICY.to_string(), kind: "dynamic-webspace".to_string(), traversable: true, + size: 0, + object_id: format!( + "object:webspace:{}", + stable_hex(&format!("localhost://WebSpaces/{}", moniker)) + ), + head_id: head_id_for(&format!("localhost://WebSpaces/{}", moniker)), + cache_state: cache_state_for(DEFAULT_CACHE_POLICY, Some(0)), + sync_state: sync_state_for(DEFAULT_SYNC_POLICY, false, None), description: description.to_string(), + forked_from: None, next_step, } } +fn mount_handle_from_record(record: &WebSpaceMount) -> WebSpaceHandle { + WebSpaceHandle { + moniker: record.moniker.clone(), + handle_uri: format!("localhost://WebSpaces/{}", record.moniker), + namespace_uri: record.namespace_uri.clone(), + target_uri: Some(record.target_uri.clone()), + resolver_state: if record.readonly { + "mounted-readonly".to_string() + } else { + "mounted-mutable".to_string() + }, + resolver: record.resolver.clone(), + cache_policy: record.cache_policy.clone(), + sync_policy: record.sync_policy.clone(), + readonly: record.readonly, + access_policy: record.access_policy.clone(), + kind: "mounted-webspace".to_string(), + traversable: true, + size: 0, + object_id: format!("object:webspace:{}", stable_hex(&record.target_uri)), + head_id: head_id_for(&format!("localhost://WebSpaces/{}", record.moniker)), + cache_state: cache_state_for(&record.cache_policy, Some(record.updated_at)), + sync_state: sync_state_for(&record.sync_policy, false, None), + description: record.description.clone(), + forked_from: record.forked_from.clone(), + next_step: Some( + "This mount resolves local WebSpace handles to its target URI. Actual network traversal requires the named resolver/provider to be available." + .to_string(), + ), + } +} + +fn mounted_child_handle(record: &WebSpaceMount, parts: &[&str]) -> WebSpaceHandle { + let target_uri = append_target_uri(&record.target_uri, parts); + let handle_uri = format!( + "localhost://WebSpaces/{}/{}", + record.moniker, + parts + .iter() + .map(|part| part.trim_matches('/')) + .filter(|part| !part.is_empty()) + .collect::>() + .join("/") + ); + WebSpaceHandle { + moniker: record.moniker.clone(), + handle_uri: handle_uri.clone(), + namespace_uri: record.namespace_uri.clone(), + target_uri: Some(target_uri.clone()), + resolver_state: "mapped-unavailable".to_string(), + resolver: record.resolver.clone(), + cache_policy: record.cache_policy.clone(), + sync_policy: record.sync_policy.clone(), + readonly: record.readonly, + access_policy: record.access_policy.clone(), + kind: "external-object-handle".to_string(), + traversable: false, + size: render_external_descriptor_size(&handle_uri, Some(&target_uri), "external-object-handle"), + object_id: format!("object:webspace:{}", stable_hex(&target_uri)), + head_id: head_id_for(&handle_uri), + cache_state: cache_state_for(&record.cache_policy, Some(record.updated_at)), + sync_state: sync_state_for(&record.sync_policy, false, None), + description: format!( + "Typed handle mapped through the {} WebSpace. Attach resolver '{}' to open or sync live content.", + record.moniker, record.resolver + ), + forked_from: record.forked_from.clone(), + next_step: Some( + "Live resolver invocation and provider streaming are required before this external handle can stream live content." + .to_string(), + ), + } +} + +fn indexed_entry_handle(record: &WebSpaceMount, entry: &WebSpaceIndexEntry) -> WebSpaceHandle { + let handle_uri = format!("localhost://WebSpaces/{}/{}", record.moniker, entry.path); + let traversable = entry.kind == "directory"; + WebSpaceHandle { + moniker: record.moniker.clone(), + handle_uri: handle_uri.clone(), + namespace_uri: record.namespace_uri.clone(), + target_uri: Some(entry.target_uri.clone()), + resolver_state: entry.resolver_state.clone(), + resolver: entry.resolver.clone(), + cache_policy: record.cache_policy.clone(), + sync_policy: record.sync_policy.clone(), + readonly: entry.readonly, + access_policy: normalized_access_policy(None, entry.readonly), + kind: if traversable { + "indexed-directory".to_string() + } else { + "indexed-file".to_string() + }, + traversable, + size: if traversable { + 0 + } else { + render_external_descriptor_size(&handle_uri, Some(&entry.target_uri), "indexed-file") + }, + object_id: format!("object:webspace:{}", stable_hex(&entry.target_uri)), + head_id: head_id_for(&handle_uri), + cache_state: cache_state_for(&record.cache_policy, Some(entry.updated_at)), + sync_state: sync_state_for(&record.sync_policy, false, None), + description: entry.description.clone(), + forked_from: record.forked_from.clone(), + next_step: Some( + "This handle came from a resolver index. Attach provider streaming before opening remote bytes." + .to_string(), + ), + } +} + +fn indexed_virtual_folder_handle(record: &WebSpaceMount, parts: &[&str]) -> WebSpaceHandle { + let target_uri = append_target_uri(&record.target_uri, parts); + let handle_uri = format!( + "localhost://WebSpaces/{}/{}", + record.moniker, + parts + .iter() + .map(|part| part.trim_matches('/')) + .filter(|part| !part.is_empty()) + .collect::>() + .join("/") + ); + WebSpaceHandle { + moniker: record.moniker.clone(), + handle_uri: handle_uri.clone(), + namespace_uri: record.namespace_uri.clone(), + target_uri: Some(target_uri.clone()), + resolver_state: "indexed-virtual".to_string(), + resolver: record.resolver.clone(), + cache_policy: record.cache_policy.clone(), + sync_policy: record.sync_policy.clone(), + readonly: record.readonly, + access_policy: record.access_policy.clone(), + kind: "indexed-directory".to_string(), + traversable: true, + size: 0, + object_id: format!("object:webspace:{}", stable_hex(&target_uri)), + head_id: head_id_for(&handle_uri), + cache_state: cache_state_for(&record.cache_policy, None), + sync_state: sync_state_for(&record.sync_policy, false, None), + description: format!( + "Virtual folder inferred from the {} WebSpace resolver index.", + record.moniker + ), + forked_from: record.forked_from.clone(), + next_step: Some("List this folder to inspect indexed resolver children.".to_string()), + } +} + fn folder_handle( moniker: &str, handle_uri: String, @@ -197,15 +3021,31 @@ fn folder_handle( description: &str, next_step: Option, ) -> WebSpaceHandle { + let object_id = format!( + "object:webspace:{}", + stable_hex(target_uri.as_deref().unwrap_or(&handle_uri)) + ); + let head_id = head_id_for(&handle_uri); WebSpaceHandle { moniker: moniker.to_string(), - handle_uri, + handle_uri: handle_uri.clone(), namespace_uri: Some("elastos://".to_string()), - target_uri, + target_uri: target_uri.clone(), resolver_state: "resolved".to_string(), + resolver: "builtin".to_string(), + cache_policy: DEFAULT_CACHE_POLICY.to_string(), + sync_policy: DEFAULT_SYNC_POLICY.to_string(), + readonly: true, + access_policy: DEFAULT_READONLY_ACCESS_POLICY.to_string(), kind: "folder-handle".to_string(), traversable: true, + size: 0, + object_id, + head_id, + cache_state: cache_state_for(DEFAULT_CACHE_POLICY, Some(0)), + sync_state: sync_state_for(DEFAULT_SYNC_POLICY, false, None), description: description.to_string(), + forked_from: None, next_step, } } @@ -216,15 +3056,28 @@ fn file_handle( target_uri: String, description: &str, ) -> WebSpaceHandle { + let object_id = format!("object:webspace:{}", stable_hex(&target_uri)); + let head_id = head_id_for(&handle_uri); WebSpaceHandle { moniker: moniker.to_string(), - handle_uri, + handle_uri: handle_uri.clone(), namespace_uri: Some("elastos://".to_string()), - target_uri: Some(target_uri), + target_uri: Some(target_uri.clone()), resolver_state: "resolved".to_string(), + resolver: "builtin".to_string(), + cache_policy: DEFAULT_CACHE_POLICY.to_string(), + sync_policy: DEFAULT_SYNC_POLICY.to_string(), + readonly: true, + access_policy: DEFAULT_READONLY_ACCESS_POLICY.to_string(), kind: "file-endpoint".to_string(), traversable: false, + size: render_external_descriptor_size(&handle_uri, Some(&target_uri), "file-endpoint"), + object_id, + head_id, + cache_state: cache_state_for(DEFAULT_CACHE_POLICY, Some(0)), + sync_state: sync_state_for(DEFAULT_SYNC_POLICY, false, None), description: description.to_string(), + forked_from: None, next_step: Some( "Read this handle for the current descriptor view, or inspect _meta.json for structured metadata." .to_string(), @@ -327,7 +3180,10 @@ fn resolve_elastos_handle(parts: &[&str]) -> Result { } } -fn resolve_handle_segments(parts: &[&str]) -> Result { +fn resolve_handle_segments( + state: &ProviderState, + parts: &[&str], +) -> Result { let Some((moniker, rest)) = parts.split_first() else { return Err("missing WebSpace moniker".to_string()); }; @@ -337,11 +3193,28 @@ fn resolve_handle_segments(parts: &[&str]) -> Result { } match normalized.as_str() { "Elastos" => resolve_elastos_handle(rest), - _ => Err(format!("unknown WebSpace moniker: {}", normalized)), + _ => { + let Some(record) = state.mount_by_moniker(&normalized) else { + return Err(format!("unknown WebSpace moniker: {}", normalized)); + }; + if rest.is_empty() { + Ok(mount_handle_from_record(record)) + } else if let Some(object) = state.exact_object(&record.moniker, rest) { + Ok(materialized_object_handle(record, &object)) + } else if let Some(entry) = state.exact_index_entry(&record.moniker, rest) { + Ok(indexed_entry_handle(record, &entry)) + } else if state.has_object_children(&record.moniker, rest) { + Ok(materialized_virtual_folder_handle(record, rest)) + } else if state.has_index_children(&record.moniker, rest) { + Ok(indexed_virtual_folder_handle(record, rest)) + } else { + Ok(mounted_child_handle(record, rest)) + } + } } } -fn resolve_path(path: &str) -> Result { +fn resolve_path(state: &ProviderState, path: &str) -> Result { let trimmed = path.trim(); let (_, rest) = parse_localhost_uri(trimmed) .or_else(|| parse_localhost_path(trimmed)) @@ -361,7 +3234,7 @@ fn resolve_path(path: &str) -> Result { parts.pop(); } - let handle = resolve_handle_segments(&parts)?; + let handle = resolve_handle_segments(state, &parts)?; if wants_meta { Ok(ResolvedPath::Meta { handle }) } else { @@ -380,9 +3253,227 @@ fn handle_from_resolved_path(resolved: ResolvedPath) -> Result, moniker: Option) -> Result { +fn handle_index_parts(handle: &WebSpaceHandle) -> Vec { + let prefix = format!("localhost://WebSpaces/{}", handle.moniker); + handle + .handle_uri + .strip_prefix(&prefix) + .unwrap_or_default() + .trim_matches('/') + .split('/') + .filter(|part| !part.is_empty()) + .map(ToString::to_string) + .collect() +} + +fn immediate_index_children<'a>( + entries: &'a [WebSpaceIndexEntry], + moniker: &'a str, + prefix_parts: &'a [&'a str], +) -> impl Iterator { + entries.iter().filter(move |entry| { + if entry.moniker != moniker { + return false; + } + let Ok(parts) = normalized_index_parts(&entry.path) else { + return false; + }; + parts.len() > prefix_parts.len() + && parts + .iter() + .take(prefix_parts.len()) + .zip(prefix_parts.iter()) + .all(|(left, right)| left == right) + }) +} + +fn immediate_object_children<'a>( + objects: &'a [WebSpaceObject], + moniker: &'a str, + prefix_parts: &'a [&'a str], +) -> impl Iterator { + objects.iter().filter(move |object| { + if object.moniker != moniker { + return false; + } + let Ok(parts) = normalized_index_parts(&object.path) else { + return false; + }; + parts.len() > prefix_parts.len() + && parts + .iter() + .take(prefix_parts.len()) + .zip(prefix_parts.iter()) + .all(|(left, right)| left == right) + }) +} + +fn indexed_child_handles( + state: &ProviderState, + record: &WebSpaceMount, + prefix_parts: &[String], +) -> Vec<(String, WebSpaceHandle)> { + let prefix_refs = prefix_parts.iter().map(String::as_str).collect::>(); + let mut children = BTreeMap::new(); + for entry in immediate_index_children(&state.index_entries, &record.moniker, &prefix_refs) { + let Ok(parts) = normalized_index_parts(&entry.path) else { + continue; + }; + let Some(child_name) = parts.get(prefix_parts.len()).cloned() else { + continue; + }; + if parts.len() == prefix_parts.len() + 1 { + children.insert(child_name, indexed_entry_handle(record, entry)); + } else { + let child_parts = prefix_parts + .iter() + .cloned() + .chain(std::iter::once(child_name.clone())) + .collect::>(); + let child_refs = child_parts.iter().map(String::as_str).collect::>(); + children + .entry(child_name) + .or_insert_with(|| indexed_virtual_folder_handle(record, &child_refs)); + } + } + children.into_iter().collect() +} + +fn materialized_child_handles( + state: &ProviderState, + record: &WebSpaceMount, + prefix_parts: &[String], +) -> Vec<(String, WebSpaceHandle)> { + let prefix_refs = prefix_parts.iter().map(String::as_str).collect::>(); + let mut children = BTreeMap::new(); + for object in immediate_object_children(&state.objects, &record.moniker, &prefix_refs) { + let Ok(parts) = normalized_index_parts(&object.path) else { + continue; + }; + let Some(child_name) = parts.get(prefix_parts.len()).cloned() else { + continue; + }; + if parts.len() == prefix_parts.len() + 1 { + children.insert(child_name, materialized_object_handle(record, object)); + } else { + let child_parts = prefix_parts + .iter() + .cloned() + .chain(std::iter::once(child_name.clone())) + .collect::>(); + let child_refs = child_parts.iter().map(String::as_str).collect::>(); + children + .entry(child_name) + .or_insert_with(|| materialized_virtual_folder_handle(record, &child_refs)); + } + } + children.into_iter().collect() +} + +fn materialized_object_handle(record: &WebSpaceMount, object: &WebSpaceObject) -> WebSpaceHandle { + let parts = normalized_index_parts(&object.path).unwrap_or_else(|_| vec![object.name.clone()]); + let refs = parts.iter().map(String::as_str).collect::>(); + let handle_uri = format!( + "localhost://WebSpaces/{}/{}", + record.moniker, + parts.join("/") + ); + let target_uri = object + .target_uri + .clone() + .unwrap_or_else(|| append_target_uri(&record.target_uri, &refs)); + let traversable = object.kind == "directory"; + WebSpaceHandle { + moniker: record.moniker.clone(), + handle_uri: handle_uri.clone(), + namespace_uri: record.namespace_uri.clone(), + target_uri: Some(target_uri.clone()), + resolver_state: "materialized-local".to_string(), + resolver: record.resolver.clone(), + cache_policy: record.cache_policy.clone(), + sync_policy: record.sync_policy.clone(), + readonly: record.readonly, + access_policy: record.access_policy.clone(), + kind: if traversable { + "materialized-directory".to_string() + } else { + "materialized-file".to_string() + }, + traversable, + size: if traversable { + 0 + } else { + object.content.len() as u64 + }, + object_id: format!( + "object:webspace:{}", + stable_hex(&format!( + "{}:{}:{}", + object.moniker, object.path, object.revision + )) + ), + head_id: head_id_for(&handle_uri), + cache_state: "content_cached".to_string(), + sync_state: sync_state_for(&record.sync_policy, object.dirty, None), + description: format!( + "Local materialized {} in the {} WebSpace.", + object.kind, record.moniker + ), + forked_from: record.forked_from.clone(), + next_step: if object.dirty { + Some("Sync this mutable local object through its resolver when a sync worker is available.".to_string()) + } else { + Some("This object is materialized locally.".to_string()) + }, + } +} + +fn materialized_virtual_folder_handle(record: &WebSpaceMount, parts: &[&str]) -> WebSpaceHandle { + let target_uri = append_target_uri(&record.target_uri, parts); + let handle_uri = format!( + "localhost://WebSpaces/{}/{}", + record.moniker, + parts + .iter() + .map(|part| part.trim_matches('/')) + .filter(|part| !part.is_empty()) + .collect::>() + .join("/") + ); + WebSpaceHandle { + moniker: record.moniker.clone(), + handle_uri: handle_uri.clone(), + namespace_uri: record.namespace_uri.clone(), + target_uri: Some(target_uri.clone()), + resolver_state: "materialized-virtual".to_string(), + resolver: record.resolver.clone(), + cache_policy: record.cache_policy.clone(), + sync_policy: record.sync_policy.clone(), + readonly: record.readonly, + access_policy: record.access_policy.clone(), + kind: "materialized-directory".to_string(), + traversable: true, + size: 0, + object_id: format!("object:webspace:{}", stable_hex(&target_uri)), + head_id: head_id_for(&handle_uri), + cache_state: "content_cached".to_string(), + sync_state: sync_state_for(&record.sync_policy, true, None), + description: format!( + "Virtual folder inferred from local materialized objects in the {} WebSpace.", + record.moniker + ), + forked_from: record.forked_from.clone(), + next_step: Some("List this folder to inspect local materialized children.".to_string()), + } +} + +fn resolve_handle_request( + state: &ProviderState, + path: Option, + moniker: Option, +) -> Result { match (path, moniker) { - (Some(path), _) => handle_from_resolved_path(resolve_path(&path)?), + (Some(path), _) => handle_from_resolved_path(resolve_path(state, &path)?), (None, Some(moniker)) => { if moniker.starts_with("localhost://") || moniker.contains('/') { let rooted = if moniker.starts_with("localhost://") { @@ -390,9 +3481,9 @@ fn resolve_handle_request(path: Option, moniker: Option) -> Resu } else { format!("localhost://WebSpaces/{}", moniker) }; - handle_from_resolved_path(resolve_path(&rooted)?) + handle_from_resolved_path(resolve_path(state, &rooted)?) } else { - resolve_handle(&moniker) + resolve_handle(state, &moniker) } } (None, None) => Err("resolve requires path or moniker".to_string()), @@ -406,9 +3497,20 @@ fn render_meta(handle: &WebSpaceHandle) -> Vec { "namespace_uri": handle.namespace_uri, "target_uri": handle.target_uri, "resolver_state": handle.resolver_state, + "resolver": handle.resolver, + "cache_policy": handle.cache_policy, + "sync_policy": handle.sync_policy, + "readonly": handle.readonly, + "access_policy": handle.access_policy, "kind": handle.kind, "traversable": handle.traversable, + "size": handle.size, + "object_id": handle.object_id, + "head_id": handle.head_id, + "cache_state": handle.cache_state, + "sync_state": handle.sync_state, "description": handle.description, + "forked_from": handle.forked_from, "next_step": handle.next_step, "note": "The WebSpace daemon owns the moniker first and returns a typed handle before any further traversal.", })) @@ -422,10 +3524,57 @@ fn render_endpoint(handle: &WebSpaceHandle) -> Vec { "kind": handle.kind, "description": handle.description, "resolver_state": handle.resolver_state, + "resolver": handle.resolver, + "cache_policy": handle.cache_policy, + "sync_policy": handle.sync_policy, + "readonly": handle.readonly, + "access_policy": handle.access_policy, + "size": handle.size, + "object_id": handle.object_id, + "head_id": handle.head_id, + "cache_state": handle.cache_state, + "sync_state": handle.sync_state, })) .unwrap_or_else(|_| b"{}".to_vec()) } +fn cache_status_from_head(head: &WebSpaceHead) -> serde_json::Value { + let content_cached = head.status.starts_with("materialized_"); + serde_json::json!({ + "schema": "elastos.webspace.cache-status/v1", + "handle_uri": head.handle_uri, + "target_uri": head.target_uri, + "object_id": head.object_id, + "head_id": head.head_id, + "access_policy": head.access_policy, + "policy": head.cache_policy, + "state": head.cache_state, + "last_cached_at": head.last_cached_at, + "content_cached": content_cached, + "note": if content_cached { + "Local materialized bytes are present in the WebSpace object table." + } else { + "This slice caches resolver metadata only. Content bytes require a resolver/cache worker." + } + }) +} + +fn sync_status_from_head(head: &WebSpaceHead) -> serde_json::Value { + serde_json::json!({ + "schema": "elastos.webspace.sync-status/v1", + "handle_uri": head.handle_uri, + "target_uri": head.target_uri, + "object_id": head.object_id, + "head_id": head.head_id, + "access_policy": head.access_policy, + "policy": head.sync_policy, + "state": head.sync_state, + "dirty": head.dirty, + "last_synced_at": head.last_synced_at, + "note": "This slice records sync intent and dirty state. Live sync requires a resolver/sync worker." + }) +} + fn stat_for(resolved: &ResolvedPath, original_path: &str) -> FileStat { match resolved { ResolvedPath::Root => FileStat { @@ -434,6 +3583,20 @@ fn stat_for(resolved: &ResolvedPath, original_path: &str) -> FileStat { is_dir: true, size: 0, readonly: true, + access_policy: DEFAULT_READONLY_ACCESS_POLICY.to_string(), + provider: PROVIDER_ID.to_string(), + resolver_state: "root".to_string(), + resolver: "builtin".to_string(), + cache_policy: DEFAULT_CACHE_POLICY.to_string(), + sync_policy: DEFAULT_SYNC_POLICY.to_string(), + kind: "webspace-root".to_string(), + traversable: true, + object_id: "object:webspace:root".to_string(), + head_id: "head:webspace:root".to_string(), + cache_state: "metadata_cached".to_string(), + sync_state: "manual_idle".to_string(), + namespace_uri: None, + target_uri: None, modified: None, created: None, }, @@ -441,12 +3604,22 @@ fn stat_for(resolved: &ResolvedPath, original_path: &str) -> FileStat { path: original_path.to_string(), is_file: !handle.traversable, is_dir: handle.traversable, - size: if handle.traversable { - 0 - } else { - render_endpoint(handle).len() as u64 - }, - readonly: true, + size: handle.size, + readonly: handle.readonly, + access_policy: handle.access_policy.clone(), + provider: PROVIDER_ID.to_string(), + resolver_state: handle.resolver_state.clone(), + resolver: handle.resolver.clone(), + cache_policy: handle.cache_policy.clone(), + sync_policy: handle.sync_policy.clone(), + kind: handle.kind.clone(), + traversable: handle.traversable, + object_id: handle.object_id.clone(), + head_id: handle.head_id.clone(), + cache_state: handle.cache_state.clone(), + sync_state: handle.sync_state.clone(), + namespace_uri: handle.namespace_uri.clone(), + target_uri: handle.target_uri.clone(), modified: None, created: None, }, @@ -456,66 +3629,115 @@ fn stat_for(resolved: &ResolvedPath, original_path: &str) -> FileStat { is_dir: false, size: render_meta(handle).len() as u64, readonly: true, + access_policy: DEFAULT_READONLY_ACCESS_POLICY.to_string(), + provider: PROVIDER_ID.to_string(), + resolver_state: handle.resolver_state.clone(), + resolver: handle.resolver.clone(), + cache_policy: handle.cache_policy.clone(), + sync_policy: handle.sync_policy.clone(), + kind: "metadata".to_string(), + traversable: false, + object_id: handle.object_id.clone(), + head_id: handle.head_id.clone(), + cache_state: handle.cache_state.clone(), + sync_state: handle.sync_state.clone(), + namespace_uri: handle.namespace_uri.clone(), + target_uri: handle.target_uri.clone(), modified: None, created: None, }, } } -fn list_for(resolved: &ResolvedPath) -> Result, String> { +fn dir_entry_from_handle(name: &str, handle: WebSpaceHandle) -> DirEntry { + DirEntry { + name: name.to_string(), + is_file: !handle.traversable, + is_dir: handle.traversable, + size: handle.size, + readonly: handle.readonly, + access_policy: handle.access_policy.clone(), + provider: PROVIDER_ID.to_string(), + resolver_state: handle.resolver_state.clone(), + resolver: handle.resolver.clone(), + cache_policy: handle.cache_policy.clone(), + sync_policy: handle.sync_policy.clone(), + kind: handle.kind.clone(), + traversable: handle.traversable, + object_id: handle.object_id.clone(), + head_id: handle.head_id.clone(), + cache_state: handle.cache_state.clone(), + sync_state: handle.sync_state.clone(), + namespace_uri: handle.namespace_uri.clone(), + target_uri: handle.target_uri.clone(), + } +} + +fn meta_dir_entry(handle: &WebSpaceHandle) -> DirEntry { + DirEntry { + name: "_meta.json".to_string(), + is_file: true, + is_dir: false, + size: render_meta(handle).len() as u64, + readonly: true, + access_policy: DEFAULT_READONLY_ACCESS_POLICY.to_string(), + provider: PROVIDER_ID.to_string(), + resolver_state: handle.resolver_state.clone(), + resolver: handle.resolver.clone(), + cache_policy: handle.cache_policy.clone(), + sync_policy: handle.sync_policy.clone(), + kind: "metadata".to_string(), + traversable: false, + object_id: handle.object_id.clone(), + head_id: handle.head_id.clone(), + cache_state: handle.cache_state.clone(), + sync_state: handle.sync_state.clone(), + namespace_uri: handle.namespace_uri.clone(), + target_uri: handle.target_uri.clone(), + } +} + +fn list_for(state: &ProviderState, resolved: &ResolvedPath) -> Result, String> { match resolved { - ResolvedPath::Root => Ok(known_mounts() + ResolvedPath::Root => Ok(known_mounts(state) .into_iter() - .map(|entry| DirEntry { - name: entry.moniker, - is_file: false, - is_dir: true, - size: 0, + .map(|entry| { + let name = entry.moniker.clone(); + dir_entry_from_handle(&name, entry) }) .collect()), ResolvedPath::Handle { handle } if !handle.traversable => { Err(format!("not a directory: {}", handle.handle_uri)) } ResolvedPath::Handle { handle } => { - let mut entries = vec![DirEntry { - name: "_meta.json".to_string(), - is_file: true, - is_dir: false, - size: render_meta(handle).len() as u64, - }]; + let mut entries = vec![meta_dir_entry(handle)]; match handle.handle_uri.as_str() { "localhost://WebSpaces/Elastos" => { - entries.extend([ - DirEntry { - name: "content".to_string(), - is_file: false, - is_dir: true, - size: 0, - }, - DirEntry { - name: "peer".to_string(), - is_file: false, - is_dir: true, - size: 0, - }, - DirEntry { - name: "did".to_string(), - is_file: false, - is_dir: true, - size: 0, - }, - DirEntry { - name: "ai".to_string(), - is_file: false, - is_dir: true, - size: 0, - }, - ]); + for child in ["content", "peer", "did", "ai"] { + entries.push(dir_entry_from_handle( + child, + resolve_elastos_handle(&[child])?, + )); + } } _ => {} } + if let Some(record) = state.mount_by_moniker(&handle.moniker) { + let prefix_parts = handle_index_parts(handle); + let mut children = BTreeMap::new(); + for (name, child) in indexed_child_handles(state, record, &prefix_parts) { + children.insert(name, child); + } + for (name, child) in materialized_child_handles(state, record, &prefix_parts) { + children.insert(name, child); + } + for (name, child) in children { + entries.push(dir_entry_from_handle(&name, child)); + } + } + Ok(entries) } ResolvedPath::Meta { handle } => Err(format!("not a directory: {}", meta_path(handle))), @@ -533,15 +3755,36 @@ fn error(code: &str, message: impl Into) -> Response { } } -fn init_payload(config: serde_json::Value) -> serde_json::Value { +fn init_payload(state: &ProviderState) -> serde_json::Value { serde_json::json!({ "protocol_version": "1.0", "provider": "webspace", "kind": "dynamic-resolver", - "config": config, + "schema": "elastos.webspace.provider-init/v1", + "persistent": state.mount_table_path().is_some(), + "mount_table": state + .mount_table_path() + .map(|path| path.display().to_string()), + "head_table": state + .head_table_path() + .map(|path| path.display().to_string()), + "index_table": state + .index_table_path() + .map(|path| path.display().to_string()), + "object_table": state + .object_table_path() + .map(|path| path.display().to_string()), + "adapter_table": state + .adapter_table_path() + .map(|path| path.display().to_string()), + "mount_count": known_mounts(state).len(), + "index_entry_count": state.index_entries.len(), + "head_count": state.heads.len(), + "object_count": state.objects.len(), + "configured_adapter_count": state.adapters.len(), "supported_ops": SUPPORTED_OPS, "unsupported_ops": UNSUPPORTED_OPS, - "surface_note": "Read-only resolver slice: resolve, read, list, stat, and exists return mounted handles or metadata views. Mutation ops are explicit errors.", + "surface_note": "Resolver lifecycle slice: built-in and persisted mounts resolve to typed handles. Registered adapters describe external resolver readiness. Mutable user mounts can materialize local objects; live external traversal and provider streaming remain resolver responsibilities.", }) } @@ -555,6 +3798,7 @@ fn main() { } let stdin = io::stdin(); let mut stdout = io::stdout(); + let mut state = ProviderState::default(); for line in stdin.lock().lines() { let line = match line { @@ -572,14 +3816,29 @@ fn main() { }; let response = match serde_json::from_str::(&line) { - Ok(Request::Init { config }) => ok(init_payload(config)), + Ok(Request::Init { config }) => match state.configure(config) { + Ok(payload) => ok(payload), + Err(err) => error("init_failed", err), + }, Ok(Request::Resolve { path, moniker }) => { - match resolve_handle_request(path, moniker) { + match resolve_handle_request(&state, path, moniker) { Ok(handle) => ok(serde_json::to_value(handle).unwrap_or(serde_json::json!({}))), Err(err) => error("resolve_failed", err), } } - Ok(Request::Read { path, .. }) => match resolve_path(&path) { + Ok(Request::Read { path, .. }) => match resolve_path(&state, &path) { + Ok(ResolvedPath::Handle { handle }) if handle.kind == "materialized-file" => { + match state.object_for_handle(&handle) { + Some(object) => { + let size = object.content.len(); + ok(serde_json::json!({ + "content": object.content, + "size": size, + })) + } + None => error("read_failed", format!("materialized object missing from local table: {}", handle.handle_uri)), + } + } Ok(ResolvedPath::Handle { handle }) if !handle.traversable => ok(serde_json::json!({ "content": render_endpoint(&handle), "size": render_endpoint(&handle).len(), @@ -594,32 +3853,226 @@ fn main() { ), Err(err) => error("read_failed", err), }, - Ok(Request::List { path, .. }) => match resolve_path(&path) { - Ok(resolved) => match list_for(&resolved) { + Ok(Request::List { path, .. }) => match resolve_path(&state, &path) { + Ok(resolved) => match list_for(&state, &resolved) { Ok(entries) => ok(serde_json::to_value(entries).unwrap_or(serde_json::json!([]))), Err(err) => error("list_failed", err), }, Err(err) => error("list_failed", err), }, - Ok(Request::Stat { path, .. }) => match resolve_path(&path) { + Ok(Request::Stat { path, .. }) => match resolve_path(&state, &path) { Ok(resolved) => ok(serde_json::to_value(stat_for(&resolved, &path)).unwrap_or(serde_json::json!({}))), Err(err) => error("stat_failed", err), }, Ok(Request::Exists { path, .. }) => ok(serde_json::json!({ - "exists": resolve_path(&path).is_ok(), + "exists": resolve_path(&state, &path).is_ok(), })), - Ok(Request::Write { path, .. }) => error( - "write_failed", - format!("WebSpaces are resolver-owned handles, not ordinary writable storage: {}", path), - ), - Ok(Request::Delete { path, .. }) => error( - "delete_failed", - format!("WebSpaces are resolver-owned handles, not ordinary deletable storage: {}", path), - ), - Ok(Request::Mkdir { path, .. }) => error( - "mkdir_failed", - format!("WebSpaces are resolver-owned handles, not ordinary directories: {}", path), - ), + Ok(Request::Head { path, .. }) => match resolve_path(&state, &path) + .and_then(handle_from_resolved_path) + .and_then(|handle| state.upsert_head_for_handle(&handle, "metadata_only", false)) + { + Ok(head) => ok(serde_json::to_value(head).unwrap_or(serde_json::json!({}))), + Err(err) => error("head_failed", err), + }, + Ok(Request::CacheStatus { path, .. }) => match resolve_path(&state, &path) + .and_then(handle_from_resolved_path) + .and_then(|handle| state.upsert_head_for_handle(&handle, "metadata_only", false)) + { + Ok(head) => ok(cache_status_from_head(&head)), + Err(err) => error("cache_status_failed", err), + }, + Ok(Request::SyncStatus { path, .. }) => match resolve_path(&state, &path) + .and_then(handle_from_resolved_path) + .and_then(|handle| state.upsert_head_for_handle(&handle, "metadata_only", false)) + { + Ok(head) => ok(sync_status_from_head(&head)), + Err(err) => error("sync_status_failed", err), + }, + Ok(Request::Mounts { .. }) => ok(serde_json::json!({ + "schema": MOUNT_TABLE_SCHEMA, + "mounts": known_mounts(&state), + "user_mounts": state.user_mounts(), + })), + Ok(Request::Adapters { .. }) => ok(state.adapter_summary_table()), + Ok(Request::RegisterAdapter { + resolver, + label, + endpoint_uri, + provider, + state: adapter_state, + capabilities, + readonly_default, + description, + .. + }) => match state.upsert_adapter( + resolver, + label, + endpoint_uri, + provider, + adapter_state, + capabilities, + readonly_default, + description, + ) { + Ok(adapter) => ok(serde_json::json!({ + "schema": "elastos.webspace.adapter-receipt/v1", + "action": "registered", + "adapter": adapter_public_summary(&adapter), + })), + Err(err) => error("register_adapter_failed", err), + }, + Ok(Request::UnregisterAdapter { resolver, .. }) => { + match state.unregister_adapter(&resolver) { + Ok(adapter) => ok(serde_json::json!({ + "schema": "elastos.webspace.adapter-receipt/v1", + "action": "unregistered", + "adapter": adapter_public_summary(&adapter), + })), + Err(err) => error("unregister_adapter_failed", err), + } + } + Ok(Request::CheckAdapter { + resolver, + result, + state: adapter_state, + error_code, + capabilities, + .. + }) => match state.check_adapter( + resolver, + result, + adapter_state, + error_code, + capabilities, + ) { + Ok(receipt) => ok(receipt), + Err(err) => error("check_adapter_failed", err), + }, + Ok(Request::Health { moniker, .. }) => match state.health_report(moniker) { + Ok(report) => ok(report), + Err(err) => error("health_failed", err), + }, + Ok(Request::Mount { + moniker, + target_uri, + namespace_uri, + resolver, + description, + readonly, + cache_policy, + sync_policy, + access_policy, + .. + }) => match state.upsert_mount( + moniker, + target_uri, + namespace_uri, + resolver, + description, + readonly, + cache_policy, + sync_policy, + access_policy, + ) { + Ok(record) => ok(serde_json::json!({ + "schema": "elastos.webspace.mount-receipt/v1", + "action": "mounted", + "mount": record, + })), + Err(err) => error("mount_failed", err), + }, + Ok(Request::Unmount { moniker, .. }) => match state.unmount(&moniker) { + Ok(record) => ok(serde_json::json!({ + "schema": "elastos.webspace.mount-receipt/v1", + "action": "unmounted", + "mount": record, + })), + Err(err) => error("unmount_failed", err), + }, + Ok(Request::Index { + moniker, entries, .. + }) => match state.replace_index(&moniker, entries) { + Ok(entries) => ok(serde_json::json!({ + "schema": "elastos.webspace.index-receipt/v1", + "action": "indexed", + "moniker": moniker, + "entry_count": entries.len(), + "entries": entries, + })), + Err(err) => error("index_failed", err), + }, + Ok(Request::Refresh { path, entries, .. }) => match state.refresh_handle(path, entries) + { + Ok(receipt) => ok(receipt), + Err(err) => error("refresh_failed", err), + }, + Ok(Request::Fork { + source_uri, + moniker, + target_uri, + resolver, + description, + readonly, + cache_policy, + sync_policy, + access_policy, + .. + }) => match state.fork_mount( + source_uri, + moniker, + target_uri, + resolver, + description, + readonly, + cache_policy, + sync_policy, + access_policy, + ) { + Ok((record, head)) => ok(serde_json::json!({ + "schema": "elastos.webspace.fork-receipt/v1", + "action": "forked", + "mount": record, + "head": head, + "materialized": false, + "next_step": "Attach a resolver/cache worker to materialize bytes and sync the fork." + })), + Err(err) => error("fork_failed", err), + }, + Ok(Request::Cache { + path, + content, + mime, + source_receipt, + .. + }) => match state.cache_handle(path, content, mime, source_receipt) { + Ok(receipt) => ok(receipt), + Err(err) => error("cache_failed", err), + }, + Ok(Request::Sync { path, .. }) => match state.sync_handle(path) { + Ok(receipt) => ok(receipt), + Err(err) => error("sync_failed", err), + }, + Ok(Request::Write { + path, + content, + append, + .. + }) => match state.write_handle(path, content, append) { + Ok(receipt) => ok(receipt), + Err(err) => error("write_failed", err), + }, + Ok(Request::Delete { + path, recursive, .. + }) => match state.delete_handle(path, recursive) { + Ok(receipt) => ok(receipt), + Err(err) => error("delete_failed", err), + }, + Ok(Request::Mkdir { + path, parents, .. + }) => match state.mkdir_handle(path, parents) { + Ok(receipt) => ok(receipt), + Err(err) => error("mkdir_failed", err), + }, Ok(Request::Ping) => ok(serde_json::json!({ "pong": true })), Ok(Request::Shutdown) => { let response = ok(serde_json::json!({ @@ -645,15 +4098,30 @@ fn main() { mod tests { use super::*; + fn state() -> ProviderState { + ProviderState::default() + } + + fn temp_base(name: &str) -> PathBuf { + let path = std::env::temp_dir().join(format!( + "elastos-webspace-provider-{name}-{}", + now_unix_secs() + )); + let _ = fs::remove_dir_all(&path); + path + } + #[test] fn resolves_elastos_mount() { - let resolved = - resolve_path("localhost://WebSpaces/Elastos").expect("should resolve Elastos mount"); + let state = state(); + let resolved = resolve_path(&state, "localhost://WebSpaces/Elastos") + .expect("should resolve Elastos mount"); match resolved { ResolvedPath::Handle { handle } => { assert_eq!(handle.handle_uri, "localhost://WebSpaces/Elastos"); assert!(handle.traversable); assert_eq!(handle.kind, "dynamic-webspace"); + assert_eq!(handle.resolver, "builtin"); } _ => panic!("expected mounted handle"), } @@ -661,7 +4129,8 @@ mod tests { #[test] fn resolves_content_endpoint() { - let resolved = resolve_path("localhost://WebSpaces/Elastos/content/QmExampleCid") + let state = state(); + let resolved = resolve_path(&state, "localhost://WebSpaces/Elastos/content/QmExampleCid") .expect("should resolve content endpoint"); match resolved { ResolvedPath::Handle { handle } => { @@ -675,7 +4144,8 @@ mod tests { #[test] fn resolves_peer_folder() { - let resolved = resolve_path("localhost://WebSpaces/Elastos/peer/alice") + let state = state(); + let resolved = resolve_path(&state, "localhost://WebSpaces/Elastos/peer/alice") .expect("should resolve peer folder"); match resolved { ResolvedPath::Handle { handle } => { @@ -689,28 +4159,34 @@ mod tests { #[test] fn rejects_deeper_peer_traversal() { - let err = resolve_path("localhost://WebSpaces/Elastos/peer/alice/messages") + let state = state(); + let err = resolve_path(&state, "localhost://WebSpaces/Elastos/peer/alice/messages") .expect_err("deeper peer traversal should fail"); assert!(err.contains("peer handles do not support traversal")); } #[test] fn rejects_deeper_did_traversal() { - let err = resolve_path("localhost://WebSpaces/Elastos/did/did:key:z6Mk/example") - .expect_err("deeper did traversal should fail"); + let state = state(); + let err = resolve_path( + &state, + "localhost://WebSpaces/Elastos/did/did:key:z6Mk/example", + ) + .expect_err("deeper did traversal should fail"); assert!(err.contains("did handles do not support traversal")); } #[test] fn rejects_deeper_ai_traversal() { - let err = resolve_path("localhost://WebSpaces/Elastos/ai/openai/gpt-5.4") + let state = state(); + let err = resolve_path(&state, "localhost://WebSpaces/Elastos/ai/openai/gpt-5.4") .expect_err("deeper ai traversal should fail"); assert!(err.contains("ai handles do not support traversal")); } #[test] fn init_payload_advertises_supported_and_unsupported_ops() { - let payload = init_payload(serde_json::json!({"seeded": true})); + let payload = init_payload(&state()); let supported = payload["supported_ops"] .as_array() .expect("supported ops should be an array"); @@ -720,43 +4196,105 @@ mod tests { assert!(supported.iter().any(|value| value == "resolve")); assert!(supported.iter().any(|value| value == "read")); - assert!(unsupported.iter().any(|value| value == "write")); - assert!(unsupported.iter().any(|value| value == "delete")); - assert!(unsupported.iter().any(|value| value == "mkdir")); + assert!(supported.iter().any(|value| value == "mount")); + assert!(supported.iter().any(|value| value == "adapters")); + assert!(supported.iter().any(|value| value == "register_adapter")); + assert!(supported.iter().any(|value| value == "unregister_adapter")); + assert!(supported.iter().any(|value| value == "check_adapter")); + assert!(supported.iter().any(|value| value == "unmount")); + assert!(supported.iter().any(|value| value == "index")); + assert!(supported.iter().any(|value| value == "health")); + assert!(supported.iter().any(|value| value == "refresh")); + assert!(supported.iter().any(|value| value == "head")); + assert!(supported.iter().any(|value| value == "cache")); + assert!(supported.iter().any(|value| value == "cache_status")); + assert!(supported.iter().any(|value| value == "sync")); + assert!(supported.iter().any(|value| value == "sync_status")); + assert!(supported.iter().any(|value| value == "fork")); + assert!(supported.iter().any(|value| value == "write")); + assert!(supported.iter().any(|value| value == "delete")); + assert!(supported.iter().any(|value| value == "mkdir")); + assert!(unsupported.is_empty()); + } + + #[test] + fn lists_root_mounts() { + let state = state(); + let resolved = resolve_path(&state, "localhost://WebSpaces").expect("should resolve root"); + let entries = list_for(&state, &resolved).expect("should list root mounts"); + let names: Vec<_> = entries.iter().map(|entry| entry.name.clone()).collect(); + assert!(names.contains(&"Elastos".to_string())); } #[test] fn lists_elastos_children() { - let resolved = - resolve_path("localhost://WebSpaces/Elastos").expect("should resolve Elastos mount"); - let entries = list_for(&resolved).expect("should list Elastos children"); - let names: Vec<_> = entries.into_iter().map(|entry| entry.name).collect(); + let state = state(); + let resolved = resolve_path(&state, "localhost://WebSpaces/Elastos") + .expect("should resolve Elastos mount"); + let entries = list_for(&state, &resolved).expect("should list Elastos children"); + let names: Vec<_> = entries.iter().map(|entry| entry.name.clone()).collect(); assert!(names.contains(&"_meta.json".to_string())); assert!(names.contains(&"content".to_string())); assert!(names.contains(&"peer".to_string())); assert!(names.contains(&"did".to_string())); assert!(names.contains(&"ai".to_string())); + let content = entries + .iter() + .find(|entry| entry.name == "content") + .expect("content child should be listed"); + assert_eq!(content.provider, "webspace-provider"); + assert_eq!(content.resolver_state, "resolved"); + assert_eq!(content.resolver, "builtin"); + assert_eq!(content.kind, "folder-handle"); + assert_eq!(content.target_uri.as_deref(), Some("elastos://")); + assert!(content.readonly); + } + + #[test] + fn stat_exposes_resolver_metadata() { + let state = state(); + let resolved = resolve_path(&state, "localhost://WebSpaces/Elastos/content/QmExampleCid") + .expect("should resolve content endpoint"); + let stat = stat_for( + &resolved, + "localhost://WebSpaces/Elastos/content/QmExampleCid", + ); + assert_eq!(stat.provider, "webspace-provider"); + assert_eq!(stat.resolver_state, "resolved"); + assert_eq!(stat.resolver, "builtin"); + assert_eq!(stat.kind, "file-endpoint"); + assert_eq!(stat.target_uri.as_deref(), Some("elastos://QmExampleCid")); + assert!(stat.object_id.starts_with("object:webspace:")); + assert!(stat.head_id.starts_with("head:webspace:")); + assert_eq!(stat.cache_state, "metadata_cached"); + assert_eq!(stat.sync_state, "manual_idle"); + assert!(!stat.traversable); + assert!(stat.readonly); } #[test] fn listing_content_endpoint_fails() { - let resolved = resolve_path("localhost://WebSpaces/Elastos/content/QmExampleCid") + let state = state(); + let resolved = resolve_path(&state, "localhost://WebSpaces/Elastos/content/QmExampleCid") .expect("should resolve content endpoint"); - assert!(list_for(&resolved).is_err()); + assert!(list_for(&state, &resolved).is_err()); } #[test] fn listing_meta_path_fails() { - let resolved = resolve_path("localhost://WebSpaces/Elastos/_meta.json") + let state = state(); + let resolved = resolve_path(&state, "localhost://WebSpaces/Elastos/_meta.json") .expect("should resolve metadata path"); - let err = list_for(&resolved).expect_err("metadata path should not list"); + let err = list_for(&state, &resolved).expect_err("metadata path should not list"); assert!(err.contains("not a directory")); assert!(err.contains("_meta.json")); } #[test] fn resolve_request_rejects_meta_path() { + let state = state(); let err = resolve_handle_request( + &state, Some("localhost://WebSpaces/Elastos/_meta.json".to_string()), None, ) @@ -764,4 +4302,934 @@ mod tests { assert!(err.contains("not metadata files")); assert!(err.contains("_meta.json")); } + + #[test] + fn custom_mount_persists_and_resolves_mapped_handles() { + let base = temp_base("persist"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + let record = state + .upsert_mount( + "Google".to_string(), + "google://drive".to_string(), + None, + Some("google-drive".to_string()), + Some("Google Drive WebSpace mount.".to_string()), + Some(true), + Some("metadata-and-thumbnails".to_string()), + Some("manual".to_string()), + None, + ) + .expect("mount should persist"); + assert_eq!(record.moniker, "Google"); + + let mut reloaded = ProviderState::default(); + reloaded + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should reload persistent path"); + let root = resolve_path(&reloaded, "localhost://WebSpaces").expect("root should resolve"); + let entries = list_for(&reloaded, &root).expect("root should list"); + assert!(entries.iter().any(|entry| entry.name == "Google")); + + let resolved = resolve_path( + &reloaded, + "localhost://WebSpaces/Google/Drive/Project X/file.pdf", + ) + .expect("mapped child should resolve to external handle"); + match resolved { + ResolvedPath::Handle { handle } => { + assert_eq!(handle.kind, "external-object-handle"); + assert_eq!( + handle.target_uri.as_deref(), + Some("google://drive/Drive/Project X/file.pdf") + ); + assert_eq!(handle.resolver, "google-drive"); + assert_eq!(handle.resolver_state, "mapped-unavailable"); + assert!(!handle.traversable); + } + _ => panic!("expected external mapped handle"), + } + let _ = fs::remove_dir_all(base); + } + + #[test] + fn head_persists_for_custom_mapped_handle() { + let base = temp_base("heads"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + state + .upsert_mount( + "Google".to_string(), + "google://drive".to_string(), + None, + Some("google-drive".to_string()), + Some("Google Drive WebSpace mount.".to_string()), + Some(true), + Some("metadata-and-thumbnails".to_string()), + Some("manual".to_string()), + None, + ) + .expect("mount should persist"); + let handle = handle_from_resolved_path( + resolve_path( + &state, + "localhost://WebSpaces/Google/Drive/Project X/file.pdf", + ) + .expect("mapped child should resolve"), + ) + .expect("resolved path should be a handle"); + let head = state + .upsert_head_for_handle(&handle, "metadata_only", false) + .expect("head should persist"); + assert_eq!(head.schema, HEAD_RECORD_SCHEMA); + assert_eq!(head.handle_uri, handle.handle_uri); + assert!(head.object_id.starts_with("object:webspace:")); + assert!(head.head_id.starts_with("head:webspace:")); + assert_eq!(head.cache_state, "metadata_cached"); + assert_eq!(head.sync_state, "manual_idle"); + assert!(!head.dirty); + + let mut reloaded = ProviderState::default(); + reloaded + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should reload persistent path"); + assert_eq!(reloaded.heads.len(), 1); + assert_eq!(reloaded.heads[0].handle_uri, head.handle_uri); + assert_eq!(reloaded.heads[0].head_id, head.head_id); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn custom_mount_index_persists_and_lists_children() { + let base = temp_base("index"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + state + .upsert_mount( + "Google".to_string(), + "google://drive".to_string(), + None, + Some("google-drive".to_string()), + Some("Google Drive WebSpace mount.".to_string()), + Some(true), + Some("metadata-and-thumbnails".to_string()), + Some("manual".to_string()), + None, + ) + .expect("mount should persist"); + let indexed = state + .replace_index( + "Google", + vec![ + WebSpaceIndexInput { + path: "Drive".to_string(), + kind: "directory".to_string(), + target_uri: None, + resolver_state: Some("indexed".to_string()), + readonly: None, + description: Some("Drive folder from resolver index.".to_string()), + }, + WebSpaceIndexInput { + path: "Drive/Project X/file.pdf".to_string(), + kind: "file".to_string(), + target_uri: None, + resolver_state: Some("indexed".to_string()), + readonly: None, + description: Some("Project file from resolver index.".to_string()), + }, + WebSpaceIndexInput { + path: "Shared/report.md".to_string(), + kind: "file".to_string(), + target_uri: Some("google://drive/shared/report.md".to_string()), + resolver_state: Some("indexed".to_string()), + readonly: None, + description: Some("Shared report from resolver index.".to_string()), + }, + ], + ) + .expect("index should persist"); + assert_eq!(indexed.len(), 3); + + let mut reloaded = ProviderState::default(); + reloaded + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should reload persistent path"); + assert_eq!(reloaded.index_entries.len(), 3); + + let google = resolve_path(&reloaded, "localhost://WebSpaces/Google") + .expect("Google mount should resolve"); + let entries = list_for(&reloaded, &google).expect("Google mount should list"); + let names: Vec<_> = entries.iter().map(|entry| entry.name.as_str()).collect(); + assert!(names.contains(&"_meta.json")); + assert!(names.contains(&"Drive")); + assert!(names.contains(&"Shared")); + let shared = entries + .iter() + .find(|entry| entry.name == "Shared") + .expect("implicit parent folder should be listed"); + assert_eq!(shared.kind, "indexed-directory"); + assert_eq!(shared.resolver_state, "indexed-virtual"); + + let project = resolve_path(&reloaded, "localhost://WebSpaces/Google/Drive/Project X") + .expect("indexed virtual project folder should resolve"); + let project_entries = list_for(&reloaded, &project).expect("project folder should list"); + assert!(project_entries.iter().any(|entry| entry.name == "file.pdf")); + + let file = resolve_path( + &reloaded, + "localhost://WebSpaces/Google/Drive/Project X/file.pdf", + ) + .expect("indexed file should resolve"); + match file { + ResolvedPath::Handle { handle } => { + assert_eq!(handle.kind, "indexed-file"); + assert_eq!(handle.resolver_state, "indexed"); + assert!(!handle.traversable); + assert_eq!( + handle.target_uri.as_deref(), + Some("google://drive/Drive/Project X/file.pdf") + ); + assert_eq!(handle.resolver, "google-drive"); + } + _ => panic!("expected indexed file handle"), + } + let _ = fs::remove_dir_all(base); + } + + #[test] + fn refresh_replaces_index_and_persists_refreshed_head() { + let base = temp_base("refresh"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + state + .upsert_mount( + "Google".to_string(), + "google://drive".to_string(), + None, + Some("google-drive".to_string()), + Some("Google Drive WebSpace mount.".to_string()), + Some(true), + Some("metadata-and-thumbnails".to_string()), + Some("manual".to_string()), + None, + ) + .expect("mount should persist"); + state + .replace_index( + "Google", + vec![WebSpaceIndexInput { + path: "Old/file.txt".to_string(), + kind: "file".to_string(), + target_uri: None, + resolver_state: Some("stale".to_string()), + readonly: None, + description: None, + }], + ) + .expect("initial index should persist"); + + let receipt = state + .refresh_handle( + "localhost://WebSpaces/Google".to_string(), + Some(vec![ + WebSpaceIndexInput { + path: "Drive".to_string(), + kind: "directory".to_string(), + target_uri: None, + resolver_state: Some("refreshed".to_string()), + readonly: None, + description: Some("Drive folder refreshed from resolver.".to_string()), + }, + WebSpaceIndexInput { + path: "Drive/Project X/file.pdf".to_string(), + kind: "file".to_string(), + target_uri: None, + resolver_state: Some("refreshed".to_string()), + readonly: None, + description: Some("Project file refreshed from resolver.".to_string()), + }, + ]), + ) + .expect("refresh should persist index and head"); + + assert_eq!(receipt["schema"], "elastos.webspace.refresh-receipt/v1"); + assert_eq!(receipt["index_entry_count"], 2); + assert_eq!(receipt["head"]["status"], "resolver_refreshed"); + assert_eq!(receipt["head"]["cache_state"], "metadata_cached"); + assert!(receipt["head"]["last_cached_at"].as_u64().unwrap_or(0) > 0); + assert_eq!(state.index_entries.len(), 2); + assert!(!state + .index_entries + .iter() + .any(|entry| entry.path == "Old/file.txt")); + + let mut reloaded = ProviderState::default(); + reloaded + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should reload persistent path"); + assert_eq!(reloaded.index_entries.len(), 2); + assert_eq!(reloaded.heads.len(), 1); + assert_eq!(reloaded.heads[0].status, "resolver_refreshed"); + + let project = resolve_path(&reloaded, "localhost://WebSpaces/Google/Drive/Project X") + .expect("refreshed project folder should resolve"); + let entries = list_for(&reloaded, &project).expect("project folder should list"); + let file = entries + .iter() + .find(|entry| entry.name == "file.pdf") + .expect("refreshed file should be listed"); + assert_eq!(file.resolver_state, "refreshed"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn health_reports_external_resolver_attention_and_metadata_readiness() { + let base = temp_base("health"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + state + .upsert_mount( + "Google".to_string(), + "google://drive".to_string(), + None, + Some("google-drive".to_string()), + Some("Google Drive WebSpace mount.".to_string()), + Some(true), + Some("metadata-and-thumbnails".to_string()), + Some("manual".to_string()), + None, + ) + .expect("mount should persist"); + + let attention = state.health_report(None).expect("health should report"); + assert_eq!(attention["schema"], "elastos.webspace.health/v1"); + assert_eq!(attention["state"], "attention"); + assert_eq!(attention["live_adapter_count"], 1); + let google = attention["mounts"] + .as_array() + .unwrap() + .iter() + .find(|mount| mount["moniker"] == "Google") + .expect("Google health should be listed"); + assert_eq!(google["state"], "mounted_no_index"); + assert_eq!(google["live_adapter"], false); + assert_eq!(google["adapter_state"], "not_registered"); + + state + .refresh_handle( + "Google".to_string(), + Some(vec![WebSpaceIndexInput { + path: "Drive/file.pdf".to_string(), + kind: "file".to_string(), + target_uri: None, + resolver_state: Some("refreshed".to_string()), + readonly: None, + description: None, + }]), + ) + .expect("refresh should make metadata ready"); + let ready = state + .health_report(Some("Google".to_string())) + .expect("filtered health should report"); + assert_eq!(ready["state"], "metadata_ready"); + assert_eq!(ready["mount_count"], 1); + assert_eq!(ready["user_mount_count"], 1); + assert_eq!(ready["index_entry_count"], 1); + assert_eq!(ready["head_count"], 1); + assert_eq!(ready["dirty_head_count"], 0); + assert_eq!(ready["mounts"][0]["state"], "metadata_ready"); + assert_eq!(ready["mounts"][0]["index_entry_count"], 1); + + state + .fork_mount( + "localhost://WebSpaces/Google/Drive/file.pdf".to_string(), + "ProjectFork".to_string(), + None, + None, + None, + None, + None, + None, + None, + ) + .expect("fork should create a dirty mutable head"); + let dirty = state + .health_report(None) + .expect("dirty fork health should report"); + assert_eq!(dirty["state"], "attention"); + assert_eq!(dirty["dirty_head_count"], 1); + let fork = dirty["mounts"] + .as_array() + .unwrap() + .iter() + .find(|mount| mount["moniker"] == "ProjectFork") + .expect("fork health should be listed"); + assert_eq!(fork["state"], "dirty"); + assert_eq!(fork["dirty_head_count"], 1); + + let google_still_ready = state + .health_report(Some("Google".to_string())) + .expect("filtered Google health should ignore fork heads"); + assert_eq!(google_still_ready["state"], "metadata_ready"); + assert_eq!(google_still_ready["dirty_head_count"], 0); + + state + .sync_handle("localhost://WebSpaces/ProjectFork".to_string()) + .expect("sync should clear the dirty fork head"); + let synced = state + .health_report(Some("ProjectFork".to_string())) + .expect("synced fork health should report"); + assert_eq!(synced["state"], "metadata_ready"); + assert_eq!(synced["dirty_head_count"], 0); + assert_eq!(synced["mounts"][0]["state"], "metadata_ready"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn adapter_registry_persists_and_informs_health() { + let base = temp_base("adapter_registry"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + let adapter = state + .upsert_adapter( + "google-drive".to_string(), + Some("Google Drive".to_string()), + Some("https://token:secret@example.test/drive".to_string()), + Some("google-drive-provider".to_string()), + Some("connected".to_string()), + vec!["metadata_index".to_string(), "read_bytes".to_string()], + Some(true), + Some("Google Drive resolver adapter.".to_string()), + ) + .expect("adapter should register"); + assert_eq!(adapter.resolver, "google-drive"); + assert_eq!(adapter.state, "connected"); + assert_eq!(adapter.capabilities, vec!["metadata_index", "read_bytes"]); + + state + .upsert_mount( + "Google".to_string(), + "google://drive".to_string(), + None, + Some("google-drive".to_string()), + None, + Some(true), + None, + None, + None, + ) + .expect("mount should persist"); + + let health = state.health_report(None).expect("health should report"); + assert_eq!(health["live_adapter_count"], 2); + assert_eq!(health["connected_adapter_count"], 1); + assert_eq!(health["checked_adapter_count"], 0); + let google = health["mounts"] + .as_array() + .unwrap() + .iter() + .find(|mount| mount["moniker"] == "Google") + .expect("Google mount should be listed"); + assert_eq!(google["live_adapter"], true); + assert_eq!(google["adapter_state"], "connected"); + assert_eq!(google["adapter"]["provider"], "google-drive-provider"); + assert_eq!( + google["adapter"]["health"]["status"], + "connected_unverified" + ); + assert_eq!( + google["adapter"]["endpoint_uri"], + "https://redacted@example.test/drive" + ); + + let failed = state + .check_adapter( + "google-drive".to_string(), + Some("failed".to_string()), + None, + Some("upstream_timeout".to_string()), + Vec::new(), + ) + .expect("adapter health check should persist"); + assert_eq!( + failed["schema"], + "elastos.webspace.adapter-health-receipt/v1" + ); + assert_eq!(failed["adapter"]["state"], "unavailable"); + assert_eq!(failed["adapter"]["health"]["status"], "unavailable"); + assert_eq!( + failed["adapter"]["health"]["last_error_code"], + "upstream_timeout" + ); + + let mut reloaded = ProviderState::default(); + reloaded + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("reload should configure persistent path"); + assert_eq!(reloaded.adapters.len(), 1); + assert_eq!(reloaded.adapters[0].resolver, "google-drive"); + assert_eq!(reloaded.adapters[0].state, "unavailable"); + assert_eq!( + reloaded.adapters[0].last_check_error_code.as_deref(), + Some("upstream_timeout") + ); + let checked_health = reloaded + .health_report(Some("Google".to_string())) + .expect("checked health should report"); + assert_eq!(checked_health["connected_adapter_count"], 0); + assert_eq!(checked_health["checked_adapter_count"], 1); + assert_eq!(checked_health["mounts"][0]["adapter_state"], "unavailable"); + assert_eq!( + checked_health["mounts"][0]["adapter"]["health"]["status"], + "unavailable" + ); + + let removed = reloaded + .unregister_adapter("google-drive") + .expect("adapter should unregister"); + assert_eq!(removed.resolver, "google-drive"); + assert!(reloaded.adapters.is_empty()); + let health = reloaded + .health_report(Some("Google".to_string())) + .expect("health should report after unregister"); + assert_eq!(health["mounts"][0]["adapter_state"], "not_registered"); + assert_eq!(health["mounts"][0]["live_adapter"], false); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn unmount_removes_index_and_head_state() { + let base = temp_base("unmount-index"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + state + .upsert_mount( + "Google".to_string(), + "google://drive".to_string(), + None, + Some("google-drive".to_string()), + Some("Google Drive WebSpace mount.".to_string()), + Some(true), + Some("metadata-and-thumbnails".to_string()), + Some("manual".to_string()), + None, + ) + .expect("mount should persist"); + state + .replace_index( + "Google", + vec![WebSpaceIndexInput { + path: "Drive/file.pdf".to_string(), + kind: "file".to_string(), + target_uri: None, + resolver_state: Some("indexed".to_string()), + readonly: None, + description: None, + }], + ) + .expect("index should persist"); + let handle = handle_from_resolved_path( + resolve_path(&state, "localhost://WebSpaces/Google/Drive/file.pdf") + .expect("indexed file should resolve"), + ) + .expect("resolved path should be a handle"); + state + .upsert_head_for_handle(&handle, "metadata_only", false) + .expect("head should persist"); + assert_eq!(state.index_entries.len(), 1); + assert_eq!(state.heads.len(), 1); + + state.unmount("Google").expect("unmount should succeed"); + assert!(state.index_entries.is_empty()); + assert!(state.heads.is_empty()); + + let mut reloaded = ProviderState::default(); + reloaded + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should reload persistent path"); + assert!(reloaded.index_entries.is_empty()); + assert!(reloaded.heads.is_empty()); + let root = resolve_path(&reloaded, "localhost://WebSpaces").expect("root should resolve"); + let entries = list_for(&reloaded, &root).expect("root should list"); + assert!(!entries.iter().any(|entry| entry.name == "Google")); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn fork_creates_mutable_mount_and_dirty_head() { + let base = temp_base("fork"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + state + .upsert_mount( + "Google".to_string(), + "google://drive".to_string(), + None, + Some("google-drive".to_string()), + Some("Google Drive WebSpace mount.".to_string()), + Some(true), + Some("metadata-and-thumbnails".to_string()), + Some("manual".to_string()), + None, + ) + .expect("mount should persist"); + + let (record, head) = state + .fork_mount( + "localhost://WebSpaces/Google/Drive/Project X/file.pdf".to_string(), + "ProjectFork".to_string(), + None, + None, + None, + None, + None, + None, + None, + ) + .expect("fork should create mutable WebSpace mount"); + + assert_eq!(record.moniker, "ProjectFork"); + assert!(!record.readonly); + assert_eq!( + record.forked_from.as_deref(), + Some("localhost://WebSpaces/Google/Drive/Project X/file.pdf") + ); + assert_eq!(head.status, "forked_metadata_only"); + assert!(head.dirty); + assert_eq!(head.sync_state, "manual_pending"); + assert_eq!(head.handle_uri, "localhost://WebSpaces/ProjectFork"); + + let fork = handle_from_resolved_path( + resolve_path(&state, "localhost://WebSpaces/ProjectFork") + .expect("forked mount should resolve"), + ) + .expect("forked mount should be a handle"); + assert_eq!(fork.resolver_state, "mounted-mutable"); + assert_eq!( + fork.forked_from.as_deref(), + Some("localhost://WebSpaces/Google/Drive/Project X/file.pdf") + ); + let cache = cache_status_from_head(&head); + assert_eq!(cache["content_cached"], false); + let sync = sync_status_from_head(&head); + assert_eq!(sync["dirty"], true); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn sync_clears_dirty_fork_head_without_claiming_byte_sync() { + let base = temp_base("sync-fork"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + state + .upsert_mount( + "Google".to_string(), + "google://drive".to_string(), + None, + Some("google-drive".to_string()), + Some("Google Drive WebSpace mount.".to_string()), + Some(true), + Some("metadata-and-thumbnails".to_string()), + Some("manual".to_string()), + None, + ) + .expect("mount should persist"); + let (_record, forked_head) = state + .fork_mount( + "localhost://WebSpaces/Google/Drive/Project X/file.pdf".to_string(), + "ProjectFork".to_string(), + None, + None, + None, + None, + None, + None, + None, + ) + .expect("fork should create dirty head"); + assert!(forked_head.dirty); + + let receipt = state + .sync_handle("localhost://WebSpaces/ProjectFork".to_string()) + .expect("sync should persist a clean metadata head"); + assert_eq!(receipt["schema"], "elastos.webspace.sync-receipt/v1"); + assert_eq!(receipt["content_synced"], false); + assert_eq!(receipt["head"]["status"], "metadata_synced"); + assert_eq!(receipt["head"]["dirty"], false); + assert_eq!(receipt["head"]["sync_state"], "manual_synced"); + assert!(receipt["head"]["last_synced_at"].as_u64().unwrap_or(0) > 0); + + let health = state + .health_report(Some("ProjectFork".to_string())) + .expect("fork health should report"); + assert_eq!(health["state"], "metadata_ready"); + assert_eq!(health["mounts"][0]["dirty_head_count"], 0); + + let mut reloaded = ProviderState::default(); + reloaded + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should reload persistent path"); + let synced = reloaded + .heads + .iter() + .find(|head| head.handle_uri == "localhost://WebSpaces/ProjectFork") + .expect("synced fork head should persist"); + assert!(!synced.dirty); + assert_eq!(synced.status, "metadata_synced"); + assert_eq!(synced.sync_state, "manual_synced"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn mutable_mount_materializes_objects_and_persists_them() { + let base = temp_base("materialized"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + let record = state + .upsert_mount( + "Project".to_string(), + "local://project".to_string(), + None, + Some("local-materialized".to_string()), + Some("Mutable project WebSpace.".to_string()), + Some(false), + Some("metadata-and-bytes".to_string()), + Some("manual".to_string()), + None, + ) + .expect("mutable mount should persist"); + assert!(!record.readonly); + assert_eq!(record.access_policy, DEFAULT_MUTABLE_ACCESS_POLICY); + + let mkdir = state + .mkdir_handle("localhost://WebSpaces/Project/Docs".to_string(), false) + .expect("mkdir should materialize a local directory"); + assert_eq!(mkdir["schema"], "elastos.webspace.mkdir-receipt/v1"); + assert_eq!(mkdir["object"]["kind"], "materialized-directory"); + + let write = state + .write_handle( + "localhost://WebSpaces/Project/Docs/notes.txt".to_string(), + b"hello".to_vec(), + false, + ) + .expect("write should materialize local bytes"); + assert_eq!(write["schema"], "elastos.webspace.write-receipt/v1"); + assert_eq!(write["object"]["kind"], "materialized-file"); + assert_eq!(write["object"]["size"], 5); + assert_eq!(write["head"]["status"], "materialized_local"); + assert_eq!( + cache_status_from_head( + &serde_json::from_value::(write["head"].clone()) + .expect("write head should deserialize") + )["content_cached"], + true + ); + + state + .write_handle( + "localhost://WebSpaces/Project/Docs/notes.txt".to_string(), + b" world".to_vec(), + true, + ) + .expect("append should update local bytes"); + let resolved = resolve_path(&state, "localhost://WebSpaces/Project/Docs/notes.txt") + .expect("materialized file should resolve"); + let handle = handle_from_resolved_path(resolved).expect("resolved path should be a handle"); + assert_eq!(handle.kind, "materialized-file"); + assert_eq!(handle.size, 11); + let object = state + .object_for_handle(&handle) + .expect("materialized object should be stored"); + assert_eq!(object.content, b"hello world"); + + let docs = resolve_path(&state, "localhost://WebSpaces/Project/Docs") + .expect("materialized directory should resolve"); + let entries = list_for(&state, &docs).expect("materialized directory should list"); + assert!(entries.iter().any(|entry| entry.name == "notes.txt")); + + let mut reloaded = ProviderState::default(); + reloaded + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should reload persistent object table"); + let persisted = handle_from_resolved_path( + resolve_path(&reloaded, "localhost://WebSpaces/Project/Docs/notes.txt") + .expect("persisted materialized file should resolve"), + ) + .expect("persisted path should be a handle"); + assert_eq!(persisted.kind, "materialized-file"); + assert_eq!(persisted.size, 11); + + let delete = reloaded + .delete_handle( + "localhost://WebSpaces/Project/Docs/notes.txt".to_string(), + false, + ) + .expect("delete should remove local file"); + assert_eq!(delete["schema"], "elastos.webspace.delete-receipt/v1"); + assert_eq!(delete["removed_count"], 1); + let entries = list_for( + &reloaded, + &resolve_path(&reloaded, "localhost://WebSpaces/Project/Docs") + .expect("directory should still resolve"), + ) + .expect("directory should list after delete"); + assert!(!entries.iter().any(|entry| entry.name == "notes.txt")); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn cache_handle_materializes_adapter_bytes_without_dirty_sync_debt() { + let base = temp_base("cache-bytes"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + state + .upsert_adapter( + "google-drive".to_string(), + Some("Google Drive".to_string()), + Some("provider:google-drive-adapter".to_string()), + Some("google-drive-adapter".to_string()), + Some("connected".to_string()), + vec!["metadata_index".to_string(), "read_bytes".to_string()], + Some(true), + Some("Google Drive adapter.".to_string()), + ) + .expect("adapter should persist"); + state + .upsert_mount( + "Google".to_string(), + "google://drive".to_string(), + None, + Some("google-drive".to_string()), + Some("Google Drive WebSpace mount.".to_string()), + Some(true), + Some("metadata-and-bytes".to_string()), + Some("manual".to_string()), + None, + ) + .expect("mount should persist"); + state + .replace_index( + "Google", + vec![WebSpaceIndexInput { + path: "Drive/Project X/file.pdf".to_string(), + kind: "file".to_string(), + target_uri: Some("google://drive/Drive/Project X/file.pdf".to_string()), + resolver_state: Some("indexed".to_string()), + readonly: Some(true), + description: Some("Indexed file.".to_string()), + }], + ) + .expect("index should persist"); + + let receipt = state + .cache_handle( + "localhost://WebSpaces/Google/Drive/Project X/file.pdf".to_string(), + Some(b"adapter bytes".to_vec()), + Some("application/pdf".to_string()), + Some(serde_json::json!({ + "schema": "elastos.webspace.adapter-cache-source/v1", + "provider": "google-drive-adapter" + })), + ) + .expect("cache should materialize adapter bytes"); + assert_eq!(receipt["schema"], "elastos.webspace.cache-receipt/v1"); + assert_eq!(receipt["action"], "content_cached"); + assert_eq!(receipt["content_cached"], true); + assert_eq!(receipt["dirty"], false); + assert_eq!(receipt["object"]["kind"], "materialized-file"); + assert_eq!(receipt["object"]["size"], 13); + assert_eq!(receipt["head"]["status"], "materialized_cached"); + assert_eq!(receipt["head"]["dirty"], false); + assert_eq!(receipt["head"]["cache_state"], "content_cached"); + + let resolved = handle_from_resolved_path( + resolve_path( + &state, + "localhost://WebSpaces/Google/Drive/Project X/file.pdf", + ) + .expect("cached path should resolve"), + ) + .expect("cached path should be a handle"); + assert_eq!(resolved.kind, "materialized-file"); + let object = state + .object_for_handle(&resolved) + .expect("cached object should be stored"); + assert_eq!(object.content, b"adapter bytes"); + assert!(!object.dirty); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn custom_mount_rejects_reserved_elastos_moniker() { + let mut state = ProviderState::default(); + let err = state + .upsert_mount( + "Elastos".to_string(), + "elastos://override".to_string(), + None, + None, + None, + None, + None, + None, + None, + ) + .expect_err("built-in moniker should be reserved"); + assert!(err.contains("built-in WebSpace")); + } + + #[test] + fn unmount_removes_persisted_mount() { + let base = temp_base("unmount"); + let mut state = ProviderState::default(); + state + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should configure persistent path"); + state + .upsert_mount( + "Docs".to_string(), + "https://example.com/docs".to_string(), + None, + Some("https".to_string()), + None, + Some(true), + None, + None, + None, + ) + .expect("mount should persist"); + state.unmount("Docs").expect("unmount should persist"); + + let mut reloaded = ProviderState::default(); + reloaded + .configure(serde_json::json!({ "base_path": base.display().to_string() })) + .expect("init should reload persistent path"); + let err = resolve_path(&reloaded, "localhost://WebSpaces/Docs") + .expect_err("unmounted WebSpace should be gone"); + assert!(err.contains("unknown WebSpace moniker")); + let _ = fs::remove_dir_all(base); + } } diff --git a/components.json b/components.json index b19b7bbe..34f8de29 100644 --- a/components.json +++ b/components.json @@ -91,6 +91,24 @@ "aarch64-linux" ] }, + "object-provider": { + "cid": "", + "sha256": "", + "size": 0, + "platforms": [ + "x86_64-linux", + "aarch64-linux" + ] + }, + "content-block-graph-provider": { + "cid": "", + "sha256": "", + "size": 0, + "platforms": [ + "x86_64-linux", + "aarch64-linux" + ] + }, "drm-provider": { "cid": "", "sha256": "", @@ -423,6 +441,46 @@ } } }, + "object-provider": { + "install_path": "bin/object-provider", + "description": "Host object provider binary for principal-root Explorer/Library object authority", + "platforms": { + "linux-amd64": { + "cid": "", + "checksum": "", + "size": 0, + "release_path": "object-provider-linux-amd64", + "install_path": "bin/object-provider" + }, + "linux-arm64": { + "cid": "", + "checksum": "", + "size": 0, + "release_path": "object-provider-linux-arm64", + "install_path": "bin/object-provider" + } + } + }, + "content-block-graph-provider": { + "install_path": "bin/content-block-graph-provider", + "description": "Host block graph provider binary for Runtime-mediated arbitrary DAG repair", + "platforms": { + "linux-amd64": { + "cid": "", + "checksum": "", + "size": 0, + "release_path": "content-block-graph-provider-linux-amd64", + "install_path": "bin/content-block-graph-provider" + }, + "linux-arm64": { + "cid": "", + "checksum": "", + "size": 0, + "release_path": "content-block-graph-provider-linux-arm64", + "install_path": "bin/content-block-graph-provider" + } + } + }, "drm-provider": { "install_path": "bin/drm-provider", "description": "Host DRM provider binary for Runtime-mediated protected content requests", @@ -505,7 +563,7 @@ }, "webspace-provider": { "install_path": "bin/webspace-provider", - "description": "Host WebSpace resolver binary required for localhost://WebSpaces//... handles", + "description": "Host WebSpace resolver binary for mounted localhost://WebSpaces//... handles", "platforms": { "linux-amd64": { "cid": "", @@ -523,6 +581,26 @@ } } }, + "operator-drive-adapter": { + "install_path": "bin/operator-drive-adapter", + "description": "Runtime-only operator WebSpace resolver adapter for metadata, byte reads, and mutable write-back", + "platforms": { + "linux-amd64": { + "cid": "", + "checksum": "", + "size": 0, + "release_path": "operator-drive-adapter-linux-amd64", + "install_path": "bin/operator-drive-adapter" + }, + "linux-arm64": { + "cid": "", + "checksum": "", + "size": 0, + "release_path": "operator-drive-adapter-linux-arm64", + "install_path": "bin/operator-drive-adapter" + } + } + }, "home-cli": { "install_path": "capsules/home-cli", "description": "First-party Home CLI capsule required for the default elastos surface", @@ -753,7 +831,7 @@ }, "library": { "install_path": "capsules/library", - "description": "Library capsule for browsing local document objects", + "description": "Library capsule for PC2-style Explorer file management through the Runtime object provider", "platforms": { "*": { "cid": "", @@ -962,6 +1040,8 @@ "wallet-metamask", "wallet-unisat", "wallet", + "object-provider", + "content-block-graph-provider", "browser", "documents", "library", @@ -988,6 +1068,8 @@ "wallet-metamask", "wallet-unisat", "wallet", + "object-provider", + "content-block-graph-provider", "browser", "chat", "chat-wasm", @@ -1057,6 +1139,8 @@ "wallet-metamask", "wallet-unisat", "wallet", + "object-provider", + "content-block-graph-provider", "browser", "kubo", "ipfs-provider", @@ -1089,6 +1173,8 @@ "wallet-metamask", "wallet-unisat", "wallet", + "object-provider", + "content-block-graph-provider", "browser", "kubo", "ipfs-provider", @@ -1117,6 +1203,8 @@ "browser-stream-bridge", "browser-local-exit", "wallet-provider", + "object-provider", + "content-block-graph-provider", "drm-provider", "rights-provider", "key-provider", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 421d37ef..00c77b7b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -477,18 +477,22 @@ localhost://ElastOS/Documents/ → Mutable document object localhost://Users//Documents/report.pdf → Principal-owned local file localhost://MyWebSite/index.html → Local browser-facing site root localhost://Public/manual.pdf → Locally shared public file -google://drive/vacation-photos → Third-party provider example (aspirational, not implemented) +localhost://WebSpaces/Google Drive/Project X/file.pdf → Mounted third-party WebSpace view (aspirational) +google://drive/files/ → Third-party provider target (aspirational, provider-private) elastos://peer/did:key:z6Mk.../shared/music → P2P from a verified peer elastos://ai/claude/chat → AI provider ``` -### Provider + Content Separation +### Mounted WebSpace + Provider + Content Separation ``` -google://drive/photo.jpg (aspirational provider example) - │ │ - │ └─► Content path (what to fetch) - └─► Provider (how to fetch, credentials) +localhost://WebSpaces/Google Drive/Project X/photo.jpg + │ + └─► Local mounted WebSpace handle shown to Library/Home + +google://drive/files/ (aspirational provider target) + │ + └─► Provider-private target: credentials, API, sync, and rate limits Once fetched, content becomes: elastos://Qm789xyz (local, provider-independent) @@ -498,6 +502,11 @@ This means: - Content survives provider deletion - Content can be shared without sharing credentials - Provider can be swapped without losing data +- Apps speak mounted WebSpace/provider intent, not raw cloud API authority +- Mutable WebSpace mounts/forks may materialize local provider-owned objects + and dirty heads, but remote resolver sync, cloud-provider traversal, and + Carrier availability remain provider/Carrier responsibilities rather than app + filesystem authority ### Content Availability And IPLD @@ -936,7 +945,7 @@ capsule still sees object state, not Kubo, gateway, relay, or cluster topology. Providers handle network/cache transparently: ``` -Request: google://drive/doc.pdf (aspirational provider example) +Provider-internal target: google://drive/doc.pdf (aspirational example) Online scenario: 1. Check cache: miss diff --git a/docs/ARCHIVE_POLICY.md b/docs/ARCHIVE_POLICY.md new file mode 100644 index 00000000..c0e3110b --- /dev/null +++ b/docs/ARCHIVE_POLICY.md @@ -0,0 +1,60 @@ +# Archive Dependency And Release Policy + +Date: 2026-06-06 + +This release enables Archive only for archive families that already have +bounded provider-owned support in `object-provider`: `.zip`, `.tar`, `.tar.gz`, +and `.tgz`. + +## Runtime Boundary + +- Archive is a viewer capsule. It does not parse archive bytes in the + browser and does not call `/api/provider/object/*` directly. +- Runtime injects the signed principal and mediates viewer routes for archive + stat, entry listing, safe entry preview, roots, and selected extraction. +- `object-provider` owns archive byte reads, path normalization, unsafe-entry + rejection, selected writes, conflict policy, progress/cancel receipts, and + WebSpace write-back policy. +- WebSpace archive reads use mounted `localhost://WebSpaces/...` handles. Any + resolver-private `target_uri`, credentials, endpoint tokens, Kubo/IPFS handles, + or host paths must be redacted from Archive receipts. + +## Enabled Families + +- `.zip`: enabled for list, bounded safe entry preview, selected extract/import, + folder download, and provider-owned ZIP creation. +- `.tar`: enabled for list, bounded safe entry preview, and selected + extract/import. +- `.tar.gz` / `.tgz`: enabled for list, bounded safe entry preview, selected + extract/import, and folder download. + +All enabled families must use relative UTF-8 paths only. Absolute paths, +traversal, symlinks, devices, hardlinks, FIFOs, and other non-file entries are +blocked or rejected before write. + +## Generic Family Review Gate + +No generic non-tar/non-zip family is approved in this branch. + +Before enabling a new family, the owner must record: + +- Dependency license and redistribution posture. +- Maintenance status, upstream CVE posture, and release cadence. +- Memory and CPU bounds for listing, preview, and extraction. +- Streaming/listing support without unbounded in-memory expansion. +- Unsafe-entry handling equivalent to current ZIP/tar policy. +- Password/encrypted archive posture, including explicit fail-closed behavior. +- Runtime/build impact and platform support. +- Security owner approval. + +## Current Decisions + +- `.7z`: first candidate for future review, but not enabled in this branch. +- `.rar`: blocked until licensing, decompression safety, and redistribution are + explicitly approved. +- `.tar.xz`, `.tar.bz2`, `.tar.zst`, `.xz`, `.bz2`, `.zst`, `.lz4`, and plain + `.gz`: policy-gated until dependency and release review is complete. + +Unsupported families remain visible as policy-gated archives in Library +Properties and Archive. They can be inspected for object identity and +policy status, but entry browsing, preview, and extraction fail closed. diff --git a/docs/CAPSULE_MODEL.md b/docs/CAPSULE_MODEL.md index 8eb767d4..fa218d9b 100644 --- a/docs/CAPSULE_MODEL.md +++ b/docs/CAPSULE_MODEL.md @@ -294,6 +294,9 @@ This implies: - providers should implement URI semantics - the Capsule Runtime should make execution portable - Carrier should stay below app semantics +- `localhost://WebSpaces//...` is the local mounted view; raw provider + targets such as `google://drive/...` or backing `elastos://content/...` + handles remain provider-resolved authority, not ordinary app storage ## Current Repo Mapping diff --git a/docs/CARRIER.md b/docs/CARRIER.md index 30c44987..3716e014 100644 --- a/docs/CARRIER.md +++ b/docs/CARRIER.md @@ -122,6 +122,19 @@ semantics and availability receipts. Carrier owns secure peer discovery, messaging, relay, and peer/object transport. IPLD gives the traversable CID graph shape. Rights/decryption remain in the access provider and dDRM layer. +Provider-to-provider Carrier invocation follows the same boundary. Runtime adds +an `elastos.provider.invocation/v1` envelope, selects `carrier-provider-plane`, +and sends a generic Carrier `provider_invoke` message only between service +providers such as `content`, `availability`, `rights`, `key`, `decrypt`, and +`drm`. Raw connect tickets stay inside Runtime transport state and are not +returned in app-visible receipts. Raw backend providers such as `ipfs` or +`localhost` remain local implementation details, not remote Carrier authorities. +If the provider transfer is `stream`, the Carrier side also validates the +target-visible `elastos.provider.stream/v1` base64-chunk contract before +dispatching the request. Carrier availability fetches use that path for remote +`content/fetch` calls and decode the returned stream envelope inside the +availability provider. + See [CONTENT_AVAILABILITY.md](CONTENT_AVAILABILITY.md). ### Where HTTP Fits diff --git a/docs/COMMAND_MATRIX.md b/docs/COMMAND_MATRIX.md index e8605776..fd050068 100644 --- a/docs/COMMAND_MATRIX.md +++ b/docs/COMMAND_MATRIX.md @@ -77,8 +77,8 @@ The detailed runtime/TTY/home contract for those paths lives in [INTERACTIVE_RUN | `elastos site rollback [release-or-bundle-cid]` | Re-points the active site head to a previous published site bundle or named release snapshot and records a new rollback activation | | `elastos site promote ` | Promotes a named release into an Edge release channel under `localhost://ElastOS/SystemServices/Edge/ReleaseChannels/...` | | `elastos site bind-domain` | Writes a runtime-owned public-edge domain binding under `localhost://ElastOS/SystemServices/Edge/Bindings/...` | -| `elastos webspace list [path]` | Queries the dynamic `localhost://WebSpaces//...` resolver surface directly. Today `Elastos` exposes typed children such as `content`, `peer`, `did`, and `ai`; deeper `peer` / `did` / `ai` traversal fails closed until richer resolver semantics exist | -| `elastos webspace resolve ` | Resolves a mounted WebSpace moniker or deeper handle path into a typed local handle. Current contract: `resolve` is handle-only; `content/` resolves to a file endpoint, `peer/`, `did/`, and `ai/` resolve to one typed folder handle, and `_meta.json` is a metadata file view for `read` / `stat`, not another handle | +| `elastos webspace list [path]` | Queries the dynamic mounted `localhost://WebSpaces//...` resolver surface directly. Today `Elastos` exposes typed children such as `content`, `peer`, `did`, and `ai`; deeper `peer` / `did` / `ai` traversal fails closed until richer resolver semantics exist | +| `elastos webspace resolve ` | Resolves a mounted WebSpace moniker or deeper handle path into a typed local handle. Current contract: `resolve` is handle-only; provider target URIs stay resolver-private; `content/` resolves to a file endpoint, `peer/`, `did/`, and `ai/` resolve to one typed folder handle, and `_meta.json` is a metadata file view for `read` / `stat`, not another handle | | `elastos run` (Data) | Power-user explicit path/CID launch. Data capsules are served in-process. | | `elastos home --status` | Host-side snapshot of Home state | | `elastos home --json` | Machine-readable host-side snapshot of Home state | diff --git a/docs/CONTENT_AVAILABILITY.md b/docs/CONTENT_AVAILABILITY.md index 2ec619e8..8db8eb61 100644 --- a/docs/CONTENT_AVAILABILITY.md +++ b/docs/CONTENT_AVAILABILITY.md @@ -5,9 +5,9 @@ > Current behavior has a first `content` provider seam that delegates to > `ipfs-provider` for local CID creation/pinning, blocks normal capsule > capability requests to `elastos://ipfs/*`, and records signed local -> availability receipts. This document defines where content addressing, IPLD, -> IPFS/Kubo, Carrier, and future replication providers belong as the SmartWeb -> content plane matures. +> availability receipts with peer-selection/quota/repair-worker metadata. This +> document defines where content addressing, IPLD, IPFS/Kubo, Carrier, and +> future replication providers belong as the SmartWeb content plane matures. ## Decision @@ -199,6 +199,18 @@ Minimum shape: "policy": "local_pin", "status": "local_pinned", "replicas": 1, + "peer_selection": { + "mode": "single_local", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "not_enforced", + "scope": "local_content_backend" + }, + "repair_worker": { + "scheduled": false, + "status": "not_scheduled" + }, "checked_at": 1777852800 }, "signer_did": "did:key:...", @@ -210,6 +222,35 @@ This receipt is not a payment proof and not a rights grant. It says a provider accepted or verified an availability responsibility for a CID under a policy. The first implementation appends signed JSONL receipts under runtime-managed system state and exposes the latest receipt through `elastos://content/status`. +It also appends an auditable `elastos.content.repair-task/v1` ledger under the +same system-owned content state, so local-only, queued, healthy, and retired +availability work is durable instead of only request-local. Local-only receipts +explicitly state that there is no live multi-peer proof and no enforced quota. A +configured availability provider may return richer `peer_selection`, `quota`, +and `repair_worker` metadata, but that metadata is not replication proof until +the provider proves live multi-peer availability. + +The built-in Carrier availability provider enforces replica-count ceilings when +selecting remote replication candidates and records a quota verdict in signed +availability receipts: `enforced`, `effective_max_replicas`, `used_replicas`, +and `within_quota`/`at_quota`/`requirements_exceed_quota` status. That is +bounded provider-plane quota enforcement for this proof path. Signed receipts +also include `elastos.content.accounting/v1` metadata with observed local +file/byte counts and replica-byte estimates when the provider operation exposes +that data. Signed receipts also include `elastos.content.abuse-controls/v1` +metadata; the built-in Carrier path records candidate limits, attempted remote +provider invocations, failure counts, and whether the local attempt cap +throttled candidates. Configured federated quota-ledger and abuse-control +exchanges can now gate remote admission preflight, but production storage quota +networks, billing, network-wide banlists/rate policy, abuse markets, and signed +cross-runtime peer reputation/attestation policy remain separate production +work. +Candidate selection is deterministic and locally scored from signed announcement +metadata plus bounded local success/failure history. Runtime startup loads and +persists that local peer reputation under system content state, and redacted +score/reason plus local reputation fields are included in peer-selection +receipts. Federated cross-runtime reputation remains separate production +policy. ## Availability Provider Seam @@ -225,20 +266,379 @@ ensure network availability: "uri": "elastos://bafy...", "policy": "network_default", "publisher_did": "did:key:...", + "requirements": { + "min_replicas": 1, + "max_replicas": null, + "require_live_multi_peer_proof": false + }, "local": { "status": "local_pinned", "provider": "ipfs-provider", - "replicas": 1 + "replicas": 1, + "peer_selection": { + "mode": "single_local", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "not_enforced", + "scope": "local_content_backend" + }, + "repair_worker": { + "scheduled": false, + "status": "not_scheduled" + } } } ``` A successful adapter may return `network_available` with a provider name and -replica count. A failing or malformed adapter is recorded as `repair_needed`. -If no availability provider is registered, the content provider reports the -honest local state. This is the seam where Elacity, supernode, volunteer, or -later market-backed availability providers should plug in without becoming -capsule-visible IPFS/Kubo/SDK authority. +replica count only when it also returns coherent `peer_selection`, `quota`, and +`repair_worker` metadata. Peer-selection metadata must name a concrete `mode` +or `strategy` so receipts do not sign anonymous availability claims. +Multi-replica claims must carry +`live_multi_peer_proof=true`; requested `min_replicas`, `max_replicas`, and +`require_live_multi_peer_proof` requirements are enforced before a signed +receipt can record network availability. Claims that miss those requirements are +recorded as `repair_needed`, not optimistic `network_available`. The external +availability-provider bridge also fails closed when a multi-replica upstream +claim omits peer-selection metadata. + +The built-in Carrier availability provider signs and broadcasts CID +availability announcements on deterministic Carrier topics. When matching +signed remote announcements already exist, it treats those announcements as +candidate peers, invokes the remote peer's `content/ensure` over the Carrier +provider plane, falls back first to bounded remote `content/import_object` for +manifest-backed content objects, then to bounded remote `content/import_exact` +for file-like CIDs when remote pin cannot fetch the object, verifies the same +CID with remote `content/status`, and records `network_available` only when an +independent remote provider proves a live pinned replica. `import_object` +reconstructs the directory/object from the provider-owned object manifest and +listed file bytes; `import_exact` accepts bytes through a provider stream +envelope. Both fail closed unless the remote low-level content backend produces +the exact requested CID; mismatched imports are unpinned. Successful remote +`ensure`, `import_object`, or `import_exact` responses may also carry the remote +content provider's signed availability receipt; Carrier verifies and summarizes +that receipt in the peer-selection proof when present, including safe +peer-selection, quota, repair-worker, and accounting posture without exposing +raw provider internals. +If only the local pin is proven, the honest state is +`carrier_announced`; if requested replica/live-proof requirements are not met, +the state is `repair_needed`. Repair-only local announcements omit fetch +descriptors and are ignored as replication/fetch candidates. This is the seam +where Carrier, Elacity, supernode, volunteer, or later market-backed +availability providers should plug in without becoming capsule-visible +IPFS/Kubo/SDK authority. + +`content-provider` also exposes a provider-only `repair_worker` operation. It +requires the Runtime provider invocation envelope, so app capsules cannot call +the autonomous worker directly through the capsule-facing content contract. The +worker reads the latest durable repair tasks, retries queued CIDs through the +same local pin plus availability-provider ensure path, writes a fresh signed +receipt, updates the repair task, and returns an explicit +`content_repair_worker_guardrail` quota/abuse-control receipt with run limits, +attempt budgets, and failure throttling state. Operators trigger the same path +with `elastos content repair-worker`, which calls the provider through Runtime +`ProviderInvocation` instead of raw provider JSON. Servers can also enable the +same bounded loop with `ELASTOS_CONTENT_REPAIR_SCHEDULER=true`; it is opt-in, +minimum-interval guarded, and uses the same run limit, attempt budget, and +failure budget controls as the manual worker. Worker runs and provider status +also expose an `elastos.content.repair-fleet/v1` policy/status surface. The +current repair fleet is intentionally scoped to a single Runtime: +`content-provider` is both the coordinator and local worker, scheduling is +driven by the durable repair task ledger, and external repair fleets, +storage-market admission, and settlement are explicitly reported as not +configured. +Provider status, per-CID status, and repair-worker runs also expose +`elastos.content.network-abuse-policy/v1`. That surface ties the existing signed +abuse-control receipts to one operator-visible policy: Runtime provider +invocation is required, Carrier provider-invocation candidate caps and remote +admission preflight are local guardrails, repair-worker attempt/failure budgets +are visible, and a configured +`ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_*` endpoint or bounded +endpoint quorum can enforce signed external abuse-control admission exchange. +Production network-wide throttles, banlists, and abuse ledgers remain outside +the current provider-local policy plane. +Provider-wide status also includes `elastos.content.operator-dashboard/v1`, a +derived operator view over the signed receipt, repair-task, and +storage-accounting ledgers. It reports storage pressure, top principals by active +content bytes, replica-byte estimates, quota-exceeded records, fleet-history +attempts, recent repair rows, live-proof counts, and a non-production federation +posture without exposing raw backend, Carrier peer, or market authority. +Carrier peer selection and content status also expose +`elastos.carrier.peer-reputation/v1` policy metadata. Current scoring uses local +Runtime success/failure history only; content status aggregates whether local +history was applied, whether reputation was not reported, and whether any +federated-policy receipts were observed. Signed cross-runtime reputation +attestations are explicitly not configured. +Carrier peer selection, redacted remote receipt summaries, provider proof +summaries, and the operator dashboard also expose +`elastos.carrier.peer-attestation-exchange-policy/v1`. The current policy +records signed availability announcements, verified remote content receipts, +remote provider proofs, and local Runtime reputation as available. When +`ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_*` is configured, Carrier posts a +signed `elastos.carrier.peer-attestation.exchange-request/v1` with redacted +live remote proof summaries to either one operator-owned exchange endpoint or a +bounded configured endpoint set with an explicit quorum after remote replica +proof succeeds. Accepted endpoint responses must include a signed +`elastos.carrier.peer-attestation.exchange-receipt/v1`; Carrier verifies the +receipt signature/domain, records endpoint receipts and quorum counters, and +marks the exchange accepted only when configured quorum accepts. Third-party +attestations, revocation, and production fleet-wide reputation policy remain +explicitly not configured. +Storage-market receipts and provider-wide status also expose +`elastos.content.storage-settlement-policy/v1`. The current policy records +pricing, escrow, payment settlement, SLA enforcement, storage-market admission, +and cross-provider escrow as `not_configured`; this is explicit operator +posture, not live settlement execution. +Storage-market receipts and provider-wide status also expose +`elastos.content.storage-market-admission-policy/v1`. The current policy records +the local principal quota ledger and bounded remote `content/admission` +preflight as proof-path admission. The remote preflight now carries a signed +`elastos.content.admission/v1` receipt, and Carrier rejects unsigned or +payload-mismatched admission before bytes or DAG repair data move. When +`ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_*` is configured, `content/admission` +also calls either one external storage-market admission endpoint or a bounded +configured endpoint set with an explicit quorum before remote bytes or DAG +repair data move. The accepted quorum decision is normalized into +`elastos.content.storage-market-admission.decision/v1`, credential details are +redacted from status, and rejection, malformed response, endpoint failure, or +quorum failure fails closed into the signed admission receipt. Price discovery, +SLA admission, settlement/escrow, and economic abuse controls remain explicitly +not configured. +Quota receipts and provider-wide status also expose +`elastos.content.federated-quota-ledger-policy/v1`. The current policy records +the durable local per-principal storage-accounting ledger and remote +`content/admission` preflight with signed admission receipts as available. When +`ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_*` is configured, +`content/admission` posts a signed +`elastos.content.federated-quota-ledger.exchange-request/v1` to either one +operator-owned quota-ledger endpoint or a bounded configured endpoint set with +an explicit quorum before remote bytes or DAG repair data move. Accepted +endpoint responses must include a signed +`elastos.content.federated-quota-ledger.exchange-receipt/v1`; the provider +verifies receipt signature/domain/schema, records endpoint receipts and quorum +counters in the signed admission receipt and quota-ledger policy, and rejects +admission fail-closed on configured quorum failure, malformed signed receipt, +timeout, or transport failure. Production independent provider-network +quota-ledger federation and production storage-admission networks remain +explicitly not configured. +Network-abuse policy status also records configured federated abuse-control +exchange posture. When +`ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_*` is configured, +`content/admission` posts a signed +`elastos.content.federated-abuse-control.exchange-request/v1` to either one +operator-owned abuse-control endpoint or a bounded configured endpoint set with +an explicit quorum after local principal quota accepts and before quota-ledger, +storage-market, remote byte, or DAG repair movement. Accepted endpoint +responses must include a signed +`elastos.content.federated-abuse-control.exchange-receipt/v1`; the provider +verifies receipt signature/domain/schema, records endpoint receipts and quorum +counters in the signed admission receipt, and rejects admission fail-closed on +configured quorum failure, malformed signed receipt, timeout, transport +failure, or receipt verification failure. Production network-wide banlists, +cross-provider rate limits, and abuse ledger federation remain explicitly not +configured. +Provider-wide status, repair-worker runs, and the operator dashboard also expose +`elastos.content.external-repair-fleet-policy/v1`. The current policy records the +provider-owned local repair worker/scheduler as available. When +`ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_*` is configured, the Runtime-gated +`repair_worker` can dispatch due tasks to either one operator-owned external +repair fleet endpoint or a bounded configured endpoint set with an explicit +quorum using `elastos.content.external-repair-fleet.dispatch-request/v1`; +responses are normalized into +`elastos.content.external-repair-fleet.dispatch-receipt/v1` with endpoint +receipts and quorum counters. +Worker attestation receipts, fleet settlement, and repair SLAs remain explicitly +not configured. +Provider-wide status and the nested operator dashboard also expose +`elastos.content.federated-operator-alerting-policy/v1`. The current policy +records provider-local status JSON, storage-pressure signals, repair-task +pressure, live-proof counters, remote-receipt counters, and optional +provider-local operator alert sink plus configured federated alert-exchange +posture as available through Runtime provider invocation. Operators may request +an `elastos.content.operator-alert/v1` +payload by calling provider-wide `content/status` with +`emit_operator_alert: true`. The provider always records a durable +`elastos.content.operator-alert.receipt/v1` outbox entry; when an operator sink +is configured, it also posts the alert to one HTTPS or loopback HTTP endpoint +without exposing sink credentials to apps or status JSON. When +`ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_*` is configured, the same +Runtime-gated alert emission also posts an +`elastos.content.federated-operator-alert.exchange-request/v1` payload to one +operator-owned federated alert-exchange endpoint, requires an explicit +`accepted` decision, and records the normalized +`elastos.content.federated-operator-alert.exchange-receipt/v1` inside the same +durable operator-alert receipt. Cross-provider dashboards, peer-health +subscriptions, fleet-wide SLA policy, and operator UI remain explicitly not +configured. + +The standalone `availability-provider` capsule preserves the same proof +metadata when an explicitly configured Elacity/supernode-compatible target +reports availability: `storage_market`, `repair_graph`, and `abuse_controls` +pass through to Runtime validation, and absent fields default to explicit +no-market / target-report-only posture. It also continues past `repair_needed` +configured targets and can aggregate multiple configured target reports into a +bounded `configured_availability_target_fanout` proof when min-replica or +live-proof requirements demand it. Max-replica quota still fails closed. This +keeps external target responses machine-readable without granting apps raw +target credentials or claiming a production storage market. + +Every signed availability receipt also projects into a durable +`elastos.content.storage-accounting.ledger/v1` JSONL ledger keyed by CID and +grouped by publisher principal. Publish, exact import, and manifest-object +import can enforce `availability_requirements.max_storage_bytes_per_principal` +from that ledger before bytes enter the local backend; rejected operations fail +with `storage_quota_exceeded` before IPFS/Kubo provider calls, while accepted +operations record `principal_storage_quota` posture in the signed receipt and +ledger. `content/status` without a CID returns a provider-owned +`elastos.content.availability.dashboard/v1` summary of the same latest receipt, +storage-accounting, and repair-task ledgers: tracked object counts, status +counts, provider counts, quota verdict counts, accounting byte/file counters, +per-principal active/tracked objects, content bytes, replica-byte estimates, +no-settlement storage-market policy metadata, storage-market admission policy +metadata, abuse-control counters, live +proof counts, remote replica and verified remote receipt counts, capped recent +remote-replica proof rows with redacted peer-selection score/reason and +local-runtime reputation, queued/due/healthy repair counts, recent repair +tasks, scheduler posture, repair-fleet task pressure, and network-abuse policy +posture. The same response includes the derived operator dashboard so storage +pressure and fleet history are visible without scraping lower-level ledgers. +Peer reputation and peer-attestation exchange policy posture are included in +proof summaries and recent remote replica rows. Federated operator alerting +policy posture is included at the provider-wide level and inside the operator +dashboard so alert readiness is machine-readable instead of buried in release +notes. The same status operation +can emit a provider-local operator alert receipt and optional configured webhook +delivery plus optional configured federated alert-exchange delivery when +explicitly requested. Peer-attestation exchange policy posture is included in +Carrier peer selection, provider proof summaries, and the operator dashboard so +live peer proof is not confused with a production cross-runtime trust network. +Carrier availability also emits `elastos.content.repair-graph/v1` metadata. +Object-manifest and exact-byte repair remain bounded fallbacks, while arbitrary +`ipld_dag` repair now routes through the Runtime-only `elastos://block-graph/*` +provider path: Carrier asks local `content-block-graph-provider.export_graph`, +then invokes remote `content-block-graph-provider.import_graph` over the +provider plane. The provider uses the local `ipfs-provider` Kubo coordination +file to exchange bounded DAG CAR bytes and pin the imported root. If Kubo or +the provider is absent, the path fails closed and still refuses object/exact +fallback. +Operators can inspect the same availability/storage state with `elastos content status` for the +provider-wide dashboard or `elastos content status --cid ` for a single +object; both commands route through Runtime provider invocation and expose the +provider status JSON without granting app capsules raw backend authority. +With the built-in Carrier provider, that retry can execute the same remote +`content/admission` + `content/ensure` + exact-import fallback + +`content/status` proof path against signed remote announcements. Remote +admission is a provider-only preflight: the remote content provider evaluates +projected principal storage quota and returns an +`elastos.content.admission/v1` receipt before Carrier sends exact bytes, object +manifests, or block-graph repair data. Carrier verifies that signed receipt and +its payload/CID binding before trusting the admission decision. When a live +multi-peer proof is required, Carrier keeps at least one remote provider +invocation in the candidate budget when quota permits it, even if the reported +local replica count already satisfies `min_replicas`. +Verified remote content-provider receipts are summarized with safe +peer-selection replica counts plus capped redacted score/reason/local +reputation rows with explicit cap/truncation metadata, admission, quota, +repair-worker, accounting, and abuse-control posture. This is +provider-mediated autonomous cross-peer repair for announced Carrier peers; it +is not yet a complete global storage market. +External availability providers still own production peer admission across +independent provider networks, production independent provider-network +quota-ledger federation beyond the configured bounded endpoint quorum, +pricing/escrow/settlement, actual +federated network abuse throttles/banlists/abuse ledgers beyond the configured +bounded abuse-control endpoint quorum, production repair execution across +Carrier/supernode/volunteer fleets beyond the current external-fleet +policy-status receipts, production peer reputation trust policy, third-party +attestations, revocation, and fleet-wide reputation exchange beyond the +configured Carrier peer-attestation endpoint quorum, +production storage-market admission/execution beyond the current signed +admission proof path, configured storage-market endpoint-quorum admission gate, +and storage-market-admission policy-status receipt, live +settlement/escrow execution +beyond the current storage-settlement policy-status receipt, and +production federated storage dashboards/UI, peer-health subscriptions, and +fleet-wide alert policy beyond the current provider-local alert sink plus +configured alert-exchange endpoint. + +Fetch uses the same seam in reverse. `content-provider` tries the local CID +backend first; if the local cache misses, it asks the internal availability +provider for the same CID/path. Fetch requests may include a Runtime +provider-transfer contract: + +```json +{ + "range": { "start": 0, "end": 65535 }, + "progress": { + "request_id": "content-fetch:...", + "expected_bytes": 65536 + } +} +``` + +The content provider passes that contract to the local `ipfs-provider` read or +availability-provider fallback read, lets the Runtime provider registry enforce +bounded byte-range slicing on the provider response, and returns the typed +`elastos.provider.transfer/v1` receipt with source, target, capability, +transport, range, and progress metadata in the fetch response. This is still a +bounded JSON/base64 byte path, not the final streaming ABI. + +Carrier now has a typed internal +`content_fetch` byte operation on its file ALPN: a connected Runtime peer can +request a CID/path and the serving Runtime reads bytes through its local +`ipfs-provider`. That operation is still Runtime-internal; normal capsules do +not receive raw Carrier tickets, peer handles, or Kubo authority. It remains a +narrow compatibility/bootstrap path. + +Internal `content-provider` calls now use the Runtime provider-to-provider +invocation envelope for IPFS and availability effects. The envelope validates +source, target, operation, transfer class, byte range, and progress receipt +metadata. The provider plane now has explicit local and Carrier transports: +Carrier `provider_invoke` runs over the Carrier ALPN with the same +`elastos.provider.invocation/v1` envelope, hides raw connect tickets from +receipts, and only dispatches to service providers (`content`, `availability`, +`rights`, `key`, `decrypt`, `drm`) rather than raw backends. +`ProviderTransfer::Stream` now carries validated +`elastos.provider.stream/v1` base64 chunks with range/progress receipts, and +`content-provider` fetch opens that stream envelope as a Runtime-owned session +for local IPFS or availability-provider reads. The session exposes read-next +backpressure, live progress events, and cancel support without exposing raw +provider or Carrier handles to apps. +The built-in Carrier availability provider embeds internal fetch descriptors in +signed CID availability announcements. On a local cache miss it verifies +matching signed announcements, extracts the Carrier fetch ticket internally, and +uses `carrier-provider-plane` `provider_invoke` to call remote +`content/fetch` with `local_only: true` and `transfer: "stream"`, so remote +peers serve from their own content provider without recursive availability +loops or app-visible backend authority. The built-in availability provider +decodes the returned stream envelope before returning the normal byte response. +For replication, the same provider plane now calls remote `content/ensure`, +remote `content/import_object` when a manifest-backed object needs +provider-owned reconstruction, remote `content/import_exact` when a file-like +CID needs byte-push fallback, and remote `content/status`; `network_available` +requires that remote proof. +Verified remote content availability receipts are included in the proof +metadata when the remote content provider returns them, including safe +peer-selection, quota, repair-worker, and accounting posture; selected remote +replicas also include redacted local score and selection reason. No-CID +`content/status` returns the provider-owned availability dashboard from the same +signed receipt and repair-task ledgers, including quota verdict, live proof, +remote replica, verified remote receipt counters, capped recent remote-replica +proof rows, and local accounting counters. +Remaining work beyond this first proof path is arbitrary block-level/IPLD DAG +repair for non-manifest graphs, production scheduling policy beyond the current +opt-in bounded loop, richer remote/multi-peer dashboard/UI and federated +alerting surfaces beyond the provider status summary counters plus the +provider-local alert sink plus configured alert-exchange endpoint, external +storage quota enforcement/accounting markets beyond local receipt accounting, +network abuse policy beyond local provider-invocation guardrails, +historical/federated peer reputation beyond durable local scoring, and durable +remote peer-selection policy beyond verified receipt summaries and capped +dashboard rows. +`_runtime_invocation` and `_runtime_transfer` are +Runtime-owned fields; source providers cannot predeclare or spoof them in the +target request. ## dDRM and Protected Content @@ -273,9 +673,11 @@ In implementation terms, this means: - repair workers keep receipts fresh and retry failed replication - future blockchain incentives pay for proven storage/serving work -This belongs behind the Carrier/provider plane. Carrier coordinates peers and -secure messages. The content provider owns availability policy. IPFS/Kubo and -cluster-like systems move and replicate CID blocks underneath. +This belongs behind the Carrier/provider plane. Carrier coordinates peers, +availability announcements, and eventually content transport/repair. The +content provider owns availability policy and receipts. IPFS/Kubo and +cluster-like systems are backend implementations for CID creation, pinning, +fetch, or replication underneath. ## Red Lines @@ -292,13 +694,31 @@ The existing `ipfs-provider` is the right low-level starting point because it already wraps Kubo behind a provider boundary. The first `content` provider seam now sits above it for Documents publish/unpublish, reports honest `local_pinned` / `local_unpinned` availability state, calls a registered -availability provider to upgrade publish/ensure results to `network_available` -or `repair_needed` when configured, writes signed availability receipts, routes +availability provider to upgrade publish/ensure results to +`carrier_announced`, `network_available`, or `repair_needed` when configured, +writes signed availability receipts with peer-selection/quota/repair-worker +metadata, routes Documents, Share, Site, and provenance attestation writes through the same content path, routes ordinary capsule publishes through content availability, -adds a first fetch operation for simple CID/path reads, routes gateway CID file -reads and share metadata/head reads through that fetch path, adds local -ensure/repair operations that re-pin a CID or record `repair_needed`, injects +adds a first fetch operation for simple CID/path reads, falls back to an +availability provider on local fetch miss, adds a Carrier `content_fetch` +operation for connected Runtime peers to serve CID/path bytes from their local +content backend, uses Carrier `provider_invoke` for availability-provider peer +fetches, routes gateway CID file reads and share metadata/head reads through +that fetch path, adds local ensure/repair operations that re-pin a CID +or record `repair_needed`, records durable +`elastos.content.repair-task/v1` entries for local-only, queued, healthy, and +retired availability state, records `elastos.content.accounting/v1` +file/byte/replica-byte estimates in signed receipts and the no-CID status +dashboard, and exposes a Runtime-provider-only +`repair_worker` pass that retries queued CIDs through the same +provider-mediated repair/ensure path with bounded run limits, attempt budgets, +failure throttling, explicit local guardrail receipts, and an +`elastos content repair-worker` operator trigger plus an opt-in +`ELASTOS_CONTENT_REPAIR_SCHEDULER=true` server loop that both route through +Runtime provider invocation, and a provider-owned availability dashboard from +no-CID `content/status`, +injects deterministic `_elastos_object.json` manifests into directory publishes, materializes data-capsule opens from that manifest through the content provider with size/hash checks, routes `run --cid` and `serve --cid` materialization @@ -310,12 +730,23 @@ release/head/installer objects while preserving raw release CIDs for update compatibility. `elastos open` now treats release object CIDs as release metadata graphs, verifies the signed release/head envelope before summarizing them, and CID materialization rejects release objects as non-launchable instead of treating -them as generic directories. The first `availability-provider` capsule can now -be registered for configured Elacity/supernode-compatible targets by setting -`ELASTOS_AVAILABILITY_ENSURE_URL` or `ELASTOS_AVAILABILITY_PROVIDER_CONFIG`; if -no target is configured, the provider is not registered and publish remains -honestly local. Public gateway installer publishing now also uses -`elastos://content/publish` instead of direct IPFS. The protected-content +them as generic directories. The first external `availability-provider` capsule +can now be registered for configured Elacity/supernode-compatible targets by +setting `ELASTOS_AVAILABILITY_ENSURE_URL` or +`ELASTOS_AVAILABILITY_PROVIDER_CONFIG`. If no external target is configured, +the built-in Carrier availability provider signs and announces the CID on +Carrier, attempts remote provider-mediated replication proof from signed remote +announcements, uses bounded `content/import_exact` byte-push fallback for +file-like exact-CID imports and bounded `content/import_object` fallback for +manifest-backed content objects when remote pin cannot fetch, records +`network_available` only after remote `content/ensure`/`import_object` or +`import_exact` plus `content/status` proves a live independent replica, +verifies/summarizes remote content availability receipts when present, and otherwise records +`carrier_announced` or `repair_needed`; if Carrier is unavailable, publish +remains honestly local or `repair_needed`. Public gateway +installer publishing now also uses `elastos://content/publish` instead of +direct IPFS. The +protected-content foundation now adds shared sealed-object schemas, a fail-closed `drm-provider` for `elastos://drm/open`, a fail-closed `rights-provider` for typed access questions, a fail-closed `key-provider` for key release, a fail-closed @@ -328,10 +759,10 @@ rights reads. The content provider now validates and protected-content algorithm allowlists. The remaining work is: -- Library and share metadata should display object identity and availability without exposing raw backend details. - Wire sealed-object publish/open to rights, PQ-hybrid key release, and decrypt/render providers without exposing raw CEKs or backend SDKs to app capsules. - Move remaining release artifact uploads off explicit operator IPFS tooling when the release pipeline can use the same content-provider contract without losing install compatibility or build/release proof. - MicroVM materialization paths should consult availability state and repair/fetch through explicit operator/provider-plane tooling. +- Promote the first Carrier remote proof path into production-grade storage policy: production independent provider-network quota-ledger federation beyond the configured bounded endpoint quorum, repair-fleet worker attestations/SLA/settlement beyond the configured external dispatch endpoint quorum, live market pricing/escrow/settlement beyond the configured storage-market endpoint-quorum admission gate and current storage-settlement policy-status receipt, actual federated network abuse throttles/banlists/abuse ledgers beyond the configured bounded abuse-control endpoint quorum, richer remote storage receipt policy, production peer reputation trust policy/third-party attestations/revocation beyond the configured Carrier peer-attestation endpoint quorum, and live federated dashboard/UI/peer-health subscriptions beyond the current provider-local dashboard plus configured alert-exchange endpoint. - Raw gateway, Kubo RPC, IPFS Cluster, and Elacity SDK authority should stay unavailable to normal capsules. Configured provider shape: @@ -347,3 +778,197 @@ or: ```bash ELASTOS_AVAILABILITY_PROVIDER_CONFIG='{"targets":[{"id":"elacity-supernode","ensure_url":"https://your-supernode.example/availability/ensure"}]}' ``` + +Optional provider-local operator alert sink: + +```bash +ELASTOS_CONTENT_OPERATOR_ALERT_URL=https://ops.example/content-alerts +ELASTOS_CONTENT_OPERATOR_ALERT_AUTHORIZATION='Bearer ...' +ELASTOS_CONTENT_OPERATOR_ALERT_TIMEOUT_SECS=5 +``` + +or: + +```bash +ELASTOS_CONTENT_OPERATOR_ALERT_CONFIG='{"url":"https://ops.example/content-alerts","authorization":"Bearer ...","timeout_secs":5}' +``` + +The sink is operator-owned and provider-local. `https://` is required unless the +target is loopback `http://127.0.0.1`, `http://localhost`, or `http://[::1]`. +Credentials are accepted only through provider configuration and are redacted +from status. Alerts are sent only when the operator explicitly asks for +provider-wide status with `emit_operator_alert: true`; every request writes a +receipt to the provider-owned alert outbox even when no sink is configured. + +Optional federated operator alert exchange: + +```bash +ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_URL=https://ops.example/alerts/exchange +ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_AUTHORIZATION='Bearer ...' +ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_TIMEOUT_SECS=5 +``` + +or: + +```bash +ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_CONFIG='{"url":"https://ops.example/alerts/exchange","authorization":"Bearer ...","timeout_secs":5}' +``` + +The exchange endpoint is operator-configured, provider-owned, and invoked only +from explicit provider-wide status alert emission. `https://` is required unless +the target is loopback `http://127.0.0.1`, `http://localhost`, or +`http://[::1]`. The response must include an `accepted` boolean and may include +`exchange_id`, `receipt_id`, and `reason`. Malformed responses, timeouts, and +transport failures produce failed federated exchange receipts without exposing +endpoint credentials. + +Optional Carrier peer-attestation exchange: + +```bash +ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_URL=https://attest.example/peer-attestation/exchange +ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_AUTHORIZATION='Bearer ...' +ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_TIMEOUT_SECS=5 +``` + +or: + +```bash +ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_CONFIG='{"url":"https://attest.example/peer-attestation/exchange","authorization":"Bearer ...","timeout_secs":5}' +``` + +or a bounded endpoint quorum: + +```bash +ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_CONFIG='{"quorum":2,"endpoints":[{"id":"attest-a","url":"https://attest-a.example/peer-attestation/exchange","authorization":"Bearer ...","timeout_secs":5},{"id":"attest-b","url":"https://attest-b.example/peer-attestation/exchange","authorization":"Bearer ...","timeout_secs":5}]}' +``` + +The endpoint set is operator-configured, provider-owned, capped at five +endpoints, and invoked only by `carrier-availability` after it has live remote +provider proofs to attest. `https://` is required unless the target is loopback +`http://127.0.0.1`, `http://localhost`, or `http://[::1]`. Carrier signs the +exchange request and requires accepted endpoint responses to include a signed +exchange receipt; configured quorum failure, malformed responses, failed receipt +verification, timeouts, or transport failures produce failed +attestation-exchange policy receipts without exposing connect tickets or +endpoint credentials. + +Optional federated quota-ledger exchange: + +```bash +ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_URL=https://quota.example/exchange +ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_AUTHORIZATION='Bearer ...' +ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_TIMEOUT_SECS=5 +``` + +or: + +```bash +ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_CONFIG='{"url":"https://quota.example/exchange","authorization":"Bearer ...","timeout_secs":5}' +``` + +or a bounded endpoint quorum: + +```bash +ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_CONFIG='{"quorum":2,"endpoints":[{"id":"ledger-a","url":"https://quota-a.example/exchange","authorization":"Bearer ...","timeout_secs":5},{"id":"ledger-b","url":"https://quota-b.example/exchange","authorization":"Bearer ...","timeout_secs":5}]}' +``` + +The endpoint set is operator-configured, provider-owned, capped at five +endpoints, and invoked only from `content/admission` after the local principal +quota preflight accepts and before Carrier transfers remote bytes or DAG repair +data. `https://` is required unless the target is loopback +`http://127.0.0.1`, `http://localhost`, or `http://[::1]`. The provider signs +`elastos.content.federated-quota-ledger.exchange-request/v1`; accepted +responses must include a signed +`elastos.content.federated-quota-ledger.exchange-receipt/v1`. Rejection, +configured quorum failure, malformed signed receipt, timeout, transport +failure, or receipt verification failure rejects admission fail-closed without +exposing endpoint credentials. + +Optional federated abuse-control exchange: + +```bash +ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_URL=https://abuse.example/exchange +ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_AUTHORIZATION='Bearer ...' +ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_TIMEOUT_SECS=5 +``` + +or: + +```bash +ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_CONFIG='{"url":"https://abuse.example/exchange","authorization":"Bearer ...","timeout_secs":5}' +``` + +or a bounded endpoint quorum: + +```bash +ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_CONFIG='{"quorum":2,"endpoints":[{"id":"abuse-a","url":"https://abuse-a.example/exchange","authorization":"Bearer ...","timeout_secs":5},{"id":"abuse-b","url":"https://abuse-b.example/exchange","authorization":"Bearer ...","timeout_secs":5}]}' +``` + +The endpoint set is operator-configured, provider-owned, capped at five +endpoints, and invoked only from `content/admission` after local principal quota +accepts and before any later quota-ledger, storage-market, byte-transfer, or +repair-graph movement. `https://` is required unless the target is loopback +`http://127.0.0.1`, `http://localhost`, or `http://[::1]`. The provider signs +`elastos.content.federated-abuse-control.exchange-request/v1`; accepted +responses must include a signed +`elastos.content.federated-abuse-control.exchange-receipt/v1`. Rejection, +configured quorum failure, malformed signed receipt, timeout, transport +failure, or receipt verification failure rejects admission fail-closed without +exposing endpoint credentials. + +Optional storage-market endpoint-quorum admission gate: + +```bash +ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_URL=https://market.example/admission +ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_AUTHORIZATION='Bearer ...' +ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_TIMEOUT_SECS=5 +``` + +or one explicit client config: + +```bash +ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_CONFIG='{"url":"https://market.example/admission","authorization":"Bearer ...","timeout_secs":5}' +``` + +or a bounded endpoint quorum: + +```bash +ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_CONFIG='{"quorum":2,"endpoints":[{"id":"market-a","url":"https://market-a.example/admission","authorization":"Bearer ...","timeout_secs":5},{"id":"market-b","url":"https://market-b.example/admission","authorization":"Bearer ...","timeout_secs":5}]}' +``` + +The endpoint set is operator-configured, provider-owned, capped at five +endpoints, and invoked only from `content/admission`. `https://` is required +unless the target is loopback `http://127.0.0.1`, `http://localhost`, or +`http://[::1]`. Each response must include an `accepted` boolean and may include +`status`, `reason`, `market_id`, `offer_id`, and `receipt`. Rejection, +malformed response, configured quorum failure, timeout, or transport failure +rejects the admission before Carrier transfers bytes or DAG repair data. + +Optional external repair-fleet dispatch: + +```bash +ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_URL=https://repair.example/dispatch +ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_AUTHORIZATION='Bearer ...' +ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_TIMEOUT_SECS=5 +``` + +or: + +```bash +ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_CONFIG='{"url":"https://repair.example/dispatch","authorization":"Bearer ...","timeout_secs":5}' +``` + +or a bounded endpoint quorum: + +```bash +ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_CONFIG='{"quorum":2,"endpoints":[{"id":"repair-a","url":"https://repair-a.example/dispatch","authorization":"Bearer ...","timeout_secs":5},{"id":"repair-b","url":"https://repair-b.example/dispatch","authorization":"Bearer ...","timeout_secs":5}]}' +``` + +The endpoint set is operator-configured, provider-owned, capped at five +endpoints, and invoked only by the Runtime-gated `content repair-worker` path +after local guardrails select a due repair task. `https://` is required unless +the target is loopback `http://127.0.0.1`, `http://localhost`, or +`http://[::1]`. Each response must include an `accepted` boolean and may include +`status`, `reason`, `fleet_id`, `job_id`, and `receipt`. Dispatch acceptance is +recorded only when configured quorum accepts as an external fleet +receipt, but local provider verification still decides final availability. diff --git a/docs/DECRYPT_PROVIDER.md b/docs/DECRYPT_PROVIDER.md index c8f98ed3..6bb6f53c 100644 --- a/docs/DECRYPT_PROVIDER.md +++ b/docs/DECRYPT_PROVIDER.md @@ -31,7 +31,8 @@ Unsupported operations fail closed and do not create broad provider wildcards. - object CID - requested action - viewer interface -- key-release receipt ID +- typed `elastos.release.receipt/v1` from `key-provider`, bound to the same + principal/session/object/action - output kind - reason - expiry diff --git a/docs/FILE_MANAGER_MIGRATION.md b/docs/FILE_MANAGER_MIGRATION.md new file mode 100644 index 00000000..b9e37299 --- /dev/null +++ b/docs/FILE_MANAGER_MIGRATION.md @@ -0,0 +1,563 @@ +# Explorer Migration Plan + +> PC2 baseline: `Elacity/pc2.net` `main` +> `a0a910158bd67666a6d3ea2a775ce09005ba7ae7` (`v1.3.0`). + +This is the contract for bringing PC2's Explorer experience onto ElastOS +Runtime rails. The product goal is PC2-familiar file management. The security +goal is no PC2 authority shortcut. + +## Decision + +Use the existing `library` capsule as the first Explorer surface. Do not add +a second competing file app unless a later product decision explicitly renames +the surface. The visible UI should be a PC2-style Explorer; the Runtime +contract should be a typed object provider. + +## Current Branch State + +Implemented in this branch: + +- Typed object provider rail exposed to the browser through the canonical + `/api/provider/object/:op` route. +- Standalone `object-provider` capsule process for principal-root Library + object storage and event authority; the `library` app remains UI-only and + Runtime injects the signed principal before forwarding provider operations. +- Principal-scoped roots, list, stat, read/download, write/upload, mkdir, + rename, trash, restore, delete-permanently, status, publish, unpublish, + repair, share, provider-owned `.tar.gz`/`.zip` folder archive download, + provider-owned `.tar.gz`/`.zip` selected-object archive download, + provider-owned same-folder `Compress to ZIP` object creation for files, + folders, and same-folder selections, + `.tar`/`.tar.gz`/`.tgz`/`.zip` extraction, and typed object events. +- SSE event stream for active Library windows, with app-side refresh only when + an event touches the current folder. +- Provider tests for token scope, principal isolation, traversal rejection, + encrypted-root writes/reads, object lifecycle, content-provider publish + handoff, no-content fail-closed behavior, share requiring an active published + object, unpublish/repair status transitions, and typed event filtering. +- PC2-style Explorer UI with Places, breadcrumbs, grid/list views, upload, new + folder, text document, inline draft naming, inline rename, context actions, + file download, folder archive download, `Download as ZIP` for folders, + selected-object archive download, `Download Selected as ZIP`, + provider-backed `Compress to ZIP` and `Compress Selected to ZIP`, + `Extract Here` for `.tar`/`.tar.gz`/`.tgz`/`.zip` archives, publish, share, unpublish, + status, repair, public-link share receipt, signed-principal `Check My Access` + receipt UX for shared published objects, properties, recoverable Trash + actions for `.Trash` objects, and Chat Room attach mode. +- PC2-style first-level context-menu groups for installed viewers (`Open With`), + folder sorting (`Sort By`), view mode (`View`), and creation (`New`). The app + hides unsupported actions instead of shipping inert menu rows. +- PC2-style `Open in New Window` for sidebar places and active folder items, + routed through Home's signed `library -> library` open-target policy instead + of a PC2 global shell/window shortcut. +- Per-file upload progress in the UI, a raw Runtime upload route + (`PUT /api/provider/object/upload`) for small browser file uploads, and + Runtime upload sessions (`start` / bounded `chunk` / `finish` / `cancel`) for + large uploads. User file uploads no longer travel through JSON/base64 `write`; + text/editor writes still use the JSON provider operation intentionally. +- Raw download route (`GET /api/provider/object/download/raw`) for principal-root + file/folder archive downloads, selected-object archive downloads, and + WebSpace resolver/materialized files. Directory and selected-object archive + downloads default to `.tar.gz` and accept explicit `archive=zip`. Library + browser downloads no longer travel through JSON/base64 `download`; the raw + route supports single HTTP byte-range responses. Preview/read paths still use + the typed JSON provider operation intentionally. +- Raw upload/download routes emit Runtime transfer receipts (`schema`, + `request_id`, `op`, `transport`, `status`, byte counts, and optional range + metadata). Chunked large uploads add `elastos.object.upload-session/v1` / + `http-chunk-session` receipt metadata so operator smokes can prove bounded + browser chunks crossed the Runtime/audit boundary before the final + object-provider commit. +- Multi-select with context-menu batch publish, unpublish, trash, restore, + delete, cut, and copy through the same provider operations. +- PC2 Explorer visual parity pass: compact PC2 sidebar favorites, PC2 + SVG icon assets, icon-tile grid with PC2 dimensions, details-list + columns/headers, navbar back/forward/up controls, PC2-like selection states, + footer density, context-menu density, and F2 rename. +- Drag/drop move between folders/places through the typed provider `move` + operation with revision checks and object events. +- Drag/drop copy between folders/places through the typed provider `copy` + operation with revision checks and object events. Copy is explicit via + Alt-drop or context-menu Copy/Paste. +- Browser-native Back/Forward is mapped onto Explorer folder history. The first + Library history entry is guarded so browser Back does not accidentally leave + the capsule. +- Large folder rendering now avoids full synchronous repaint: Library reuses + unchanged item DOM nodes by URI/signature and paints large folders in chunks + so first content appears before all rows are appended. +- Library rendering is split into `src/render.js` so the large-folder hot path, + keyed DOM reuse, footer/view-state sync, and first-paint telemetry stay + isolated from provider orchestration and object actions. +- Library preview handling is split into `src/preview.js` so provider-backed + preview reads and blob URL lifecycle stay isolated from app orchestration. +- Library inline create/rename behavior is split into `src/editor.js` so draft + object insertion, rename input lifecycle, Enter/Escape/blur handling, and + provider-backed commit callbacks stay isolated from app orchestration. The + module receives provider and refresh helpers by injection and does not own + raw storage or content authority. +- Library object actions are split into `src/actions.js` so open/viewer handoff, + upload/download, publish/share/status/repair, trash/restore/delete, + clipboard paste/move/copy, and create actions are separated from the app + event/render orchestrator. Authority still flows only through injected + Runtime provider/Home helpers. +- Library state/cache ownership is split into `src/state.js` so in-memory UI + state, visible-object filtering/sorting, folder-listing signatures, and + stale-cache invalidation metadata are separated from provider orchestration. + This module has no backend, Carrier, Kubo/IPFS, network, wallet, or host + filesystem authority. +- Library UI event binding is split into `src/events.js` so places, + breadcrumbs, toolbar clicks, content selection/open/context-menu behavior, + drag/drop, keyboard shortcuts, browser history popstate, and unload cleanup + are separated from provider orchestration. The module receives actions and + navigation helpers by injection and does not call the provider directly. +- Library realtime refresh is split into `src/realtime.js` so SSE/EventSource + lifecycle, reconnect timers, current-folder event matching, and debounced + refresh scheduling stay isolated from the app shell. The module only calls + the injected folder refresh path and does not own provider/backend authority. +- PC2 treats the desktop as an item container. Runtime mirrors that without + giving Home filesystem authority: Home summary projects + `localhost://Users//Desktop` through `object-provider`, and Home + only owns desktop placement/opening. File mutations remain in Library. +- Viewer routing only exposes installed viewer capsules from Runtime capsule + manifests, with properties/details fallback when no verified viewer exists. +- Archive-like files advertise the installed `archive-manager` viewer. Archive + Manager uses Runtime viewer routes for stat, supported-family entry listing, + bounded safe entry preview, destination roots, and selected extraction. It + never extracts unsupported bytes or touches host storage. +- Documents viewer routing can open and save a concrete Library object through + `/api/viewers/documents/library-object`; the viewer route checks that + Documents is an installed viewer for the object and keeps raw Library + provider access scoped to the Library capsule. +- Legacy plaintext objects left in the protected principal root are + auto-protected by `object-provider` on first access so old dev files regain + normal capabilities without weakening the protected-root rule. +- Published object status/repair is exposed through the context menu and + availability detail dialog, including content CID, status, publish/share + timestamps, and provider receipts. +- Spaces are visible as resolver roots with explicit provider capability + metadata. The Library UI must make readonly resolver mounts explicit: sidebar + Spaces are navigation-only where appropriate, mutable actions are + hidden/fail-closed for `readonly` handles, and an empty resolver state + explains that mounted provider-backed spaces appear only when a WebSpace + resolver is available. Mutable WebSpace mounts/forks may expose New/Upload/ + Delete only when the WebSpace provider marks the current handle writable. + `localhost://WebSpaces//...` is the Library-visible mounted handle; + provider targets such as `google://drive/...` or backing + `elastos://content/*` identities stay behind resolver/provider authority. +- Library object summaries expose typed WebSpace resolver metadata so mounted + handles can show mount, provider, resolver state, read-only/access-policy + state, and resolved target URI without exposing raw provider credentials or + transport handles to the app. +- `webspace-provider` now persists mount and resolver-index tables plus + provider-owned object-head/cache/sync/fork metadata and a local materialized + object table for mutable WebSpace objects, exposes typed + `mounts`/`mount`/`unmount`/`index`/`health`/`refresh`/`head`/`cache`/`cache_status`/`sync`/`sync_status`/`fork`/`write`/`mkdir`/`delete` + operations, and has CLI support for the lifecycle/read-only resolver verbs through + `elastos webspace mounts|mount|unmount|index|health|refresh|head|cache|cache-status|sync|sync-status|fork`. + Custom mounts map `localhost://WebSpaces//...` to resolver-private + targets such as `google://drive/...`; resolver indexes can expose discovered + child handles, health reports mounted-no-index/metadata-ready/dirty-head + state, refresh can replace resolver metadata, and cache/sync can advance + provider-owned metadata/fork heads without exposing provider credentials. + Mutable mounts/forks can materialize local provider-owned bytes and folders; + live external traversal, provider streaming, and remote sync workers remain + separate provider responsibilities. +- WebSpace adapters also expose safe liveness receipts through `check_adapter`. + Health can now distinguish configured/unchecked, connected/unverified, + healthy, stale, unavailable, and disabled resolver adapters while keeping + endpoint credentials redacted. This is an operator/provider readiness signal, + not a claim that Runtime can traverse external bytes yet. +- The Runtime provider registry has a provider-to-provider invocation envelope. + `content-provider` uses it for internal IPFS/availability effects, while the + envelope now validates byte-range/progress receipt metadata and applies + byte-range slicing to provider `data.data` base64 payloads for bounded + `ProviderTransfer::Bytes` calls. `ProviderTransfer::Stream` now uses a + validated `elastos.provider.stream/v1` base64-chunk envelope with the same + range/progress contract, and Runtime opens it as a stream session with + read-next backpressure, progress events, and cancel support. +- Published-object sharing now has a recipient-scoped `shared_access` gate for + recorded Library share grants plus persisted key-release receipts. Library + exposes an in-app share policy dialog for public-link and recipient-scoped + grants, status/share dialogs show grant and key-release state, and + authorized recipient checks return explicit access-decision and shared-open + contracts only when Runtime recipient-proof state matches the requested + recipient. Gateway requests strip app-supplied recipient proof and inject + launch-grant proof only for the signed session principal when the session is + passkey-proof-bound. Unauthorized recipients and recipient-scoped requests + without Runtime proof fail closed and are audited. The protected-content + provider contracts now bind key release to an allowed rights-decision receipt + and bind decrypt/render to a typed key-provider release receipt. The branch + also includes a non-production `protected_content_fixture` path that publishes + a sealed-object descriptor, records recipient-scoped key-release grants, + invokes DRM/rights/key/decrypt fixture providers, and returns a viewer-scoped + protected-open contract without exposing raw keys or plaintext. Production + encrypted payload generation, real dDRM policy reads, and production dKMS + remain future Trusted content work. +- Content availability receipts now include peer-selection, quota, and repair + worker metadata. Local-only receipts state that there is no live multi-peer + proof; configured availability providers may pass through richer policy + metadata. + +Still pending: + +- Richer type-aware previews and chooser-style Open With selection once more + viewer capsules are available. Current installed-viewer handoff covers + Documents for text/markdown/PDF and GBA Emulator for `.gba`, `.gb`, and + `.gbc` ROM objects. +- Production protected-content backends and richer policy UX beyond the current + fixture-backed receipt chain: approved dDRM rights reads, production dKMS key + release, real encrypted payload generation, and production decrypt/render + backends. +- Generic non-tar/non-zip archive extraction beyond current provider-owned + `.tar.gz`/`.zip` folder and selected-object archive downloads, same-folder ZIP + object creation, `.tar`/`.tar.gz`/`.tgz`/`.zip` browsing/preview/extraction, + and WebSpace archive import/write-back policy. Policy-gated generic archive + families are recognized and can open Archive, but their entry + browsing/importing remains disabled until dependency/release-policy review. +- Automated live external resolver adapters, external resolver byte traversal, + byte cache/sync workers, mutable fork byte materialization, and + external cloud/provider adapters beyond the current persisted + mount/index/object-head/health/refresh/cache/sync/fork metadata receipts. +- Live multi-peer replication proof, enforced quotas, repair workers, + peer-selection policy, and abuse controls beyond the receipt metadata. + +## Source Of Truth + +PC2 is the design and behavior reference for the first pass: + +- `src/gui/src/UI/UIItem.js` +- `src/gui/src/UI/UIDesktop.js` +- `src/gui/src/IPC.js` +- `pc2-node/src/api/filesystem.ts` +- `pc2-node/src/api/file.ts` +- `pc2-node/src/api/storage.ts` + +Use these files to match layout, object affordances, and interaction behavior. +Do not copy the Puter/PC2 authority model or broad-session assumptions. +Reimplement the behavior on Runtime contracts. Static PC2 icon assets may be +copied with provenance to preserve Explorer visual parity; PC2 runtime modules, +Puter authority code, and generated app bundles must not be transplanted. + +## PC2 UX Parity Checklist + +The Runtime Explorer should keep these PC2 behaviors unless they conflict +with ElastOS authority: + +- Places/sidebar navigation for user roots and mounted spaces. +- Breadcrumb/current-folder state. +- Toolbar actions for upload, new folder, view mode, sort, and refresh when a + manual refresh is useful. +- Grid and list views. +- PC2 item anatomy: icon or thumbnail, badge stack, divider, display name, + hidden name editor, and list-mode attrs. +- List columns for modified time, size, and type. +- Sort by name, modified, size, and type. +- File/folder badges for shared/published/public/availability state. +- Type-aware icons and previews. +- Empty-folder state. +- Loading and error states that do not erase the current folder context. +- Upload by button and drag/drop. +- Upload/download progress. +- Explicit inline rename from context menu or F2, with Enter to commit, Escape + to cancel, and blur to commit. Selected-name clicks must remain selection/open + affordances so double-click open is reliable. Active rename-editor clicks, + double-clicks, and context menus must stay inside the editor and not bubble + into item open handling. +- Double-click/tap open. +- Context menu/taphold. +- Keyboard open: Enter opens every selected object through the same Runtime + viewer/provider path as double-click. +- Keyboard context menu: ContextMenu key or Shift-F10 opens the selected-object + menu, or the folder background menu when no object is selected. +- Background context menu for folder-level actions. +- Multi-select where the Runtime operation supports it. +- Shift-click range selection in the visible grid/list order, anchored to the + last explicit selection. +- Multi-select archive download where all selected objects are downloadable + principal-root objects in the same folder. +- `Extract Here` for provider-owned `.tar`/`.tar.gz`/`.tgz`/`.zip` archives. +- Drag/drop move and copy only when backed by Runtime operations. +- Delete moves recoverable objects to Trash before permanent delete. +- Create, rename, and destructive confirmation flows use in-app Explorer UI, + not browser-native `prompt`, `alert`, or `confirm` dialogs. +- Properties/details view with name, URI, type, size, created/modified, current + file-byte content CID, published CID when available, availability receipt, and + object revision. +- Footer/status text for item count, selection count, and active operation + progress where PC2 shows equivalent feedback. + +Initial context menu actions: + +- Open +- Open in New Window, only for folders/directories +- Open With -> viewer rows, when external viewers are available +- Download +- Download as ZIP, only for downloadable principal-root folders +- Download Selected, only for multi-select downloadable principal-root objects +- Download Selected as ZIP, only for multi-select downloadable principal-root objects +- Compress to ZIP, only for provider-compressible principal-root objects +- Compress Selected to ZIP, only for same-folder provider-compressible selections +- Extract Here, only for `.tar`/`.tar.gz`/`.tgz`/`.zip` archives +- Publish / Unpublish +- Share +- Copy content CID, for byte-bearing files +- Copy published link, only when published +- Cut / Copy / Paste Into Folder +- Delete +- Restore, only inside Trash +- Delete Permanently, only inside Trash and behind confirmation +- Rename +- Properties + +Initial folder/background actions: + +- Sort By -> Name / Date Modified / Type / Size / Ascending / Descending +- View -> Icons / Details +- Refresh +- Show Hidden +- New -> Folder / Text Document +- Paste, only after a valid Runtime clipboard move/copy exists +- Upload Here +- Properties + +Unsupported PC2 actions must be hidden or shown with a clear not-yet-available +state only when useful. Do not ship dead menu items. + +## Intentional Divergence From PC2 + +Do not port these PC2/Puter-era behaviors: + +- Wallet-address roots as filesystem truth. +- `/null` path fallbacks. +- Username path aliases as authority. +- Bearer-token file shortcuts as app authority. +- Direct app calls to Kubo, IPFS Cluster, Elacity APIs, host paths, or broad + `localhost://Users/*`. +- Socket/global-state assumptions that bypass Runtime capability checks. +- App-visible IPFS pinning, node credentials, wallet RPC, chain RPC, or network + HTTP authority. + +The Explorer is an app capsule. Dangerous authority stays in providers. The +current split is `library` for UI and `object-provider` for principal-root +object authority. + +## Runtime Contract + +Add a typed Library/Object provider contract before UI replacement. The app +calls this contract only; it never reaches raw storage or content backends. + +## SmartWeb File Storage And CID Model + +Local mutable storage is owned by `object-provider`. Files, folders, +Desktop/Documents/Public, Trash, revisions, protected principal-root storage, +and object events are ordinary mutable Library object state mediated by Runtime. +They are not exposed to apps as host filesystem paths, Kubo handles, Carrier +tickets, or raw provider SDKs. + +CID is content identity, not a storage-location guarantee. Every readable local +file gets a current immutable raw-byte `content_cid` for the bytes at that +mutable object head. That proves what the current bytes are; it does not mean +the file has been published, replicated, or made globally fetchable. + +Private files are SmartWeb object heads: a mutable `localhost://...` object URI +with provider-owned metadata, revision, capabilities, and current +`content_cid`. Published files are separate content-provider records: only after +publish does the object receive a `published_cid`, a public `elastos://...` +link, and content-provider availability/repair/replication receipts. + +`Public` is a Library placement/projection root, not a second publish pipeline. +Putting an object under `Public` makes the user's intent visible in Library, but +it does not silently create a public network link. Public network access is +controlled by the explicit `publish` action and the resulting content-provider +receipt. Published objects do not automatically appear in `Public`; they appear +there only if the user also places or copies the object into that root. + +The practical split is: + +- `object-provider`: local mutable file/object storage and UI lifecycle + authority. +- `content-provider`: immutable published content identity, public CIDs, + availability receipts, and Carrier-backed delivery policy. +- `webspace-provider`: mounted resolver Spaces and provider-owned cache/sync/fork + heads. + +Minimum object record: + +```json +{ + "schema": "elastos.library.object/v1", + "uri": "localhost://Users//Documents/example.txt", + "name": "example.txt", + "kind": "file", + "mime": "text/plain", + "size": 1234, + "created_at": 1770000000, + "modified_at": 1770000000, + "revision": "rev:...", + "viewer": "text-viewer", + "thumbnail_uri": null, + "availability": "local-only", + "content_cid": "bafkrei...", + "published_cid": null, + "published": false, + "shared": false, + "capabilities": ["open", "rename", "download", "publish", "trash"] +} +``` + +Minimum provider operations: + +- `list` +- `stat` +- `read` +- `stream` +- `download` +- `write` / `upload` +- `mkdir` +- `rename` +- `move` +- `copy` +- `trash` +- `restore` +- `delete_permanently` +- `publish` +- `unpublish` +- `share` +- `status` +- `repair` +- `events` + +Every mutating operation must accept and return a revision or receipt so stale +UI state cannot silently overwrite newer state. + +Large reads and media opens must use range/stream semantics through the provider +contract. The app must not synthesize host URLs or bypass the Runtime stream +path to make media playback work. + +## Object Roots + +Initial places: + +- Home +- Documents +- Pictures +- Videos +- Downloads +- Public +- Spaces + +The root labels can match PC2's familiar model, but the backing URIs must be +principal-rooted or WebSpace-mounted Runtime objects. + +## Provider Mapping + +- Explorer/Library UI: the `library` app capsule. It owns layout, + interactions, viewer handoff, and PC2-familiar UX only. +- Local mutable object graph: canonical `object-provider` package in this + branch, exposed through the canonical `object` Runtime provider scheme. It + owns folders, files, Desktop/Documents, revisions, Trash, encrypted + principal-root storage, and object events. +- Published content: Carrier-backed `content-provider`. It owns immutable + content identity, CIDs, publish/fetch, status, repair, replication, and + availability receipts. It must not own Explorer UI, folder names, Trash, + local rename/move semantics, or app-visible Kubo/IPFS authority. +- Provider coordination: publish/unpublish/repair/status use + `elastos://content/*` through Runtime/provider mediation. The first + provider-to-provider request envelope exists for internal provider effects, + with explicit local and Carrier provider-plane transports. Carrier + `provider_invoke` stays Runtime-mediated and service-provider-only; the + current `ProviderTransfer::Stream` envelope is bounded and validated, with a + Runtime-owned stream-session read/cancel contract above it. +- Availability and replication: `availability-provider`. +- Mounted spaces: `webspace-provider`. It should return typed mount handles, + backing target metadata, provider-owned object heads, metadata health and + refresh/cache/sync/fork status, read-only/mutable capability flags, and availability + hints without exposing raw provider credentials, cloud APIs, Kubo/IPFS, or + Carrier transport authority to Library. Current mutable mounts can also + materialize local WebSpace objects in a provider-owned object table; remote + mutable sync still belongs to resolver/sync workers. +- Viewer selection: Runtime viewer registry and capsule manifests. +- Sharing policy: Runtime capability/share records, not a public path shortcut. + +## Security And Audit Requirements + +- Two principals must not read each other's roots. +- Path traversal, foreign roots, raw host paths, and broad + `localhost://Users/*` must fail closed. +- Protected principal-root writes must use the protected storage helper or fail. +- Publish/share/delete-permanent must produce signed audit records. +- Local upload, mkdir, rename, move, trash, restore, and download must produce + auditable object-operation receipts. +- App tokens must be scoped to the Explorer capsule and current principal. +- No operation may require app-visible Kubo/IPFS/Elacity credentials. + +## Realtime Rule + +Do not implement PC2-style global socket assumptions directly. The first slice +may refresh after local actions. The durable path is a typed Runtime event stream +for object changes, upload/download progress, publish status, availability +repair, and share changes. No aggressive polling. + +## Implementation Slices + +1. Provider contract and tests. +2. Explorer UI shell using the typed provider. +3. Upload/download/new-folder/rename/trash/properties. +4. Publish/unpublish/share/status plus provider-level repair support. +5. Viewer routing. +6. Spaces root visibility plus read-only/mutable policy. +7. Event stream and progress receipts. +8. Provider-to-provider content coordination for publish/unpublish/repair. + +Each slice must be releasable and must not expose a dead visible control. + +## Verification + +Provider tests: + +- list/stat/read a file in the current principal root +- reject foreign principal roots +- reject traversal and raw host paths +- write/upload through protected storage +- stream/download with HTTP range semantics through the Runtime route, and + provider-streaming through the bounded provider chunk envelope +- rename with revision precondition +- move to Trash and restore +- delete permanently only from Trash +- publish returns a content receipt +- availability status returns a provider receipt +- app cannot call content/IPFS/host paths directly + +UI/operator smoke: + +- automated context-menu and core journey smoke: + `node scripts/library-menu-smoke.mjs` +- signed live publish/share smoke when a Home token or browser cookie is + available: `scripts/library-live-smoke.sh` +- open Explorer from Home +- browse each initial place +- upload one file by button +- upload one file by drag/drop +- create a folder from the toolbar or background context menu +- create a folder in Library -> Desktop and confirm Home desktop shows it as a + desktop object without direct filesystem access from Home +- switch grid/list and sort by name, modified, size, and type +- rename inline with Enter, Escape, and blur paths +- open a file through viewer routing and verify the Home frame receives the + target plus object URI +- download the file +- move it to Trash, restore it, then delete permanently with confirmation +- publish it and copy the CID +- share it and verify the share state changes +- verify another principal cannot read it unless shared +- verify no network-tab polling hammer while idle + +Design parity smoke: + +- compare the Runtime Explorer against the PC2 reference checklist above +- capture at least one desktop-size and one narrow-window screenshot or trace + for the implemented slice +- if a PC2 behavior is missing, mark it unsupported with a reason or implement it +- if a PC2 behavior is intentionally changed, document the authority or platform + reason in this file before shipping the slice diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index bfc5b073..0e821bc5 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -190,4 +190,13 @@ and not an access-control or rights system. ## WebSpace -In the WCI-aligned model, a WebSpace is a special AppCapsule class that interprets the named data after `://` dynamically. It is not just a folder on disk. The resolver owns the raw moniker first and may then return either a file endpoint or a traversable `folder/` handle. `localhost://WebSpaces/...` is therefore not ordinary local storage; it is the future local handle into named, daemon-resolved spaces such as `Elastos`, `SimpleX.chat`, or `WeChat.com`. +In the WCI-aligned model, a WebSpace is a mounted resolver surface for named +data and provider-backed resources. It is not just a folder on disk. The +resolver owns `localhost://WebSpaces//...` first and returns typed +handles instead of letting an app walk raw storage. A mount can project native +ElastOS resources such as `elastos://content/*` or, in the future, external +provider targets such as `google://drive/...`; those provider targets remain +resolver-private authority, while Library/Home show the local mounted WebSpace +view. Mutable WebSpace mounts/forks can also materialize local provider-owned +objects with explicit access-policy metadata; that local materialization is +provider state, not a raw app-visible filesystem alias. diff --git a/docs/KEY_PROVIDER.md b/docs/KEY_PROVIDER.md index 249726dd..4dece4ea 100644 --- a/docs/KEY_PROVIDER.md +++ b/docs/KEY_PROVIDER.md @@ -15,9 +15,10 @@ or provider credentials. - `release`: validate a `KeyReleaseRequestV1` and request scoped key release. Current implementation is intentionally fail-closed. It validates schema, -principal/session/object/action fields, supported schemes, and PQ-hybrid -algorithm metadata, then refuses backend work until an ElastOS dKMS adapter -exists. +principal/session/object/action fields, an allowed +`elastos.rights.decision.receipt/v1` bound to the same +principal/session/object/action, supported schemes, and PQ-hybrid algorithm +metadata, then refuses backend work until an ElastOS dKMS adapter exists. ## Capability Schema diff --git a/docs/NAMESPACES.md b/docs/NAMESPACES.md index a2794957..90dec586 100644 --- a/docs/NAMESPACES.md +++ b/docs/NAMESPACES.md @@ -22,9 +22,19 @@ Dynamic special root: - `localhost://WebSpaces/...` - this is not ordinary storage; it is the dynamic WebSpace/AppCapsule resolver surface - the resolver owns `localhost://WebSpaces//...` first and returns typed handles instead of walking a normal filesystem path + - this is the local mounted view; any raw provider target such as `google://drive/...` or `elastos://content/...` stays behind the resolver/provider contract - the initial mounted `Elastos` handle already exposes typed children such as `content`, `peer`, `did`, and `ai` - today, `content/` resolves to a file endpoint, while `peer/`, `did/`, and `ai/` stop at one typed folder handle and deeper traversal fails closed until richer resolver semantics exist +Library's user-facing `Public` place is a projection under the active +principal root, for example `localhost://Users//Public`. That +placement is separate from published content identity. A file has a local +`content_cid` when its bytes are addressable by the object provider, but it +only has public network reachability after `content-provider` creates a +`published_cid` and `elastos://` receipt. Published objects do not +automatically appear in `Public`, and placing an object in `Public` does not +silently publish it. + Current namespace contract: - `localhost://ElastOS/...` = runtime-owned local system state and services @@ -33,6 +43,26 @@ Current namespace contract: - `localhost://WebSpaces//...` = local mounted resolver view of a broader dynamic named space - `localhost://Users//.AppData/ElastOS/Home/browser-state.json` = Home layout, window-session, and recent-target state for the active runtime principal +Mounted WebSpaces are not literal aliases for raw provider authority. A useful +external mount would look like: + +- `localhost://WebSpaces/Google Drive/Project X/file.pdf` = Library/Home-visible mounted object handle +- `google://drive/files/` = provider-private target understood only by a future Google Drive provider +- `elastos://content/` = provider-independent content identity after import, publish, or fork + +That split keeps Rong Chen's WebSpace-as-named-intent model intact: apps and +users speak mounted WebSpace intent; Runtime/provider contracts resolve it; raw +credentials, network APIs, Kubo/IPFS details, and Carrier transport stay below +the app-visible namespace. + +Current `webspace-provider` supports both readonly resolver mounts and mutable +mounts/forks. Readonly mounts expose mounted/indexed handles only. Mutable +mounts can materialize local provider-owned files and folders under +`localhost://WebSpaces//...` with persisted object/head/access-policy +metadata. This is still not ordinary principal-root filesystem storage: remote +resolver traversal, cloud-provider sync, Carrier invocation, and multi-peer +availability remain provider responsibilities below the mounted WebSpace view. + For documents, the intended identity split is: - `localhost://ElastOS/Documents/` = canonical mutable document object @@ -67,6 +97,7 @@ Useful current WebSpace commands: - `elastos webspace resolve Elastos` - `elastos webspace list Elastos` - `elastos webspace resolve Elastos/content/` +- `elastos webspace health|refresh|cache|sync|fork ...` `elastos open elastos://` opens a share through the local bridge. `elastos share --public` holds an immediate public edge open while the command is running. Plain gateway URLs are convenience transport and may take time to propagate; the CID is the stable shared content identity. @@ -76,7 +107,7 @@ The browser-facing local site root is: - `localhost://MyWebSite` -`Public` remains the shared-files root. `MyWebSite` is the personal browser root. +`Public` remains the shared-files placement root. `MyWebSite` is the personal browser root. This is now staged and served explicitly through: @@ -109,7 +140,7 @@ Public exposure sits above that root as an explicit operator choice: What is implemented now: - `localhost://MyWebSite` is a real local root under the runtime data dir -- `localhost://Public/*` is a separate shared-files root +- `localhost://Public/*` is a separate shared-files root for global/local public-placement compatibility - `localhost://ElastOS/SystemServices/Publisher/...` owns release/install/artifact state for the public edge - `elastos site ...` is the explicit site command surface diff --git a/docs/OVERVIEW.md b/docs/OVERVIEW.md index 88d4ad37..3e1f7b2e 100644 --- a/docs/OVERVIEW.md +++ b/docs/OVERVIEW.md @@ -59,7 +59,9 @@ The current preview is grounded in code and recorded proof, but not every path h - immediate public sharing through `elastos share --public` - signed publish, install, and update - native chat as the default proving surface, with explicit WASM and microVM chat proving paths -- initial read-only `webspace-provider` resolution under `localhost://WebSpaces/Elastos` +- `webspace-provider` resolution under `localhost://WebSpaces`, with read-only + resolver mounts plus local materialized writable mounts/forks backed by + provider-owned object/head tables - content availability manifests and signed local availability receipts above the low-level `ipfs-provider` @@ -151,6 +153,8 @@ The current relationship is: - `localhost://ElastOS/...` = runtime-owned local system state and services - `elastos://...` = decentralized identities, shared content, and provider-routed surfaces between nodes - `localhost://WebSpaces//...` = the local mounted/interpreted view of a broader dynamic named space +- provider-specific targets such as `google://drive/...` are resolver-private + implementation details until a provider contract intentionally exposes them For documents specifically: @@ -195,12 +199,15 @@ What is already true in code: - file-backed localhost roots are first-class - `MyWebSite` and `Public` are distinct - `http://` is no longer a first-class capability/manifest resource scheme -- an initial read-only `webspace-provider` slice exists for mounted moniker listing/resolution and typed handles under `localhost://WebSpaces/Elastos` +- `webspace-provider` exposes mounted moniker listing/resolution, typed handles, + persistent mount/index/head/object tables, and local materialized write/mkdir/delete + flows for writable mounts/forks - the current depth boundary is explicit: `content/` resolves to a file endpoint, while `peer/`, `did/`, and `ai/` stop at one typed folder handle and fail closed on deeper traversal What remains open: -- deeper `WebSpaces` daemon/object resolution beyond the initial `Elastos` handle and its first typed children +- live external resolver traversal, remote mutable/fork sync, and provider-to-provider + Carrier invocation beyond the local materialized WebSpace object model - stronger root-aware substrate cleanup across the remaining internal tests/examples - broader system-service mapping diff --git a/docs/PC2_CONVERGENCE.md b/docs/PC2_CONVERGENCE.md index a4a2d055..b4ecbf1d 100644 --- a/docs/PC2_CONVERGENCE.md +++ b/docs/PC2_CONVERGENCE.md @@ -1,6 +1,8 @@ # PC2 Convergence Notes -> Last verified from public PC2 `main`: 2026-05-06. +> Last verified from public PC2 `main`: 2026-05-29 +> (`a0a910158bd67666a6d3ea2a775ce09005ba7ae7` via `git ls-remote`). +> This commit is tagged `v1.3.0` in PC2 and is the current migration baseline. > > This document is a Runtime translation of useful PC2 implementation patterns. > It is not a commitment to port PC2 code or revive older PC2/Puter assumptions. @@ -17,6 +19,51 @@ packaging, and launcher/runtime health. Those are useful references. The Runtime must not copy PC2's older iframe, broad-session, app-visible wallet, or direct IPFS patterns. +## Verified PC2 Code Inputs - 2026-05-29 + +Use PC2 `main` as the implementation reference. It is newer than the April +inventory and already includes the recent dDRM, zero-CEK, marketplace, wallet, +and Monetisation Agent S1 work. + +| PC2 ref | Status | Runtime use | +|---|---|---| +| `main` / `v1.3.0` at `a0a910158bd67666a6d3ea2a775ce09005ba7ae7` | Canonical baseline | First reference for Explorer, AI Chat, wallet bridge, protected content, and Marketplace code study. | +| `release/2026-05-28-ddrm-hardening` at `0340618c` | Included in `main` | Historical checkpoint only. | +| `feat/ddrm-zero-cek-exposure` at `e80a5579` | Included in `main` | Historical checkpoint only. | +| `feature/elacity-ddrm-marketplace` | Included in `main` | Historical checkpoint only. | +| `dev/ipfs-connectivity` | Not fully merged into `main` | Reference-only for IPFS/CAR/connectivity experiments; do not use as the baseline. | +| `ai-work` | Older, not fully merged into `main` | Reference-only for AI/user-isolation lessons; `main` has the current AI service. | +| `feature/virtual-workspaces` | Older, not fully merged into `main` | Reference-only for workspace UX ideas; contains Puter-era assumptions that must not be imported directly. | + +Concrete files checked on PC2 `main`: + +- File/content/availability: `pc2-node/src/storage/ipfs.ts`, + `pc2-node/src/api/storage.ts`, `pc2-node/src/api/file.ts`, + `pc2-node/src/api/filesystem.ts`, + `pc2-node/src/services/ContentSeedingService.ts`, + `pc2-node/src/services/ContentIndexerService.ts`, + `pc2-node/src/services/clusterPin.ts`, and + `pc2-node/wasm-apps/ipfs-assemble/*`. +- AI Chat: `pc2-node/src/api/ai.ts`, + `pc2-node/src/services/ai/AIChatService.ts`, + `pc2-node/src/services/ai/tools/ToolExecutor.ts`, and + `pc2-node/src/services/ai/tools/MonetisationAgentTools.ts`. +- Wallet bridge/capability vocabulary: + `pc2-node/src/wallet-bridge/pc2-wallet-bridge.js`, + `pc2-node/src/wallet-bridge/pc2-wallet-provider.js`, and + `pc2-node/src/types/capabilities.ts`. +- dDRM/Marketplace: `pc2-node/src/services/wasm/WasmDdrmDecryptRuntime.ts`, + `pc2-node/crates/ddrm-decrypt/*`, `pc2-node/src/api/storage.ts` + Lit/Chipotle session routes, `pc2-node/src/api/chipotle-client.ts`, + `pc2-node/data/test-apps/elacity-market/*`, + `pc2-node/data/test-apps/elacity-creator/*`, + `pc2-node/data/test-apps/elacity-player/*`, and + `pc2-node/data/test-apps/ddrm-viewer/*`. + +Entropy finding from the PC2 code study: the useful logic is real, but much of +it is route/app/iframe-shaped and broad-session-shaped. Runtime should lift the +protocol boundaries and acceptance fixtures, not transplant the monoliths. + ## What To Reuse | PC2 asset | Runtime translation | Why it matters | @@ -65,17 +112,70 @@ IPFS patterns. ## Near-Term Action Items -1. Keep WalletConnect behind the wallet connector capsule contract. -2. Use PC2 wallet bridge method classification as test fixtures for +1. Start with Explorer / Library / WebSpace browsing as the first PC2 + product migration slice. Use PC2's file-manager and content UX as reference, + but implement it through Home/Library, principal-root storage, + `elastos://content/*`, WebSpace mounts, and availability receipts. +2. Bring AI Chat over as a provider-backed capsule. The chat UI is a normal app + capsule; model execution, hosted-model credentials, embeddings, and local + context access stay in `ai-provider` / `llama-provider` / explicit hosted + provider contracts. +3. Stage dDRM and Elacity Marketplace behind protected-content providers before + porting Marketplace/Creator/Player/Viewer UX. The provider sequence is + `elastos://drm/open -> content status/fetch -> rights-provider -> + key-provider -> decrypt-provider -> scoped viewer session`. +4. Keep WalletConnect behind the wallet connector capsule contract. +5. Use PC2 wallet bridge method classification as test fixtures for `wallet-provider` and `chain-provider` capability mapping. -3. Treat PC2's IPFS cluster work as the first realistic `availability-provider` +6. Treat PC2's IPFS cluster work as the first realistic `availability-provider` backend design, not as a reason for app capsules to call IPFS directly. -4. Use PC2 dDRM and WASM crates as protected-content provider implementation +7. Use PC2 dDRM and WASM crates as protected-content provider implementation candidates after the fail-closed `drm/open -> rights -> key -> decrypt` sequence is wired. -5. Evaluate PC2 heartbeat as a runtime/operator health contract once launcher or +8. Evaluate PC2 heartbeat as a runtime/operator health contract once launcher or supervisor integration resumes. +## Explorer UX Translation + +The Explorer should preserve PC2's user experience where it is useful, but +not PC2's authority model. The Runtime target is a PC2-familiar object browser on +ElastOS rails. + +The detailed design and authority checklist lives in +[FILE_MANAGER_MIGRATION.md](FILE_MANAGER_MIGRATION.md). Treat that file as the +gate before implementing the first Explorer slice. + +Keep from PC2: + +- places/sidebar navigation +- breadcrumbs and current-folder state +- grid/list switching, icon tiles, details columns, and type-aware previews +- drag/drop upload, upload progress, new folder, inline rename, context menus, + properties/details, open, download, and copy URI/CID actions +- share/publish badges and availability status such as local-only, syncing, + network-available, and repair-needed + +Do not keep from PC2: + +- Puter-era wallet-address roots or username path aliases as filesystem truth +- `/null` fallback paths +- bearer-token file shortcuts as an app authority model +- direct app calls to Kubo, IPFS Cluster, Elacity APIs, raw host paths, or broad + `localhost://Users/*` +- socket/global-state assumptions that bypass Runtime capabilities or audit + +Implementation rule: add the typed object provider contract first, prove +principal isolation and protected-root writes, then build the PC2-style UI on top +of that contract. + +## First Migration Slices + +| Slice | User-facing target | Runtime contract | PC2 reference input | Acceptance gate | +|---|---|---|---|---| +| Explorer / Library / WebSpace | Browse, upload, download, open, rename, publish, and share files/objects from Home | Home/Library app capsule, typed object provider, principal-root storage, WebSpace mounts, `elastos://content/*`, `availability-provider` | PC2 file manager UX, IPFS/content UX, Kubo/IPFS Cluster/supernode availability work | One file can be uploaded, opened, renamed, published, shared, and proven unavailable to another principal unless shared; app cannot bypass the provider plane | +| AI Chat | Open Chat, ask a question, optionally attach a local object/document | Chat app capsule plus `ai-provider` / `llama-provider` / hosted model provider; context by object capability | PC2 AI Chat UX and provider lessons | Prompt succeeds through provider capability; missing provider fails closed; no raw model key, host HTTP credential, or filesystem path reaches app code | +| Protected content / Elacity | Browse protected content and open only when access is proven | `elastos://drm/open`, `rights-provider`, `key-provider`, `decrypt-provider`, `chain-provider`, Wallet/Inbox approvals | PC2 dDRM contracts, WASM decrypt/render/media crates, Elacity Marketplace/Creator/Player/Viewer | One pinned fixture opens for the rightful account and fails closed for another account; apps never receive raw CEKs, chain RPC, wallet RPC, Kubo/IPFS, or Elacity SDK authority | + ## Verification Rule Before importing any PC2 pattern, the implementation must name: diff --git a/docs/PROTECTED_CONTENT.md b/docs/PROTECTED_CONTENT.md index 3c7b123b..4d4b852c 100644 --- a/docs/PROTECTED_CONTENT.md +++ b/docs/PROTECTED_CONTENT.md @@ -129,8 +129,8 @@ The provider plane should expose typed questions instead: ## Remaining Sequence 1. Wire real `elastos://drm/open` orchestration behind the declared sequence: - content status/fetch, typed rights checks, key release, decrypt/render - sessions, and signed release receipts. + content status/fetch, typed rights checks, rights-bound key release, + release-receipt-bound decrypt/render sessions, and signed release receipts. 2. Wire `key-provider` to an ElastOS PQ-hybrid threshold release backend. 3. Wire `decrypt-provider` to a real decrypt/render backend that keeps CEKs inside the provider boundary. diff --git a/docs/README.md b/docs/README.md index 6012f7a5..5bda5754 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,6 +27,7 @@ Planning and truth surfaces outside `docs/`: - [CAPSULE_MODEL.md](CAPSULE_MODEL.md) — supplemental capsule/runtime terminology note - [CARRIER.md](CARRIER.md) — supplemental Carrier framing note - [CONTENT_AVAILABILITY.md](CONTENT_AVAILABILITY.md) — IPLD, CID sync, availability receipts, and SmartWeb content-plane direction +- [ARCHIVE_POLICY.md](ARCHIVE_POLICY.md) — Archive dependency, release, and generic-family enablement policy - [PC2_CONVERGENCE.md](PC2_CONVERGENCE.md) — current translation of useful PC2 patterns into Runtime provider/capsule boundaries - [CHAIN_PROVIDER.md](CHAIN_PROVIDER.md) — typed chain-provider boundary and current blockchain-quadrant slice - [WALLET_PROVIDER.md](WALLET_PROVIDER.md) — wallet proof, account-link, typed-signing, and transaction authority boundary @@ -44,3 +45,7 @@ These should stay narrower than the canonical current docs. If they repeat the s - [VERSIONING.md](VERSIONING.md) — runtime release versioning policy - [SHARE_VERSIONING.md](SHARE_VERSIONING.md) — share lifecycle and versioning model + +## Reports + +- [WCI_EXEC_WEEKLY_REPORT_2026-06-05.md](WCI_EXEC_WEEKLY_REPORT_2026-06-05.md) — WCI executive weekly status for the Library/Explorer release slice diff --git a/docs/SITES.md b/docs/SITES.md index 028482f5..b014b9eb 100644 --- a/docs/SITES.md +++ b/docs/SITES.md @@ -91,6 +91,15 @@ Examples: - nginx / Caddy / reverse proxy in front - public domain such as `runtime.ela.city` +Reverse-proxy requirements for the public gateway: + +- TLS/front-door only; object/site authority stays in `elastos gateway` +- body-size limits must allow bounded Library upload chunks through + `/api/provider/object/upload/:upload_id/chunk`; large files use Runtime + upload sessions instead of a single giant `PUT /api/provider/object/upload` +- realtime routes such as provider event streams must not be buffered +- stale static aliases must not serve old `/apps/library/src/*.js` modules + This is the boring, durable operator path. The public `https://elastos.elacitylabs.com/` root now follows this pattern: diff --git a/docs/WCI_EXEC_WEEKLY_REPORT_2026-06-05.md b/docs/WCI_EXEC_WEEKLY_REPORT_2026-06-05.md new file mode 100644 index 00000000..fa9880b2 --- /dev/null +++ b/docs/WCI_EXEC_WEEKLY_REPORT_2026-06-05.md @@ -0,0 +1,458 @@ +# WCI Executive Weekly Report + +Week ending: 2026-06-05 + +Branch: `review/library-release` + +## Runtime Update + +This week’s Library/Explorer work moved the previous PC2-aligned file-manager +slice much further into the ElastOS Runtime / WCI model. The important outcome +is still not a direct PC2 transplant; it is PC2-familiar Explorer UX with +Runtime-owned principal authority, provider-owned object/content state, +Carrier-mediated off-box effects, and no app-visible raw Kubo/IPFS, host path, +cloud credential, wallet, chain, or peer authority. + +Release readiness is close but not finished. The code and alignment gates are +green, live smokes have passed, and the implementation is coherent, but the +worktree still contains a broad uncommitted release diff. Final release still +needs coherent commit slicing, a normal Chrome-profile human pass, coordinated +version selection, and publish with an operator release key. + +Progress: + +- Runtime Library now behaves much closer to PC2 Explorer while staying on + ElastOS rails: places/sidebar, breadcrumbs, grid/list views, sorting, + inline create/rename, double-click open, context menus, upload/download, + drag/drop move/copy, Desktop projection, Trash/restore/delete, + publish/share/status/repair, properties, preview/open, viewer handoff, SSE + refresh, and browser Back/Forward takeover are all provider-mediated. +- The PC2 baseline was checked directly from `Elacity/pc2.net` `main` at + `a0a910158bd67666a6d3ea2a775ce09005ba7ae7`; key files inspected included + `UIItem.js`, `UIDesktop.js`, `open_item.js`, + `refresh_item_container.js`, and the PC2 filesystem/file APIs. +- Runtime intentionally does not copy PC2’s authority model. PC2 shortcuts such + as username/wallet path rewriting, `/null` path fallbacks, signed file URL + shortcuts, broad global socket/session assumptions, direct GUI filesystem + access, and app-visible IPFS/gateway paths remain rejected. +- Library is now an app capsule that owns UI only. Runtime injects the signed + principal. Mutable files/folders/Desktop/Documents/Public/Trash are mediated + by canonical `object-provider` on the `object` Runtime scheme; browser calls + use `/api/provider/object/*`. Published content goes through + `content-provider`; Kubo/IPFS stays behind the provider layer. +- Home Desktop now projects `localhost://Users//Desktop` through + `object-provider`. Home owns icon placement/opening only, and delegates + Desktop object actions such as Properties and Download back to Library through + signed object-action launches. +- Library `Open in New Window` now works for sidebar places and folders through + signed `library -> library` Home open-target messages instead of weakening + Home’s source-gated message policy. +- Documents can open and save a concrete Library object through the + `/api/viewers/documents/library-object` viewer route. Library lists installed + viewer capsules only, including Documents for text/markdown/PDF and GBA + Emulator for `.gba`, `.gb`, and `.gbc` objects. +- Browser-native `prompt`, `alert`, and `confirm` are no longer used for + Library object actions. Share/create/rename flows are in-app and + provider-backed. +- PC2-style menu behavior has been improved: sidebar blank space suppresses the + browser menu, sidebar place items expose `Open` and `Open in New Window`, + item menus include working nested groups such as `Open With`, `Sort By`, + `View`, and `New`, and dead visible controls are hidden. +- The double-click/rename bug was closed. Selected names now open on + double-click in grid and list views; rename is explicit through context menu + or F2; active rename editors do not also open the object. +- Keyboard parity improved: Shift-click range selection, Enter-open for + selected objects, and Shift-F10/ContextMenu-key selected-object menus are + covered in list view. +- Published/blocked/trash badges were moved into stable Explorer layout + positions instead of awkward icon overlays. Published state appears under the + filename in icon view and in the name column in list view. +- Empty folder states are centered and clearer. Spaces empty state explains + that mounted provider-backed spaces appear only when a resolver is available. +- Library was split from a monolithic static page into active modules: + `app.js`, `actions.js`, `api.js`, `dialog.js`, `editor.js`, `events.js`, + `menu.js`, `model.js`, `navigation.js`, `preview.js`, `realtime.js`, + `render.js`, `selection.js`, `state.js`, and `uploads.js`. +- Performance was improved beyond the prior week: async SVG hydration churn was + removed, folder listings are cached, root folders are prefetched, visible and + sorted objects are cached, large folders use keyed DOM node reuse, first paint + is chunked, upload progress rendering is frame-coalesced, and object + downloads now return chunked HTTP body streams with explicit + backpressure/cancel transfer receipts. +- Library Properties, availability status, and share dialogs now lead with + SmartWeb object identity and provider-owned availability summaries instead of + raw backend diagnostics. Raw CIDs remain available as technical/copyable + details. +- Archive support advanced from basic download into provider-owned object + workflows: folder and selected-object ZIP downloads, `Compress to ZIP`, + `Compress Selected to ZIP`, and safe `.tar`, `.tar.gz`, `.tgz`, and `.zip` + extraction now exist with unsafe-entry rejection. +- Public-link and recipient-scoped share flows exist in Library. The provider + records typed share policy metadata, `elastos.library.share-grant/v1` + records, `shared_access` decisions, denied-access audit, shared-open + receipts, and fail-closed key-release policy receipts. +- Legacy plaintext objects left in a protected principal root are auto-protected + by `object-provider` on first access, preserving old development data + without weakening the protected-root rule. +- Spaces is now the user-facing name for mounted WebSpace views. The Runtime + handle remains `localhost://WebSpaces//...`; resolver-private targets + such as `google://drive/...` stay behind provider authority. +- `webspace-provider` now has persistent mount, resolver-index, object-head, + and local materialized object tables. It also exposes provider-owned + health, refresh, cache, sync, fork, write, mkdir, and delete receipts. +- Library now treats Spaces as mounted resolver views, not ordinary + principal-root folders. Read-only mounts hide mutable actions; mutable + mounts/forks can materialize provider-owned local objects only when the + WebSpace provider marks the handle writable. +- `elastos webspace` CLI support now covers the current lifecycle surface: + mounts, mount, unmount, index, health, refresh, head, cache, cache-status, + sync, sync-status, and fork. +- `content-provider` now owns the content publish/fetch/status/repair decision + above Kubo/IPFS. Apps still use one `elastos://content/*` surface whether + bytes are local, cached, or fetched through availability providers. +- Built-in Carrier availability now treats `carrier_announced` as an auditable + state. It signs and announces published CIDs on deterministic Carrier topics + without exposing peer handles, connect tickets, or raw Kubo/IPFS authority to + apps. +- Availability receipts now carry explicit peer-selection, quota, + repair-worker, local accounting, and abuse-control metadata. Local-only + publishes honestly report `single_local` with no live multi-peer proof. +- Carrier availability now scores signed candidate announcements using local + metadata plus durable local runtime success/failure reputation. Remote + receipts expose redacted score/reason/local-reputation rows, not raw transport + authority. +- The first autonomous cross-peer repair/replication proof path exists: + Carrier can select a remote provider, invoke and verify signed remote + `content/admission` before moving bytes, invoke remote `content/ensure`, + verify the same CID with remote `content/status`, and record + `network_available` only when an independent remote provider proves a live + pinned replica. +- Remote repair now has fail-closed fallbacks: `content/import_object` can + reconstruct manifest-backed objects, and `content/import_exact` can push + file-like exact-CID bytes when remote pin cannot fetch the object. +- The live-proof candidate selection bug was fixed. When live multi-peer proof + is required and quota permits it, Carrier keeps at least one remote provider + invocation in the candidate budget even if local replicas already satisfy the + minimum count. +- `content-provider` persists an `elastos.content.repair-task/v1` ledger. + Publish/ensure/repair/unpublish record local-only, queued, healthy, and + retired task states; status includes the latest task. +- A Runtime-provider-only repair worker exists. Operators can trigger it with + `elastos content repair-worker`, and servers can enable an opt-in bounded + scheduler with `ELASTOS_CONTENT_REPAIR_SCHEDULER=true`. +- Provider status and repair-worker runs now expose + `elastos.content.repair-fleet/v1` receipts for the current single-runtime + repair fleet: `content-provider` is the coordinator/local worker, scheduling + is ledger-based, task pressure is inspectable, and external repair fleets, + storage-market admission, and settlement are explicitly not configured. +- Provider status, per-CID status, and repair-worker runs now also expose + `elastos.content.network-abuse-policy/v1` receipts. These tie signed + abuse-control receipts to one provider-owned policy surface: Runtime + invocation is required, Carrier candidate caps and remote admission preflight + are local guardrails, repair-worker attempt/failure budgets are visible, and a + configured federated abuse-control endpoint quorum can enforce signed external + admission policy before bytes or repair data move. Production network-wide + throttles, banlists, and abuse ledgers remain explicitly not configured. +- Provider-wide status now also exposes + `elastos.content.operator-dashboard/v1`, a derived operator view over signed + receipts, repair-task history, and storage-accounting ledgers. It reports + storage pressure, top principals by active content bytes, replica-byte + estimates, quota-exceeded records, fleet-history attempts, recent repair rows, + live-proof counts, and explicit non-production federation posture. +- Carrier peer selection and content status now expose + `elastos.carrier.peer-reputation/v1` policy metadata. The current policy is + honest local Runtime success/failure history only; status aggregates + local-history-applied/not-reported/federated-policy counts and still reports + signed cross-runtime reputation as not configured. +- Carrier peer selection, redacted remote receipt summaries, content proof + summaries, and the operator dashboard now expose + `elastos.carrier.peer-attestation-exchange-policy/v1`. The current policy + records signed availability announcements, verified remote content receipts, + remote provider proofs, and local Runtime reputation as present. The branch + can also call one configured Carrier peer-attestation endpoint quorum with a + signed `elastos.carrier.peer-attestation.exchange-request/v1`, verifies + returned signed `elastos.carrier.peer-attestation.exchange-receipt/v1` + receipts, and records endpoint receipts plus quorum counters before marking + exchange accepted. Third-party attestations, revocation, and production + fleet-wide reputation policy remain explicitly not configured. +- Local, Carrier, ledger, per-CID, and provider-wide storage-market status now + expose `elastos.content.storage-settlement-policy/v1`. Pricing, escrow, + payment settlement, SLA enforcement, storage-market admission, and + cross-provider escrow are explicit `not_configured` policy status instead of + vague missing product behavior. +- Local, Carrier, ledger, per-CID, provider-wide, and operator-dashboard + storage-market surfaces now expose + `elastos.content.storage-market-admission-policy/v1`. Local quota admission + and signed remote `content/admission` preflight are recorded as current + proof-path admission; configured storage-market endpoint-quorum admission can + now enforce one operator-owned admission endpoint or a bounded endpoint set + with explicit quorum, while production offer receipts, price discovery, SLA + admission, and economic abuse controls are explicitly not configured. +- Local quota receipts, principal storage-quota receipts, Carrier quota + receipts, remote receipt summaries, per-CID status, provider-wide status, and + the operator dashboard now expose + `elastos.content.federated-quota-ledger-policy/v1`. The policy records local + per-principal accounting, signed remote `content/admission` preflight, and + configured signed federated quota-ledger endpoint-quorum exchange as present + when configured. Production independent provider-network quota-ledger + federation and production storage-admission networks remain explicitly not + configured. +- `content/admission` can now enforce an operator-configured federated + abuse-control exchange before quota-ledger, storage-market, byte-transfer, or + repair-graph movement. The request is signed as + `elastos.content.federated-abuse-control.exchange-request/v1`; accepted + responses must include a verified + `elastos.content.federated-abuse-control.exchange-receipt/v1`, and rejection + or endpoint failure rejects admission fail-closed without exposing endpoint + credentials. +- Provider-wide status, repair-worker runs, and the operator dashboard now expose + `elastos.content.external-repair-fleet-policy/v1`. The policy records the + provider-owned local repair worker/scheduler as present, while external + coordinators, volunteer/supernode workers, cross-provider repair queues, + worker attestations, fleet settlement, and repair SLAs are explicitly not + configured. +- Provider-wide status and the operator dashboard now expose + `elastos.content.federated-operator-alerting-policy/v1`. The policy records + provider-local status JSON, storage-pressure signals, repair-task pressure, + live-proof counters, remote-receipt counters, and configured provider-local + alert sink plus configured federated alert-exchange posture as present. + Operators can request a durable `elastos.content.operator-alert.receipt/v1`, + optional `elastos.content.operator-alert/v1` webhook delivery to one + configured sink, and optional + `elastos.content.federated-operator-alert.exchange-request/v1` delivery to + one configured operator-owned exchange endpoint. Cross-provider dashboards, + peer-health subscriptions, fleet-wide SLA policy, and operator UI remain + explicitly not configured. +- Provider-to-provider Runtime invocation now has a typed + `elastos.provider.invocation/v1` envelope. Target providers can audit + Runtime-mediated source/target/op capability metadata. +- The provider plane now has explicit local and Carrier transports. Carrier + `provider_invoke` is service-provider-only and rejects raw backend targets + such as `ipfs` or `localhost`. +- Provider transfer receipts now carry range/progress metadata. Bounded + byte-range slicing works for base64 provider payloads. +- `ProviderTransfer::Stream` now uses a validated + `elastos.provider.stream/v1` base64-chunk envelope. `content-provider` fetch + opens it as a Runtime-owned stream session for local IPFS and + availability-provider fallback reads, with read-next backpressure, live + progress events, and cancel support. +- Source providers cannot spoof Runtime-owned `_runtime_invocation` or + `_runtime_transfer` fields; predeclared fields fail closed before reaching the + target provider. +- The availability dashboard now summarizes signed receipt and repair-task + ledgers, including quota verdicts, accounting counters, abuse controls, + remote replica proof rows, verified remote receipt counters, and explicit + truncation metadata for capped rows. +- `content-provider` now exposes provider-only + signed `elastos.content.admission/v1` preflight receipts. Carrier records + accepted admission in peer-selection metadata and stops before + `content/ensure`, exact/object import, or block-graph import when a remote + peer omits/forges the receipt or rejects projected quota. +- Spaces/WebSpace now has a persistent resolver adapter registry and operator + CLI. Health reports adapter registration/connection state and redacts + endpoint credentials, which makes external resolver readiness visible without + claiming live byte traversal. +- Spaces/WebSpace adapter health now has a safe liveness receipt path: + `check_adapter` records redacted `ok`/`failed`/`skipped`/`unknown` checks, + stale-check metadata, checked-adapter counts, and CLI support without + exposing credentials or claiming external byte sync. +- Spaces/WebSpace now also has a tested adapter invocation/cache contract: + Runtime invokes resolver adapters through provider-to-provider + `metadata_index` and `read_bytes`, and `webspace-provider` can materialize + adapter bytes into a clean, non-dirty cache head. This is a fake-adapter + proof of the contract, not a claim that external operator endpoints are + production-ready. +- Spaces/WebSpace now has a second operator-style provider fixture beyond the + fake Google adapter. It proves provider-selected adapter reads, durable + cached status after materialization, cached second reads, and viewer handoff + for adapter-backed markdown content through the installed Documents viewer. +- Library/Runtime now exposes a Spaces-only `sync` operation that invokes the + resolver adapter, persists bytes into the provider-owned WebSpace cache, and + returns a byte-sync receipt without returning file bytes to the caller. A + later read uses the cached bytes. +- Share receipts and Library properties now expose a remote-access policy + summary. Recipient-scoped sharing is provider-gated by `shared_access` and + Runtime recipient proof. Library also exposes `Check My Access` for shared + published objects, which asks `object-provider` for the signed principal's + shared-access receipt and renders the access decision, open contract, and + key-release posture. The branch now includes a non-production + `protected_content_fixture` path that publishes a sealed-object descriptor, + records recipient-scoped key-release grants, invokes DRM/rights/key/decrypt + fixture providers, returns a viewer-scoped protected-open contract, and fails + closed when those providers are absent. +- DRM/rights/key/decrypt provider capsules now advertise explicit protected-content + contracts. DRM is protected-content open orchestration, rights is typed + ACL/rights decisions only, key is typed key-release receipts without raw CEK + exposure, and decrypt is viewer-scoped decrypt/render sessions without broad + plaintext or filesystem authority. Key release now requires an allowed + rights-decision receipt bound to the same principal/session/object/action, and + decrypt/render now requires a typed key-provider release receipt bound to that + same request context. + Object-provider share/open receipts now reference those provider + requirements so protected recipient sharing has a clear receipt chain. +- Object-provider status/share/shared-access responses now expose + protected-content provider-chain readiness. Library status/share dialogs show + that readiness. Production encrypted-recipient payload generation, live dDRM + policy reads, and production dKMS remain separate Trusted content follow-ups. +- Provider transfer receipts now include an explicit transfer ABI. Stream mode + advertises Runtime stream-session semantics: read-next backpressure, live + progress events, and cancel support above the validated JSON/base64 chunk + envelope. +- Carrier availability now reports storage-market policy separately from live + multi-peer proof. Current replication is receipt/proof based and explicitly + marks `settlement: not_configured`. +- Library Properties now includes an archive support matrix for implemented + ZIP/tar/tar.gz download/extract behavior and remaining generic archive + policy/dependency gaps. +- Generic archive families such as `.7z`, `.rar`, `.tar.xz`, `.tar.bz2`, + `.tar.zst`, `.xz`, `.bz2`, `.zst`, `.lz4`, and plain `.gz` are now + recognized as policy-gated archives in object metadata and Library + properties. Policy-gated archives also expose an `Archive Support` context + action so users can see the dependency/release-policy reason instead of a + missing action, while extraction remains disabled. +- Release and planning docs were updated to keep the ontology explicit: + `object-provider` is the current mutable principal-root object provider, + `content-provider` owns published content, and Kubo/IPFS is only a low-level + backend. +- The changelog now describes the Library, Spaces/WebSpace, content + availability, provider invocation/streaming, Home Desktop, Documents handoff, + archive support, sharing foundation, and known non-goals without claiming full + WebSpace federation, remote ACL/key-release, dDRM, or production storage + markets. +- Live Library publish/share was restored and smoke-tested through a signed + Home session. Public Home/Library routes return 200, public Library live + smoke passes, signed Home live smoke passed, and signed Library live smoke + passed through roots, Public write/upload, archive extraction, publish, + status, share, and cleanup. +- Current verification gates are green after the object-provider no-fallback + migration: `home-entropy-check`, WCI alignment, `git diff --check`, + object-provider/Library gateway tests, content tests, Carrier tests, content + command/scheduler/status tests, `cargo check`, `cargo clippy --tests -D + warnings`, and the hard stale-marker sweep for retired object-provider + fallback strings have all passed on the touched release surface. +- A follow-up bounded-product-slice gate also passed for WebSpace adapters, + adapter byte-cache materialization, stream ABI receipts, + Carrier storage-market metadata, recipient-scoped share receipts, Library + dialog syntax, `cargo check`, `cargo clippy`, entropy, WCI alignment, and + `git diff --check`. +- A protected-content/archive-policy gate also passed for drm/rights/key/decrypt + provider contract tests, recipient-scoped share receipt requirements, + generic archive policy-gate metadata, Library dialog/model syntax, + `cargo check`, and `cargo clippy`. +- A protected-content provider-readiness UX gate also passed for recipient + share/status readiness receipts, Library dialog syntax, entropy, WCI + alignment, whitespace, `cargo check`, and `cargo clippy`. +- A WebSpace mutable resolver-sync gate also passed: operator-backed mutable + WebSpace writes now sync back through adapter `write_bytes`, local mutable + spaces fail closed when no resolver adapter exists, and resolver conflicts + return explicit conflict receipts instead of pretending to sync. The gate + covered focused sync tests, the full Library gateway suite, `cargo check`, + entropy, and WCI alignment. +- A WebSpace resolver availability-hint gate also passed: adapter-cached and + adapter-synced WebSpace objects now expose resolver-scope availability hints + in object metadata and sync receipts. The hints are deliberately labeled as + not SmartWeb content availability receipts, so they improve UI/operator + clarity without claiming CID replication. +- An installed operator adapter gate also passed: `operator-drive-adapter` is + now a real provider capsule with manifest/build/release metadata, Runtime + startup registration, Runtime-only provider invocation, deterministic + provider-owned local byte storage, read-only/conflict policy, and hidden + credential-field rejection. This promotes the fixture contract into a shippable + adapter package, while still not claiming external operator federation. +- An operator endpoint-backend gate also passed: `operator-drive-adapter` can be + configured through Runtime operator config with an operator-private loopback + HTTP endpoint, traverse metadata, read bytes, and write mutable forks through + that endpoint. App-visible status/receipts redact endpoint URL and + authorization, and the adapter does not forward the Runtime invocation + envelope to the backend. +- A production-style operator endpoint proof also passed: the adapter can talk + to a filesystem-backed operator endpoint that traverses real temp-dir + metadata, reads bytes, writes mutable forks, and returns no endpoint + credentials, host paths, or Runtime invocation envelope to app-visible + receipts. + +Remaining gaps: + +- The branch is not release-clean yet. The worktree still contains a broad + uncommitted Library/WebSpace/content-provider release diff that must be split + into coherent, reviewable commits before tag/release. +- Final normal Chrome-profile testing is still required. The remaining human + gate is perceived speed/native feel, no stale browser-profile cache behavior, + and no visible Explorer regressions on + `https://elastos.elacitylabs.com/apps/home/`. +- Production protected-content backends are not complete. The branch proves the + Library/Runtime recipient-proof, rights, key-release, decrypt-session, and + viewer-handoff receipt chain with a non-production fixture. Real encrypted + payload production, approved dDRM rights backends, production dKMS/key + release, and production decrypt/render backends remain deferred. +- Provider streaming is complete for this Library branch. Remaining transport + work should be scoped only if it changes behavior beyond the tested Runtime + stream-session and chunked object-download contracts. +- Production multi-peer storage markets are not complete, but durable + accounting slice landed. Signed content availability receipts now project + into a durable per-principal storage-accounting ledger, `content/status` + exposes active/tracked objects, bytes, replica-byte estimates, quota posture, + and no-settlement storage-market metadata, unpublish preserves the original + publisher principal for retired accounting, and publish/import can enforce + `max_storage_bytes_per_principal` before local content-backend writes. + Carrier now also records bounded repair-graph policy and refuses to flatten + requested arbitrary IPLD DAG repair into exact-byte fallback. The + `content-block-graph-provider` path is now present: Runtime reserves + `elastos://block-graph/*`, Carrier exports a local graph through + `export_graph`, imports it on the remote peer through `import_graph`, and + uses the `ipfs-provider` Kubo coordination file to move bounded DAG CAR bytes + without exposing raw Kubo authority to apps. `elastos content status` gives + operators the provider-wide or per-CID availability/storage status through Runtime provider + invocation. Bounded remote content admission preflight is now also present: + Carrier asks the remote content provider for a signed admission receipt before + moving bytes or graph repair data. `content/admission` can now also enforce an + operator-configured storage-market admission endpoint or bounded endpoint + quorum, with accepted or rejected market decisions normalized into the signed + admission receipt and endpoint/quorum failure rejecting admission fail-closed. + Repair-fleet status receipts + now make the current single-runtime coordinator/worker policy explicit, and + the Runtime-gated repair worker can dispatch due tasks to a configured + external repair-fleet endpoint quorum with normalized dispatch receipts while + local provider verification still decides final availability. + Network-abuse policy/status receipts make local guardrails and configured + abuse-control endpoint-quorum exchange posture explicit without pretending + production network-wide throttles or banlists exist. Provider-local operator + dashboard depth now exposes storage pressure and fleet history from the + existing ledgers. + Availability/storage policy and status coverage is now complete for this + branch. Remaining work is production execution: production independent provider-network + quota-ledger federation beyond the configured bounded endpoint quorum, + repair-fleet worker attestation/SLA/settlement beyond configured dispatch + quorum, live market + pricing/escrow/settlement beyond the configured admission gate, actual + federated network throttles/banlists/abuse ledgers beyond the configured + bounded abuse-control endpoint quorum, production peer + reputation trust policy/third-party attestations/revocation beyond the + configured Carrier peer-attestation endpoint quorum, durable remote + peer-selection policy, cross-peer repair policy, and live federated + dashboard/UI/peer-health + subscriptions beyond the current provider-local dashboard plus configured + alert-exchange endpoint. +- Object-provider capsule migration is now in place for package/manifest/profile + selection, Runtime startup, and browser routing. The branch uses the canonical + `object-provider` package with the `object` Runtime scheme and + `/api/provider/object/*` route. The pure object-provider core still lives in + `elastos-server::library`; extracting that into a smaller core crate is + architecture/build-review cleanup, not a current Library behavior blocker. +- Archive support is good for this release slice, but richer generic archive + UX, generic non-tar/non-zip archive extraction, archive-manager/import flows, + and WebSpace selection archives remain deferred until resolver/archive policy + is clearer. Unsupported archive families are now recognized and labeled as + policy-gated rather than enabled. +- The next PC2 slices should stay out of this release until Library is clean: + AI Chat, dDRM, Elacity Marketplace, Mac VZ, and broader Browser provider work. + +Bottom line: Library is now substantially more complete than last week. It is +PC2-familiar for users, but the backend model is ElastOS-native: Runtime +principals and provider mediation own authority, `object-provider` owns mutable +local objects, `content-provider` owns published content and +availability, Carrier coordinates remote proof/repair, and Kubo/IPFS remains an +internal backend. The remaining work is mostly release hygiene, final human +testing, and keeping product deferrals mapped to plain product areas: +production multi-peer/storage-market infrastructure, production content-rights +backends, and format-specific archive dependency approvals. diff --git a/elastos/CHANGELOG.md b/elastos/CHANGELOG.md index 1601e84e..703f5491 100644 --- a/elastos/CHANGELOG.md +++ b/elastos/CHANGELOG.md @@ -5,6 +5,208 @@ All notable changes to the public ElastOS Runtime repository. ## [Next] ### Added +- Added a provider-backed Library / Explorer release slice: PC2-familiar + places, breadcrumbs, grid/list views, inline create/rename, drag/drop, + preview/open, upload/download, folder and selected-object archive download, + provider-created ZIP objects, safe `.tar`/`.tar.gz`/`.tgz`/`.zip` + extraction, publish/share/status/repair, Trash, browser Back/Forward + takeover, and PC2-style context menus now run through Runtime-scoped Library + provider authority. +- Added `object-provider` as the mutable principal-root object provider for + Library files/folders, Desktop/Documents/Public roots, protected-root object + envelopes, object events, legacy plaintext auto-protection, and WebSpace + resolver routing. Runtime registers the canonical `object` provider scheme, + and browser calls use `/api/provider/object/*`. +- Added Library-to-Documents viewer handoff and Home Desktop projection so + concrete Library objects can open in Documents and Desktop file mutations stay + provider-owned while Home only displays and launches them. +- Added the current Spaces/WebSpace contract for Library: + `localhost://WebSpaces/*` is the local mounted resolver view shown as + **Spaces**, provider targets such as `google://drive/...` remain + resolver-private, read-only mounts hide mutable actions, mutable mounts/forks + can materialize provider-owned objects, and `elastos://content/*` remains the + provider-independent published/shared content surface. +- Added the first Carrier-backed content availability proof path for Library + publish/status/repair: `content-provider` signs availability receipts with + peer-selection, quota, repair-worker, accounting, and abuse-control metadata; + built-in Carrier availability can invoke remote service providers for + `content/ensure`, `content/status`, manifest-backed object import, and + exact-CID byte fallback without exposing raw Carrier tickets, Kubo/IPFS, or + peer handles to apps. +- Added Runtime provider-to-provider invocation foundations, including typed + invocation envelopes, transfer receipts, range/progress propagation, a + validated bounded `ProviderTransfer::Stream` base64-chunk envelope, and + service-provider-only Carrier `provider_invoke` transport. +- Added Runtime-native provider stream sessions on top of + `ProviderTransfer::Stream`: providers can be opened as read/cancel sessions + with live progress events and read-next backpressure, `content-provider` + fetch consumes local IPFS and availability fallback reads through that + session path, and Library object downloads now return chunked HTTP body + streams with backpressure/cancel transfer receipts. +- Added the WebSpace federation slice: `operator-drive-adapter` now has an + operator-private endpoint backend contract with redacted config/status, + provider-to-provider invocation boundaries, durable cache/viewer handoff + coverage, mutable fork write-back, and a filesystem-backed endpoint proof for + metadata traversal, byte reads, and mutable writes. +- Added recipient-scoped Library share-grant records, `shared_access` checks, + access-decision/shared-open receipts, and fail-closed key-release policy + handling as the current local/provider-mediated sharing slice. +- Added the protected recipient receipt-chain proof: Library can publish a + non-production `protected_content_fixture` sealed-object descriptor through + `content-provider`, record recipient-scoped key-release grants, bind + `shared_access` to Runtime recipient proof plus launch `session_id`, invoke + DRM/rights/key/decrypt providers, return a viewer-scoped protected-open + contract, and fail closed when protected-content providers are absent without + exposing raw CEKs, plaintext, wallet, chain, Kubo, host filesystem, or + provider credentials to apps. +- Added durable storage accounting: `content-provider` projects + signed availability receipts into a persistent per-principal ledger, exposes + storage-accounting summaries and no-settlement storage-market posture through + `content/status`, and preserves original publisher identity when unpublish is + called without explicit principal metadata. +- Added principal storage-quota admission for content publish/import: + `availability_requirements.max_storage_bytes_per_principal` is enforced from + the durable content accounting ledger before local content-backend writes and + recorded as `principal_storage_quota` posture in receipts/status. +- Added bounded cross-provider content admission preflight: `content-provider` + exposes provider-only signed `elastos.content.admission/v1` receipts, and + Carrier invokes and verifies remote `content/admission` before + `content/ensure`, exact/object import, or block-graph repair transfer so + unsigned admission, payload mismatches, and quota rejections fail closed before + bytes move. +- Added an optional storage-market endpoint-quorum admission gate for `content/admission`: + `ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_*` can point content-provider at one + operator-owned admission endpoint or a bounded configured endpoint set with an + explicit quorum, and accepted/rejected market decisions are normalized into + the signed admission receipt before Carrier moves bytes or DAG repair data. + Credentials are redacted from status, and endpoint or quorum failure rejects + admission fail-closed. +- Added optional external repair-fleet dispatch for the Runtime-gated + `content repair-worker`: `ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_*` can point + content-provider at one operator-owned dispatch endpoint or a bounded endpoint + set with an explicit quorum, due tasks are sent as + `elastos.content.external-repair-fleet.dispatch-request/v1`, replies are + normalized into dispatch receipts with endpoint receipts plus quorum counters, + and credentials are redacted while local provider verification still decides + final availability. +- Added bounded Carrier repair-graph policy receipts: current cross-peer repair + explicitly supports object-manifest and exact-byte import fallbacks while + refusing arbitrary IPLD DAG fallback unless the Runtime-only block-graph + provider ABI is available. +- Added the block-graph provider path: Runtime reserves + `elastos://block-graph/*`, the build/release surfaces include a + `content-block-graph-provider` contract capsule, startup registers the + verified provider when installed, and Carrier routes arbitrary `ipld_dag` + repair through local `export_graph` plus remote `import_graph` provider-plane + invocations. The provider uses the `ipfs-provider` Kubo coord file to export + and import bounded DAG CAR bytes, pins imported roots, and fails closed when + Kubo/provider setup is absent. +- Added `elastos content status` for operator inspection of provider-wide or + per-CID availability, storage-accounting, quota, repair, peer-proof, and + no-settlement storage-market status through Runtime provider invocation. +- Added `elastos.content.repair-fleet/v1` status receipts to content dashboard + and repair-worker runs so operators can inspect the current single-runtime + provider-owned repair coordinator/worker policy, ledger-based due scheduling, + task pressure, and explicit non-production external-fleet/settlement posture. +- Added `elastos.content.network-abuse-policy/v1` status receipts to content + dashboard, per-CID status, and repair-worker runs so operators can inspect + provider-owned local guardrails, Carrier candidate caps, admission preflight + posture, repair-worker budgets, configured abuse-control endpoint-quorum + exchange posture, and explicit non-production network-wide + throttles/banlists/abuse-ledger posture. +- Added `elastos.content.operator-dashboard/v1` to provider-wide content status + so operators can inspect provider-local storage pressure, top principals, + replica-byte estimates, quota-exceeded records, fleet-history attempts, recent + repair rows, live-proof counts, and explicit non-production federation posture. +- Added `elastos.carrier.peer-reputation/v1` policy/status metadata to Carrier + peer selection and content status so local Runtime success/failure scoring is + visible while signed cross-runtime reputation remains explicitly not + configured. +- Added `elastos.carrier.peer-attestation-exchange-policy/v1` metadata to + Carrier peer selection, redacted remote receipt summaries, content proof + summaries, and operator dashboard surfaces so signed availability + announcements, verified remote content receipts, remote provider proofs, and + local Runtime reputation are distinguished from unconfigured signed + cross-runtime reputation receipts, third-party attestations, trust-policy + exchange, and revocation. +- Added opt-in Carrier peer-attestation exchange: when + `ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_*` is configured, + `carrier-availability` posts a signed + `elastos.carrier.peer-attestation.exchange-request/v1` with redacted remote + proof summaries to one operator-owned exchange endpoint or a bounded endpoint + set with an explicit quorum, requires accepted endpoint responses to include + signed `elastos.carrier.peer-attestation.exchange-receipt/v1` receipts, + verifies receipts before marking configured quorum accepted, records endpoint + receipts plus quorum counters, and keeps connect tickets plus endpoint + credentials out of app-visible proof surfaces. +- Added `elastos.content.storage-settlement-policy/v1` metadata to local, + Carrier, ledger, per-CID, and provider-wide storage-market status so pricing, + escrow, payment settlement, SLA enforcement, storage-market admission, and + cross-provider escrow are explicit non-production policy state. +- Added `elastos.content.storage-market-admission-policy/v1` metadata to local, + Carrier, ledger, per-CID, provider-wide, and operator-dashboard + storage-market surfaces so local quota admission and remote + `content/admission` preflight are distinguished from unconfigured production + provider-admission networks, offer receipts, price discovery, SLA admission, + and economic abuse controls. +- Preserved proof metadata through the standalone `availability-provider` + capsule so configured external availability targets can report + `storage_market`, `repair_graph`, and `abuse_controls` on the same + Runtime-validated contract as built-in Carrier availability, with explicit + no-market / target-report-only defaults when a target omits them; configured + target fanout can now satisfy min-replica/live-proof requirements without + bypassing max-replica quota. +- Added `elastos.content.federated-quota-ledger-policy/v1` metadata to local, + principal storage-quota, Carrier quota, remote receipt, per-CID, provider-wide + status, and operator dashboard surfaces so local per-principal ledgers and + signed remote admission receipt exchange are distinguished from configured + signed federated quota-ledger endpoint-quorum exchange and production + quota-receipt exchange. +- Added opt-in federated quota-ledger exchange for remote admission preflight: + `content/admission` can post a signed + `elastos.content.federated-quota-ledger.exchange-request/v1` to one + operator-configured endpoint or a bounded endpoint set with an explicit + quorum, require signed + `elastos.content.federated-quota-ledger.exchange-receipt/v1` receipts for + accepted endpoints, record endpoint receipts and quorum counters in the signed + admission receipt, and reject fail-closed on configured quorum failure, + malformed signed receipt, timeout, or transport failure without exposing + endpoint credentials. +- Added opt-in federated abuse-control exchange for remote admission preflight: + `content/admission` can post a signed + `elastos.content.federated-abuse-control.exchange-request/v1` to one + operator-configured endpoint or a bounded endpoint set with an explicit + quorum before quota-ledger, storage-market, byte-transfer, or repair-graph + movement, require signed + `elastos.content.federated-abuse-control.exchange-receipt/v1` receipts for + accepted endpoints, record endpoint receipts and quorum counters in the signed + admission receipt, and reject fail-closed on configured quorum failure, + malformed signed receipt, timeout, transport failure, or receipt verification + failure without exposing endpoint credentials. +- Added `elastos.content.external-repair-fleet-policy/v1` metadata to + provider-wide status, repair-worker runs, and operator dashboard surfaces so + the local provider-owned repair worker is distinguished from unconfigured + external coordinators, volunteer/supernode workers, cross-provider queues, + worker attestations, settlement, and repair SLAs. +- Added `elastos.content.federated-operator-alerting-policy/v1` metadata to + provider-wide status and operator dashboard surfaces so provider-local status + JSON, storage pressure, repair-task pressure, live-proof counters, and + remote-receipt counters plus configured provider-local alert sink and + configured federated alert-exchange posture are distinguished from production + cross-provider dashboards, peer-health subscriptions, fleet-wide SLA policy, + and operator UI. +- Added opt-in operator alert delivery for content availability: provider-wide + `content/status` can emit a durable + `elastos.content.operator-alert.receipt/v1` outbox entry, post an + `elastos.content.operator-alert/v1` payload to one HTTPS or loopback sink + when `ELASTOS_CONTENT_OPERATOR_ALERT_*` is configured, and deliver a typed + `elastos.content.federated-operator-alert.exchange-request/v1` to one + operator-owned exchange endpoint when + `ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_*` is configured. The + normalized + `elastos.content.federated-operator-alert.exchange-receipt/v1` is recorded + beside the provider-local sink result without exposing operator credentials to + apps or status JSON. - Added runtime authority primitives for proof-bound authentication: principals, proof bindings, SIWE challenges, session grants, and audit events. - Added EVM SIWE challenge, verify, and revoke gateway routes that bind verified wallet proofs to runtime principals and issue scoped Home/System launch grants. - Added `chain-provider` as the first blockchain provider capsule: typed `elastos://chain/*` access for Essentials-compatible Elastos networks without exposing raw RPC URLs to app capsules. diff --git a/elastos/Cargo.lock b/elastos/Cargo.lock index 5a02434b..c77bb4dd 100644 --- a/elastos/Cargo.lock +++ b/elastos/Cargo.lock @@ -171,6 +171,9 @@ name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] [[package]] name = "arc-swap" @@ -1317,6 +1320,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -1797,6 +1811,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "zip", ] [[package]] @@ -7376,6 +7391,22 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.18", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/elastos/crates/elastos-common/src/protected_content.rs b/elastos/crates/elastos-common/src/protected_content.rs index 1d359f06..5b7b927f 100644 --- a/elastos/crates/elastos-common/src/protected_content.rs +++ b/elastos/crates/elastos-common/src/protected_content.rs @@ -12,6 +12,7 @@ pub const KEY_RELEASE_REQUEST_SCHEMA: &str = "elastos.key_release.request/v1"; pub const DECRYPT_SESSION_REQUEST_SCHEMA: &str = "elastos.decrypt.session.request/v1"; pub const DECRYPT_SESSION_SCHEMA: &str = "elastos.decrypt.session/v1"; pub const RELEASE_RECEIPT_SCHEMA: &str = "elastos.release.receipt/v1"; +pub const RIGHTS_DECISION_RECEIPT_SCHEMA: &str = "elastos.rights.decision.receipt/v1"; pub const PROTECTED_CONTENT_ACTIONS: &[&str] = &["view", "stream", "download", "execute"]; pub const PROTECTED_CONTENT_OUTPUTS: &[&str] = &["rendered", "stream", "working_copy"]; pub const DEFAULT_PROTECTED_CONTENT_CIPHER: &str = "aes-256-gcm"; @@ -174,6 +175,7 @@ pub struct KeyReleaseRequestV1 { pub session_id: String, pub object_cid: String, pub action: String, + pub rights_receipt: RightsDecisionReceiptV1, pub key_envelope: KeyEnvelopeV1, pub reason: String, pub expires_at: u64, @@ -189,7 +191,7 @@ pub struct DecryptSessionRequestV1 { pub object_cid: String, pub action: String, pub viewer_interface: String, - pub release_receipt_id: String, + pub release_receipt: ReleaseReceiptV1, pub output_kind: String, pub reason: String, pub expires_at: u64, @@ -213,12 +215,29 @@ pub struct ReleaseReceiptV1 { pub request_id: String, pub object_cid: String, pub principal_id: String, + pub session_id: String, + pub action: String, pub provider: String, pub status: String, pub issued_at: u64, pub expires_at: u64, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RightsDecisionReceiptV1 { + pub schema: String, + pub request_id: String, + pub content_id: String, + pub principal_id: String, + pub session_id: String, + pub right: String, + pub provider: String, + pub allowed: bool, + pub issued_at: u64, + pub expires_at: u64, +} + #[cfg(test)] mod tests { use super::*; @@ -271,7 +290,18 @@ mod tests { object_cid: "bafybeigprotectedcontent".to_string(), action: PROTECTED_CONTENT_ACTIONS[0].to_string(), viewer_interface: "elastos.viewer/document@1".to_string(), - release_receipt_id: "release:test".to_string(), + release_receipt: ReleaseReceiptV1 { + schema: RELEASE_RECEIPT_SCHEMA.to_string(), + request_id: "key-release:test".to_string(), + object_cid: "bafybeigprotectedcontent".to_string(), + principal_id: "person:local:test".to_string(), + session_id: "session:test".to_string(), + action: PROTECTED_CONTENT_ACTIONS[0].to_string(), + provider: "key-provider".to_string(), + status: "released".to_string(), + issued_at: 1_800_000_000, + expires_at: 1_900_000_000, + }, output_kind: PROTECTED_CONTENT_OUTPUTS[0].to_string(), reason: "open protected document".to_string(), expires_at: 1_900_000_000, @@ -399,6 +429,18 @@ mod tests { "session_id": "session:test", "object_cid": "bafybeigprotectedcontent", "action": "view", + "rights_receipt": { + "schema": RIGHTS_DECISION_RECEIPT_SCHEMA, + "request_id": "rights:test", + "content_id": "bafybeigprotectedcontent", + "principal_id": "person:local:test", + "session_id": "session:test", + "right": "view", + "provider": "rights-provider", + "allowed": true, + "issued_at": 1_800_000_000u64, + "expires_at": 1_900_000_000u64 + }, "key_envelope": { "scheme": "elastos-pq-hybrid-threshold-v0", "kid": "kid:test", @@ -432,7 +474,18 @@ mod tests { "object_cid": "bafybeigprotectedcontent", "action": "view", "viewer_interface": "elastos.viewer/document@1", - "release_receipt_id": "release:test", + "release_receipt": { + "schema": RELEASE_RECEIPT_SCHEMA, + "request_id": "key-release:test", + "object_cid": "bafybeigprotectedcontent", + "principal_id": "person:local:test", + "session_id": "session:test", + "action": "view", + "provider": "key-provider", + "status": "released", + "issued_at": 1_800_000_000u64, + "expires_at": 1_900_000_000u64 + }, "output_kind": "rendered", "reason": "open protected document", "expires_at": 1_900_000_000u64, diff --git a/elastos/crates/elastos-runtime/src/provider/mod.rs b/elastos/crates/elastos-runtime/src/provider/mod.rs index a8783292..81663a2d 100755 --- a/elastos/crates/elastos-runtime/src/provider/mod.rs +++ b/elastos/crates/elastos-runtime/src/provider/mod.rs @@ -11,7 +11,10 @@ mod registry; pub use bridge::{CapsuleProvider, ProviderBridge, ProviderConfig as BridgeProviderConfig}; pub use registry::{ - EntryType, Provider, ProviderError, ProviderRegistry, ResourceAction, ResourceResponse, + EntryType, Provider, ProviderByteRange, ProviderCarrierInvoker, ProviderCarrierRoute, + ProviderError, ProviderInvocation, ProviderInvocationTransport, ProviderProgress, + ProviderRegistry, ProviderStreamOptions, ProviderStreamRead, ProviderStreamSession, + ProviderTransfer, ResourceAction, ResourceResponse, }; // Re-export for use by external provider implementations diff --git a/elastos/crates/elastos-runtime/src/provider/registry.rs b/elastos/crates/elastos-runtime/src/provider/registry.rs index 66c8e20f..603adb83 100755 --- a/elastos/crates/elastos-runtime/src/provider/registry.rs +++ b/elastos/crates/elastos-runtime/src/provider/registry.rs @@ -8,9 +8,11 @@ //! exclusively: `elastos://did/*`, `elastos://peer/*`, `elastos://ai/*`. use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use tokio::sync::RwLock; +use base64::Engine as _; use elastos_common::localhost::{parse_localhost_path, parse_localhost_uri}; /// A resource request @@ -113,6 +115,289 @@ pub enum ProviderError { Io(std::io::Error), } +/// Provider-to-provider invocation transfer contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderTransfer { + /// JSON request/response envelope. + Json, + /// JSON envelope whose response may carry bounded byte arrays. + Bytes, + /// JSON envelope whose response carries bounded byte chunks. + Stream, +} + +impl ProviderTransfer { + pub fn as_str(self) -> &'static str { + match self { + ProviderTransfer::Json => "json", + ProviderTransfer::Bytes => "bytes", + ProviderTransfer::Stream => "stream", + } + } +} + +const PROVIDER_STREAM_SCHEMA: &str = "elastos.provider.stream/v1"; +const PROVIDER_STREAM_ENCODING: &str = "base64-chunks"; +const PROVIDER_STREAM_CHUNK_BYTES: usize = 64 * 1024; +const PROVIDER_STREAM_SESSION_SCHEMA: &str = "elastos.provider.stream-session/v1"; +const PROVIDER_STREAM_EVENT_SCHEMA: &str = "elastos.provider.stream-event/v1"; +static NEXT_PROVIDER_STREAM_ID: AtomicU64 = AtomicU64::new(1); + +/// Carrier route for Runtime-mediated provider-to-provider invocation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderCarrierRoute { + /// Internal Carrier connect ticket. Receipts intentionally do not expose it. + pub connect_ticket: String, + /// Optional expected remote peer DID/node identity for audit and policy. + pub peer_did: Option, + /// Optional per-hop timeout. Runtime rejects zero-duration values. + pub timeout_ms: Option, +} + +/// Runtime transport used for provider-to-provider invocation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProviderInvocationTransport { + /// In-process Runtime provider registry. + Local, + /// Carrier provider plane. Apps never select this directly. + Carrier(ProviderCarrierRoute), +} + +impl ProviderInvocationTransport { + fn as_str(&self) -> &'static str { + match self { + ProviderInvocationTransport::Local => "runtime-local-provider-plane", + ProviderInvocationTransport::Carrier(_) => "carrier-provider-plane", + } + } + + fn carrier_route(&self) -> Option<&ProviderCarrierRoute> { + match self { + ProviderInvocationTransport::Local => None, + ProviderInvocationTransport::Carrier(route) => Some(route), + } + } +} + +impl Default for ProviderInvocationTransport { + fn default() -> Self { + Self::Local + } +} + +/// Optional byte range requested for a provider-to-provider transfer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProviderByteRange { + /// Inclusive start byte. + pub start: u64, + /// Inclusive end byte. `None` means open-ended. + pub end: Option, +} + +/// Optional progress receipt metadata for a provider-to-provider transfer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderProgress { + /// Runtime- or provider-owned request id used to correlate progress receipts. + pub request_id: String, + /// Expected byte count when known. + pub expected_bytes: Option, +} + +/// Runtime stream-session flow-control options. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProviderStreamOptions { + /// Maximum chunk size returned by each `read_next` call. + pub chunk_size: usize, + /// Maximum chunks the consumer may request ahead of processing. Runtime + /// sessions currently enforce one explicit read per chunk. + pub max_in_flight_chunks: usize, +} + +impl Default for ProviderStreamOptions { + fn default() -> Self { + Self { + chunk_size: PROVIDER_STREAM_CHUNK_BYTES, + max_in_flight_chunks: 1, + } + } +} + +/// One Runtime-native stream read. +#[derive(Debug, Clone)] +pub struct ProviderStreamRead { + /// Runtime-owned stream session id. + pub session_id: String, + /// Zero-based chunk index. + pub index: usize, + /// Byte offset of this chunk in the stream. + pub offset: usize, + /// Chunk bytes. + pub bytes: Vec, + /// Whether this read completed the stream. + pub completed: bool, + /// Runtime progress event for this read. + pub progress: serde_json::Value, +} + +/// Runtime-owned provider stream session. +#[derive(Debug, Clone)] +pub struct ProviderStreamSession { + id: String, + source: String, + target: String, + op: String, + request_id: String, + bytes: Vec, + cursor: usize, + read_index: usize, + chunk_size: usize, + max_in_flight_chunks: usize, + cancelled: bool, + transfer_receipt: Option, +} + +impl ProviderStreamSession { + pub fn id(&self) -> &str { + &self.id + } + + pub fn total_bytes(&self) -> usize { + self.bytes.len() + } + + pub fn is_cancelled(&self) -> bool { + self.cancelled + } + + pub fn receipt(&self) -> serde_json::Value { + serde_json::json!({ + "schema": PROVIDER_STREAM_SESSION_SCHEMA, + "session_id": self.id, + "source": self.source, + "target": self.target, + "op": self.op, + "request_id": self.request_id, + "total_bytes": self.bytes.len(), + "chunk_size": self.chunk_size, + "max_in_flight_chunks": self.max_in_flight_chunks, + "transport_native_stream": true, + "backpressure": "read_next", + "cancel_supported": true, + "progress_mode": "stream_events", + "status": if self.cancelled { + "cancelled" + } else if self.cursor >= self.bytes.len() { + "completed" + } else { + "open" + }, + "transfer": self.transfer_receipt, + }) + } + + pub fn read_next(&mut self) -> Result, ProviderError> { + if self.cancelled { + return Err(ProviderError::Provider(format!( + "provider stream session {} is cancelled", + self.id + ))); + } + if self.cursor >= self.bytes.len() { + return Ok(None); + } + let start = self.cursor; + let end = (start + self.chunk_size).min(self.bytes.len()); + self.cursor = end; + let index = self.read_index; + self.read_index += 1; + let completed = self.cursor >= self.bytes.len(); + let chunk = self.bytes[start..end].to_vec(); + let progress = self.progress_event( + index, + start, + chunk.len(), + if completed { "completed" } else { "progress" }, + ); + Ok(Some(ProviderStreamRead { + session_id: self.id.clone(), + index, + offset: start, + bytes: chunk, + completed, + progress, + })) + } + + pub fn cancel(&mut self) -> serde_json::Value { + self.cancelled = true; + self.progress_event(self.read_index, self.cursor, 0, "cancelled") + } + + pub fn drain_to_vec(&mut self) -> Result, ProviderError> { + let mut out = Vec::with_capacity(self.bytes.len().saturating_sub(self.cursor)); + while let Some(read) = self.read_next()? { + out.extend_from_slice(&read.bytes); + } + Ok(out) + } + + fn progress_event( + &self, + index: usize, + offset: usize, + bytes: usize, + status: &str, + ) -> serde_json::Value { + serde_json::json!({ + "schema": PROVIDER_STREAM_EVENT_SCHEMA, + "session_id": self.id, + "request_id": self.request_id, + "source": self.source, + "target": self.target, + "op": self.op, + "index": index, + "offset": offset, + "bytes": bytes, + "transferred_bytes": self.cursor, + "total_bytes": self.bytes.len(), + "status": status, + }) + } +} + +/// Typed provider-to-provider invocation routed by the Runtime registry. +#[derive(Debug, Clone)] +pub struct ProviderInvocation { + /// Provider initiating the call. + pub source: String, + /// Target provider scheme or sub-provider name. + pub target: String, + /// Target operation name for audit/debug validation. + pub op: String, + /// Raw request understood by the target provider. + pub request: serde_json::Value, + /// Expected transfer shape. + pub transfer: ProviderTransfer, + /// Optional byte range contract for bytes/stream transfers. + pub range: Option, + /// Optional progress receipt contract for large transfers. + pub progress: Option, + /// Runtime-owned provider transport. + pub transport: ProviderInvocationTransport, +} + +/// Runtime plug-in point for Carrier provider-plane transport. +#[async_trait::async_trait] +pub trait ProviderCarrierInvoker: Send + Sync { + /// Send an already Runtime-enveloped provider request to a remote Carrier peer. + async fn invoke_carrier_provider( + &self, + route: &ProviderCarrierRoute, + invocation: &ProviderInvocation, + request: serde_json::Value, + ) -> Result; +} + impl std::fmt::Display for ProviderError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -176,11 +461,13 @@ const RESERVED_SUB_NAMES: &[&str] = &[ "exit", "browser-engine", "wallet", + "library", "drm", "rights", "key", "decrypt", "availability", + "block-graph", ]; /// Registry of providers @@ -189,6 +476,8 @@ pub struct ProviderRegistry { providers: RwLock>>, /// Sub-providers for elastos:// hierarchical dispatch (e.g., elastos://peer/...) sub_providers: RwLock>>, + /// Optional Carrier transport for Runtime-mediated provider invocation. + carrier_invoker: RwLock>>, } impl ProviderRegistry { @@ -197,9 +486,15 @@ impl ProviderRegistry { Self { providers: RwLock::new(HashMap::new()), sub_providers: RwLock::new(HashMap::new()), + carrier_invoker: RwLock::new(None), } } + /// Register the Runtime-owned Carrier provider-plane invoker. + pub async fn set_carrier_invoker(&self, invoker: Arc) { + *self.carrier_invoker.write().await = Some(invoker); + } + /// Register a provider for its schemes pub async fn register(&self, provider: Arc) { let mut providers = self.providers.write().await; @@ -458,6 +753,122 @@ impl ProviderRegistry { Err(ProviderError::NoProvider(scheme.to_string())) } + /// Invoke one provider from another through an explicit Runtime contract. + /// + /// This is the non-app-visible provider plane. It keeps provider-to-provider + /// effects out of capsule UI code while giving future Carrier/streaming + /// transports one contract to replace instead of many ad hoc `send_raw` + /// call sites. + pub async fn invoke_provider( + &self, + invocation: ProviderInvocation, + ) -> Result { + if invocation.source.trim().is_empty() { + return Err(ProviderError::Provider( + "provider invocation requires source".to_string(), + )); + } + if invocation.target.trim().is_empty() { + return Err(ProviderError::Provider( + "provider invocation requires target".to_string(), + )); + } + if invocation.op.trim().is_empty() { + return Err(ProviderError::Provider( + "provider invocation requires op".to_string(), + )); + } + validate_provider_transfer_contract(&invocation)?; + let target_op = invocation + .request + .get("op") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if target_op != invocation.op { + return Err(ProviderError::Provider(format!( + "provider invocation op mismatch: envelope={}, request={}", + invocation.op, target_op + ))); + } + let mut request = invocation.request.clone(); + attach_provider_invocation_envelope(&mut request, &invocation)?; + let mut response = match invocation.transport.carrier_route() { + Some(route) => { + let invoker = self.carrier_invoker.read().await.clone().ok_or_else(|| { + ProviderError::Provider(format!( + "Carrier provider invocation requires registered Carrier invoker: {}", + provider_transfer_receipt(&invocation, "failed_closed") + )) + })?; + invoker + .invoke_carrier_provider(route, &invocation, request) + .await? + } + None => self.send_raw(&invocation.target, &request).await?, + }; + apply_provider_transfer_response(&mut response, &invocation)?; + attach_provider_transfer_receipt(&mut response, &invocation, "completed"); + Ok(response) + } + + /// Open a Runtime-native stream session for a provider `Stream` transfer. + /// + /// The target provider still speaks the normal provider envelope; Runtime + /// turns the validated stream payload into a typed read/cancel channel so + /// consumers can apply backpressure and observe progress without app-visible + /// provider authority. + pub async fn open_provider_stream( + &self, + mut invocation: ProviderInvocation, + options: ProviderStreamOptions, + ) -> Result { + invocation.transfer = ProviderTransfer::Stream; + let progress = invocation.progress.clone(); + let response = self.invoke_provider(invocation.clone()).await?; + if response.get("status").and_then(|status| status.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|message| message.as_str()) + .unwrap_or("unknown provider stream error"); + return Err(ProviderError::Provider(format!( + "provider stream open failed: {message}" + ))); + } + let data = response + .get("data") + .and_then(|data| data.as_object()) + .ok_or_else(|| { + ProviderError::Provider( + "provider stream open requires response data object".to_string(), + ) + })?; + let bytes = provider_stream_response_bytes(data)?; + let chunk_size = options.chunk_size.clamp(1, PROVIDER_STREAM_CHUNK_BYTES); + let max_in_flight_chunks = options.max_in_flight_chunks.max(1); + let id = format!( + "provider-stream:{}", + NEXT_PROVIDER_STREAM_ID.fetch_add(1, Ordering::Relaxed) + ); + let request_id = progress + .as_ref() + .map(|progress| progress.request_id.clone()) + .unwrap_or_else(|| id.clone()); + Ok(ProviderStreamSession { + id, + source: invocation.source, + target: invocation.target, + op: invocation.op, + request_id, + bytes, + cursor: 0, + read_index: 0, + chunk_size, + max_in_flight_chunks, + cancelled: false, + transfer_receipt: response.get("_runtime_transfer").cloned(), + }) + } + fn is_webspaces_localhost_path(path: &str) -> bool { parse_localhost_uri(path) .or_else(|| parse_localhost_path(path)) @@ -466,6 +877,494 @@ impl ProviderRegistry { } } +fn validate_provider_transfer_contract( + invocation: &ProviderInvocation, +) -> Result<(), ProviderError> { + if let Some(range) = invocation.range { + if matches!(invocation.transfer, ProviderTransfer::Json) { + return Err(ProviderError::Provider( + "provider byte range requires bytes or stream transfer".to_string(), + )); + } + if let Some(end) = range.end { + if end < range.start { + return Err(ProviderError::Provider(format!( + "provider byte range end {end} is before start {}", + range.start + ))); + } + } + } + if let Some(progress) = invocation.progress.as_ref() { + if progress.request_id.trim().is_empty() { + return Err(ProviderError::Provider( + "provider progress receipt requires request_id".to_string(), + )); + } + } + if let Some(route) = invocation.transport.carrier_route() { + if route.connect_ticket.trim().is_empty() { + return Err(ProviderError::Provider( + "Carrier provider invocation requires connect_ticket".to_string(), + )); + } + if route.timeout_ms == Some(0) { + return Err(ProviderError::Provider( + "Carrier provider invocation timeout_ms must be greater than zero".to_string(), + )); + } + } + Ok(()) +} + +fn apply_provider_transfer_response( + response: &mut serde_json::Value, + invocation: &ProviderInvocation, +) -> Result<(), ProviderError> { + match invocation.transfer { + ProviderTransfer::Json => Ok(()), + ProviderTransfer::Bytes => apply_provider_byte_range(response, invocation), + ProviderTransfer::Stream => apply_provider_stream_response(response, invocation), + } +} + +fn attach_provider_transfer_receipt( + response: &mut serde_json::Value, + invocation: &ProviderInvocation, + status: &str, +) { + let Some(object) = response.as_object_mut() else { + return; + }; + object.insert( + "_runtime_transfer".to_string(), + provider_transfer_receipt(invocation, status), + ); +} + +fn attach_provider_invocation_envelope( + request: &mut serde_json::Value, + invocation: &ProviderInvocation, +) -> Result<(), ProviderError> { + let Some(object) = request.as_object_mut() else { + return Err(ProviderError::Provider( + "provider invocation request must be a JSON object".to_string(), + )); + }; + for reserved in ["_runtime_invocation", "_runtime_transfer"] { + if object.contains_key(reserved) { + return Err(ProviderError::Provider(format!( + "provider invocation request must not predeclare runtime field {reserved}" + ))); + } + } + object.insert( + "_runtime_invocation".to_string(), + provider_invocation_envelope(invocation), + ); + Ok(()) +} + +fn provider_byte_range_bounds( + bytes_len: usize, + range: ProviderByteRange, +) -> Result<(usize, usize), ProviderError> { + let start = usize::try_from(range.start).map_err(|_| { + ProviderError::Provider("provider byte range start is too large".to_string()) + })?; + if start >= bytes_len { + return Err(ProviderError::Provider(format!( + "provider byte range start {} exceeds payload length {}", + range.start, bytes_len + ))); + } + let end = range + .end + .map(|end| { + usize::try_from(end).map_err(|_| { + ProviderError::Provider("provider byte range end is too large".to_string()) + }) + }) + .transpose()? + .map(|end| end.min(bytes_len.saturating_sub(1))) + .unwrap_or_else(|| bytes_len.saturating_sub(1)); + Ok((start, end)) +} + +fn apply_provider_byte_range( + response: &mut serde_json::Value, + invocation: &ProviderInvocation, +) -> Result<(), ProviderError> { + let Some(range) = invocation.range else { + return Ok(()); + }; + if invocation.transfer != ProviderTransfer::Bytes { + return Ok(()); + } + if response.get("status").and_then(|status| status.as_str()) == Some("error") { + return Ok(()); + } + let data_value = response + .get_mut("data") + .and_then(|data| data.as_object_mut()) + .and_then(|data| data.get_mut("data")) + .ok_or_else(|| { + ProviderError::Provider( + "provider byte range requires response data.data base64 payload".to_string(), + ) + })?; + let encoded = data_value.as_str().ok_or_else(|| { + ProviderError::Provider( + "provider byte range requires response data.data base64 string".to_string(), + ) + })?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|err| { + ProviderError::Provider(format!( + "provider byte range response has invalid base64 payload: {err}" + )) + })?; + let (start, end) = provider_byte_range_bounds(bytes.len(), range)?; + let sliced = &bytes[start..=end]; + if let Some(expected) = invocation + .progress + .as_ref() + .and_then(|progress| progress.expected_bytes) + { + if expected != sliced.len() as u64 { + return Err(ProviderError::Provider(format!( + "provider byte range expected {expected} bytes but produced {}", + sliced.len() + ))); + } + } + *data_value = + serde_json::Value::String(base64::engine::general_purpose::STANDARD.encode(sliced)); + Ok(()) +} + +fn apply_provider_stream_response( + response: &mut serde_json::Value, + invocation: &ProviderInvocation, +) -> Result<(), ProviderError> { + if response.get("status").and_then(|status| status.as_str()) == Some("error") { + return Ok(()); + } + let mut bytes = { + let data = response + .get("data") + .and_then(|data| data.as_object()) + .ok_or_else(|| { + ProviderError::Provider("provider stream requires response data object".to_string()) + })?; + provider_stream_response_bytes(data)? + }; + if let Some(range) = invocation.range { + let (start, end) = provider_byte_range_bounds(bytes.len(), range)?; + bytes = bytes[start..=end].to_vec(); + } + if let Some(expected) = invocation + .progress + .as_ref() + .and_then(|progress| progress.expected_bytes) + { + if expected != bytes.len() as u64 { + return Err(ProviderError::Provider(format!( + "provider stream expected {expected} bytes but produced {}", + bytes.len() + ))); + } + } + let data = response + .get_mut("data") + .and_then(|data| data.as_object_mut()) + .ok_or_else(|| { + ProviderError::Provider("provider stream requires response data object".to_string()) + })?; + if data.get("data").and_then(|value| value.as_str()).is_some() { + data.remove("data"); + } + data.insert("stream".to_string(), provider_stream_payload(&bytes)); + Ok(()) +} + +fn provider_stream_response_bytes( + data: &serde_json::Map, +) -> Result, ProviderError> { + if let Some(stream) = data.get("stream") { + return decode_provider_stream_payload(stream); + } + let encoded = data + .get("data") + .and_then(|value| value.as_str()) + .ok_or_else(|| { + ProviderError::Provider( + "provider stream requires data.stream chunks or data.data base64 payload" + .to_string(), + ) + })?; + base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|err| { + ProviderError::Provider(format!( + "provider stream response has invalid base64 payload: {err}" + )) + }) +} + +fn decode_provider_stream_payload(stream: &serde_json::Value) -> Result, ProviderError> { + let object = stream.as_object().ok_or_else(|| { + ProviderError::Provider("provider stream payload must be an object".to_string()) + })?; + let schema = object + .get("schema") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if schema != PROVIDER_STREAM_SCHEMA { + return Err(ProviderError::Provider(format!( + "provider stream schema mismatch: expected {PROVIDER_STREAM_SCHEMA}, got {schema}" + ))); + } + let encoding = object + .get("encoding") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if encoding != PROVIDER_STREAM_ENCODING { + return Err(ProviderError::Provider(format!( + "provider stream encoding mismatch: expected {PROVIDER_STREAM_ENCODING}, got {encoding}" + ))); + } + if !object + .get("completed") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + return Err(ProviderError::Provider( + "provider stream payload must be completed before response finalization".to_string(), + )); + } + let chunks = object + .get("chunks") + .and_then(|value| value.as_array()) + .ok_or_else(|| { + ProviderError::Provider("provider stream payload requires chunks array".to_string()) + })?; + let mut bytes = Vec::new(); + for (expected_index, chunk) in chunks.iter().enumerate() { + let chunk = chunk.as_object().ok_or_else(|| { + ProviderError::Provider(format!( + "provider stream chunk {expected_index} must be an object" + )) + })?; + let index = chunk + .get("index") + .and_then(|value| value.as_u64()) + .ok_or_else(|| { + ProviderError::Provider(format!( + "provider stream chunk {expected_index} requires index" + )) + })?; + if index != expected_index as u64 { + return Err(ProviderError::Provider(format!( + "provider stream chunk index mismatch: expected {expected_index}, got {index}" + ))); + } + let offset = chunk + .get("offset") + .and_then(|value| value.as_u64()) + .ok_or_else(|| { + ProviderError::Provider(format!("provider stream chunk {index} requires offset")) + })?; + if offset != bytes.len() as u64 { + return Err(ProviderError::Provider(format!( + "provider stream chunk {index} offset mismatch: expected {}, got {offset}", + bytes.len() + ))); + } + let encoded = chunk + .get("data") + .and_then(|value| value.as_str()) + .ok_or_else(|| { + ProviderError::Provider(format!( + "provider stream chunk {index} requires base64 data" + )) + })?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|err| { + ProviderError::Provider(format!( + "provider stream chunk {index} has invalid base64 data: {err}" + )) + })?; + if let Some(length) = chunk.get("length").and_then(|value| value.as_u64()) { + if length != decoded.len() as u64 { + return Err(ProviderError::Provider(format!( + "provider stream chunk {index} length {length} does not match decoded length {}", + decoded.len() + ))); + } + } + bytes.extend_from_slice(&decoded); + } + if let Some(total_bytes) = object.get("total_bytes").and_then(|value| value.as_u64()) { + if total_bytes != bytes.len() as u64 { + return Err(ProviderError::Provider(format!( + "provider stream total_bytes {total_bytes} does not match decoded length {}", + bytes.len() + ))); + } + } + Ok(bytes) +} + +fn provider_stream_payload(bytes: &[u8]) -> serde_json::Value { + let mut offset = 0usize; + let chunks: Vec = bytes + .chunks(PROVIDER_STREAM_CHUNK_BYTES) + .enumerate() + .map(|(index, chunk)| { + let chunk_offset = offset; + offset += chunk.len(); + serde_json::json!({ + "index": index, + "offset": chunk_offset, + "length": chunk.len(), + "data": base64::engine::general_purpose::STANDARD.encode(chunk), + }) + }) + .collect(); + serde_json::json!({ + "schema": PROVIDER_STREAM_SCHEMA, + "encoding": PROVIDER_STREAM_ENCODING, + "chunk_size": PROVIDER_STREAM_CHUNK_BYTES, + "total_bytes": bytes.len(), + "completed": true, + "chunks": chunks, + }) +} + +fn provider_stream_contract(invocation: &ProviderInvocation) -> Option { + (invocation.transfer == ProviderTransfer::Stream).then(|| { + serde_json::json!({ + "schema": PROVIDER_STREAM_SCHEMA, + "encoding": PROVIDER_STREAM_ENCODING, + "chunk_size": PROVIDER_STREAM_CHUNK_BYTES, + "mode": "runtime_stream_session", + "transport_native": true, + "progress_mode": "stream_events", + "flow_control": { + "backpressure": "read_next", + "cancel": "supported", + "max_in_flight_chunks": 1 + }, + }) + }) +} + +fn provider_transfer_abi(invocation: &ProviderInvocation) -> serde_json::Value { + serde_json::json!({ + "schema": "elastos.provider.transfer-abi/v1", + "transfer": invocation.transfer.as_str(), + "transport": invocation.transport.as_str(), + "range_supported": !matches!(invocation.transfer, ProviderTransfer::Json), + "progress_supported": invocation.progress.is_some(), + "progress_mode": if matches!(invocation.transfer, ProviderTransfer::Stream) { + "stream_events" + } else if invocation.progress.is_some() { + "receipt_metadata" + } else { + "none" + }, + "transport_native_stream": matches!(invocation.transfer, ProviderTransfer::Stream), + "backpressure": match invocation.transfer { + ProviderTransfer::Stream => "read_next", + _ => "not_applicable", + }, + "cancel_supported": matches!(invocation.transfer, ProviderTransfer::Stream), + }) +} + +fn provider_transfer_receipt(invocation: &ProviderInvocation, status: &str) -> serde_json::Value { + let range = invocation.range.map(|range| { + serde_json::json!({ + "start": range.start, + "end": range.end, + }) + }); + let progress = invocation.progress.as_ref().map(|progress| { + serde_json::json!({ + "request_id": progress.request_id, + "expected_bytes": progress.expected_bytes, + }) + }); + let mut receipt = serde_json::json!({ + "schema": "elastos.provider.transfer/v1", + "source": invocation.source, + "target": invocation.target, + "op": invocation.op, + "capability": provider_invocation_capability(invocation), + "transport": invocation.transport.as_str(), + "carrier": provider_carrier_route_receipt(invocation), + "transfer": invocation.transfer.as_str(), + "range": range, + "progress": progress, + "abi": provider_transfer_abi(invocation), + "status": status, + }); + if let Some(stream) = provider_stream_contract(invocation) { + if let Some(object) = receipt.as_object_mut() { + object.insert("stream".to_string(), stream); + } + } + receipt +} + +fn provider_invocation_envelope(invocation: &ProviderInvocation) -> serde_json::Value { + let mut envelope = serde_json::json!({ + "schema": "elastos.provider.invocation/v1", + "source": invocation.source, + "target": invocation.target, + "op": invocation.op, + "capability": provider_invocation_capability(invocation), + "transport": invocation.transport.as_str(), + "carrier": provider_carrier_route_receipt(invocation), + "transfer": invocation.transfer.as_str(), + "range": invocation.range.map(|range| serde_json::json!({ + "start": range.start, + "end": range.end, + })), + "progress": invocation.progress.as_ref().map(|progress| serde_json::json!({ + "request_id": progress.request_id, + "expected_bytes": progress.expected_bytes, + })), + "abi": provider_transfer_abi(invocation), + }); + if let Some(stream) = provider_stream_contract(invocation) { + if let Some(object) = envelope.as_object_mut() { + object.insert("stream".to_string(), stream); + } + } + envelope +} + +fn provider_carrier_route_receipt(invocation: &ProviderInvocation) -> Option { + invocation.transport.carrier_route().map(|route| { + serde_json::json!({ + "route": "connect_ticket", + "peer_did": route.peer_did.as_deref(), + "timeout_ms": route.timeout_ms, + }) + }) +} + +fn provider_invocation_capability(invocation: &ProviderInvocation) -> String { + format!( + "provider:{}->{}:{}", + invocation.source, invocation.target, invocation.op + ) +} + impl Default for ProviderRegistry { fn default() -> Self { Self::new() @@ -539,6 +1438,150 @@ mod tests { } } + struct RawMockProvider; + + #[async_trait::async_trait] + impl Provider for RawMockProvider { + async fn handle( + &self, + _request: ResourceRequest, + ) -> Result { + Err(ProviderError::Provider("raw mock only supports raw".into())) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["raw"] + } + + fn name(&self) -> &'static str { + "mock-raw" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + let op = request.get("op").and_then(|value| value.as_str()); + if op == Some("cat") { + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "data": base64::engine::general_purpose::STANDARD.encode( + b"0123456789abcdefghijklmnopqrstuvwxyz", + ) + } + })); + } + if op == Some("stream_cat") { + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "runtime_invocation": request + .get("_runtime_invocation") + .cloned() + .unwrap_or(serde_json::Value::Null), + "stream": { + "schema": PROVIDER_STREAM_SCHEMA, + "encoding": PROVIDER_STREAM_ENCODING, + "total_bytes": 36, + "completed": true, + "chunks": [ + { + "index": 0, + "offset": 0, + "length": 10, + "data": base64::engine::general_purpose::STANDARD.encode( + b"0123456789", + ), + }, + { + "index": 1, + "offset": 10, + "length": 26, + "data": base64::engine::general_purpose::STANDARD.encode( + b"abcdefghijklmnopqrstuvwxyz", + ), + }, + ], + }, + } + })); + } + if op == Some("bad_stream") { + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "stream": { + "schema": PROVIDER_STREAM_SCHEMA, + "encoding": PROVIDER_STREAM_ENCODING, + "total_bytes": 4, + "completed": true, + "chunks": [ + { + "index": 0, + "offset": 0, + "length": 99, + "data": base64::engine::general_purpose::STANDARD.encode( + b"oops", + ), + }, + ], + }, + } + })); + } + Ok(serde_json::json!({ + "status": "ok", + "data": { + "op": request.get("op").cloned().unwrap_or(serde_json::Value::Null), + "runtime_invocation": request + .get("_runtime_invocation") + .cloned() + .unwrap_or(serde_json::Value::Null), + } + })) + } + } + + fn decode_test_stream_response(response: &serde_json::Value) -> Vec { + decode_provider_stream_payload(&response["data"]["stream"]).unwrap() + } + + #[derive(Default)] + struct MockCarrierInvoker { + requests: Mutex>, + } + + #[async_trait::async_trait] + impl ProviderCarrierInvoker for MockCarrierInvoker { + async fn invoke_carrier_provider( + &self, + route: &ProviderCarrierRoute, + invocation: &ProviderInvocation, + request: serde_json::Value, + ) -> Result { + self.requests.lock().await.push(serde_json::json!({ + "connect_ticket": route.connect_ticket.as_str(), + "peer_did": route.peer_did.as_deref(), + "timeout_ms": route.timeout_ms, + "source": invocation.source.as_str(), + "target": invocation.target.as_str(), + "op": invocation.op.as_str(), + "request": request.clone(), + })); + Ok(serde_json::json!({ + "status": "ok", + "data": { + "data": base64::engine::general_purpose::STANDARD.encode(b"0123456789"), + "runtime_invocation": request + .get("_runtime_invocation") + .cloned() + .unwrap_or(serde_json::Value::Null), + } + })) + } + } + #[test] fn test_parse_uri() { let (scheme, path) = @@ -622,6 +1665,435 @@ mod tests { assert!(matches!(result, Err(ProviderError::NoProvider(_)))); } + #[tokio::test] + async fn test_provider_invocation_routes_raw_request() { + let registry = ProviderRegistry::new(); + registry.register(Arc::new(RawMockProvider)).await; + + let response = registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "raw".to_string(), + op: "ping".to_string(), + request: serde_json::json!({ "op": "ping" }), + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["op"], "ping"); + assert_eq!( + response["data"]["runtime_invocation"]["schema"], + "elastos.provider.invocation/v1" + ); + assert_eq!( + response["data"]["runtime_invocation"]["source"], + "content-provider" + ); + assert_eq!(response["data"]["runtime_invocation"]["target"], "raw"); + assert_eq!( + response["data"]["runtime_invocation"]["capability"], + "provider:content-provider->raw:ping" + ); + assert_eq!( + response["data"]["runtime_invocation"]["transport"], + "runtime-local-provider-plane" + ); + assert_eq!( + response["_runtime_transfer"]["schema"], + "elastos.provider.transfer/v1" + ); + assert_eq!( + response["_runtime_transfer"]["capability"], + "provider:content-provider->raw:ping" + ); + assert_eq!( + response["_runtime_transfer"]["transport"], + "runtime-local-provider-plane" + ); + assert_eq!(response["_runtime_transfer"]["transfer"], "json"); + assert_eq!(response["_runtime_transfer"]["status"], "completed"); + } + + #[tokio::test] + async fn test_provider_invocation_attaches_range_progress_transfer_receipt() { + let registry = ProviderRegistry::new(); + registry.register(Arc::new(RawMockProvider)).await; + + let response = registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "raw".to_string(), + op: "cat".to_string(), + request: serde_json::json!({ "op": "cat" }), + transfer: ProviderTransfer::Bytes, + range: Some(ProviderByteRange { + start: 10, + end: Some(19), + }), + progress: Some(ProviderProgress { + request_id: "transfer:test".to_string(), + expected_bytes: Some(10), + }), + transport: ProviderInvocationTransport::Local, + }) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + let sliced = base64::engine::general_purpose::STANDARD + .decode(response["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(sliced, b"abcdefghij"); + assert_eq!(response["_runtime_transfer"]["transfer"], "bytes"); + assert_eq!( + response["_runtime_transfer"]["capability"], + "provider:content-provider->raw:cat" + ); + assert_eq!( + response["_runtime_transfer"]["transport"], + "runtime-local-provider-plane" + ); + assert_eq!(response["_runtime_transfer"]["range"]["start"], 10); + assert_eq!(response["_runtime_transfer"]["range"]["end"], 19); + assert_eq!( + response["_runtime_transfer"]["progress"]["request_id"], + "transfer:test" + ); + assert_eq!( + response["_runtime_transfer"]["progress"]["expected_bytes"], + 10 + ); + } + + #[tokio::test] + async fn test_provider_invocation_rejects_invalid_range_contract() { + let registry = ProviderRegistry::new(); + registry.register(Arc::new(RawMockProvider)).await; + + let err = registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "raw".to_string(), + op: "cat".to_string(), + request: serde_json::json!({ "op": "cat" }), + transfer: ProviderTransfer::Bytes, + range: Some(ProviderByteRange { + start: 20, + end: Some(10), + }), + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .expect_err("invalid range should fail closed"); + + assert!(err.to_string().contains("range end")); + } + + #[tokio::test] + async fn test_provider_invocation_stream_normalizes_range_progress_transfer_receipt() { + let registry = ProviderRegistry::new(); + registry.register(Arc::new(RawMockProvider)).await; + + let response = registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "raw".to_string(), + op: "stream_cat".to_string(), + request: serde_json::json!({ "op": "stream_cat" }), + transfer: ProviderTransfer::Stream, + range: Some(ProviderByteRange { + start: 10, + end: Some(19), + }), + progress: Some(ProviderProgress { + request_id: "transfer:stream".to_string(), + expected_bytes: Some(10), + }), + transport: ProviderInvocationTransport::Local, + }) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert!(response["data"].get("data").is_none()); + assert_eq!(decode_test_stream_response(&response), b"abcdefghij"); + assert_eq!(response["data"]["stream"]["schema"], PROVIDER_STREAM_SCHEMA); + assert_eq!( + response["data"]["stream"]["encoding"], + PROVIDER_STREAM_ENCODING + ); + assert_eq!(response["data"]["stream"]["total_bytes"], 10); + assert_eq!( + response["data"]["runtime_invocation"]["stream"]["schema"], + PROVIDER_STREAM_SCHEMA + ); + assert_eq!( + response["data"]["runtime_invocation"]["stream"]["mode"], + "runtime_stream_session" + ); + assert_eq!( + response["data"]["runtime_invocation"]["abi"]["backpressure"], + "read_next" + ); + assert_eq!(response["_runtime_transfer"]["transfer"], "stream"); + assert_eq!( + response["_runtime_transfer"]["stream"]["schema"], + PROVIDER_STREAM_SCHEMA + ); + assert_eq!( + response["_runtime_transfer"]["abi"]["transport_native_stream"], + true + ); + assert_eq!( + response["_runtime_transfer"]["abi"]["progress_mode"], + "stream_events" + ); + assert_eq!( + response["_runtime_transfer"]["abi"]["cancel_supported"], + true + ); + assert_eq!(response["_runtime_transfer"]["range"]["start"], 10); + assert_eq!(response["_runtime_transfer"]["range"]["end"], 19); + assert_eq!( + response["_runtime_transfer"]["progress"]["request_id"], + "transfer:stream" + ); + assert_eq!( + response["_runtime_transfer"]["progress"]["expected_bytes"], + 10 + ); + } + + #[tokio::test] + async fn test_provider_stream_session_reads_partially_and_cancels() { + let registry = ProviderRegistry::new(); + registry.register(Arc::new(RawMockProvider)).await; + + let mut session = registry + .open_provider_stream( + ProviderInvocation { + source: "content-provider".to_string(), + target: "raw".to_string(), + op: "stream_cat".to_string(), + request: serde_json::json!({ "op": "stream_cat" }), + transfer: ProviderTransfer::Stream, + range: None, + progress: Some(ProviderProgress { + request_id: "transfer:session".to_string(), + expected_bytes: Some(36), + }), + transport: ProviderInvocationTransport::Local, + }, + ProviderStreamOptions { + chunk_size: 8, + max_in_flight_chunks: 1, + }, + ) + .await + .unwrap(); + + assert_eq!(session.total_bytes(), 36); + assert_eq!(session.receipt()["schema"], PROVIDER_STREAM_SESSION_SCHEMA); + assert_eq!(session.receipt()["backpressure"], "read_next"); + assert_eq!(session.receipt()["cancel_supported"], true); + assert_eq!(session.receipt()["progress_mode"], "stream_events"); + + let first = session.read_next().unwrap().unwrap(); + assert_eq!(first.session_id, session.id()); + assert_eq!(first.index, 0); + assert_eq!(first.offset, 0); + assert_eq!(first.bytes, b"01234567"); + assert!(!first.completed); + assert_eq!(first.progress["schema"], PROVIDER_STREAM_EVENT_SCHEMA); + assert_eq!(first.progress["status"], "progress"); + assert_eq!(first.progress["transferred_bytes"], 8); + + let cancel = session.cancel(); + assert_eq!(cancel["status"], "cancelled"); + assert!(session.is_cancelled()); + let err = session + .read_next() + .expect_err("cancelled session must not continue reading"); + assert!(err.to_string().contains("cancelled")); + } + + #[tokio::test] + async fn test_provider_invocation_rejects_malformed_stream_payload() { + let registry = ProviderRegistry::new(); + registry.register(Arc::new(RawMockProvider)).await; + + let err = registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "raw".to_string(), + op: "bad_stream".to_string(), + request: serde_json::json!({ "op": "bad_stream" }), + transfer: ProviderTransfer::Stream, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .expect_err("malformed stream should fail closed"); + + assert!(err.to_string().contains("provider stream chunk 0 length")); + } + + #[tokio::test] + async fn test_provider_invocation_rejects_op_mismatch() { + let registry = ProviderRegistry::new(); + let err = registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "localhost".to_string(), + op: "fetch".to_string(), + request: serde_json::json!({ "op": "publish" }), + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .expect_err("op mismatch should fail closed"); + + assert!(err.to_string().contains("op mismatch")); + } + + #[tokio::test] + async fn test_provider_invocation_rejects_predeclared_runtime_metadata() { + let registry = ProviderRegistry::new(); + registry.register(Arc::new(RawMockProvider)).await; + + for reserved in ["_runtime_invocation", "_runtime_transfer"] { + let err = registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "raw".to_string(), + op: "ping".to_string(), + request: serde_json::json!({ + "op": "ping", + reserved: { + "schema": "spoofed" + } + }), + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .expect_err("runtime metadata should be reserved"); + + assert!(err + .to_string() + .contains("provider invocation request must not predeclare runtime field")); + assert!(err.to_string().contains(reserved)); + } + } + + #[tokio::test] + async fn test_provider_invocation_carrier_requires_registered_invoker() { + let registry = ProviderRegistry::new(); + let err = registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "content".to_string(), + op: "fetch".to_string(), + request: serde_json::json!({ "op": "fetch" }), + transfer: ProviderTransfer::Bytes, + range: None, + progress: None, + transport: ProviderInvocationTransport::Carrier(ProviderCarrierRoute { + connect_ticket: "ticket-secret".to_string(), + peer_did: Some("did:key:zRemote".to_string()), + timeout_ms: Some(5_000), + }), + }) + .await + .expect_err("Carrier invocation without invoker should fail closed"); + + let error = err.to_string(); + assert!(error.contains("registered Carrier invoker")); + assert!(error.contains("carrier-provider-plane")); + assert!(error.contains("failed_closed")); + assert!(!error.contains("ticket-secret")); + } + + #[tokio::test] + async fn test_provider_invocation_carrier_routes_through_registered_invoker() { + let registry = ProviderRegistry::new(); + let invoker = Arc::new(MockCarrierInvoker::default()); + registry.set_carrier_invoker(invoker.clone()).await; + + let response = registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "content".to_string(), + op: "fetch".to_string(), + request: serde_json::json!({ "op": "fetch" }), + transfer: ProviderTransfer::Bytes, + range: Some(ProviderByteRange { + start: 2, + end: Some(5), + }), + progress: Some(ProviderProgress { + request_id: "carrier-transfer:test".to_string(), + expected_bytes: Some(4), + }), + transport: ProviderInvocationTransport::Carrier(ProviderCarrierRoute { + connect_ticket: "ticket-secret".to_string(), + peer_did: Some("did:key:zRemote".to_string()), + timeout_ms: Some(5_000), + }), + }) + .await + .unwrap(); + + let sliced = base64::engine::general_purpose::STANDARD + .decode(response["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(sliced, b"2345"); + assert_eq!( + response["data"]["runtime_invocation"]["transport"], + "carrier-provider-plane" + ); + assert_eq!( + response["data"]["runtime_invocation"]["carrier"]["route"], + "connect_ticket" + ); + assert_eq!( + response["data"]["runtime_invocation"]["carrier"]["peer_did"], + "did:key:zRemote" + ); + assert_eq!( + response["_runtime_transfer"]["transport"], + "carrier-provider-plane" + ); + assert_eq!( + response["_runtime_transfer"]["carrier"]["route"], + "connect_ticket" + ); + assert_eq!( + response["_runtime_transfer"]["progress"]["request_id"], + "carrier-transfer:test" + ); + assert!(!response.to_string().contains("ticket-secret")); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0]["connect_ticket"], "ticket-secret"); + assert_eq!( + requests[0]["request"]["_runtime_invocation"]["capability"], + "provider:content-provider->content:fetch" + ); + } + // --- elastos:// sub-dispatch tests --- #[test] @@ -674,6 +2146,7 @@ mod tests { "key", "decrypt", "availability", + "block-graph", ] { registry .register_sub_provider(name, Arc::new(MockProvider::new())) diff --git a/elastos/crates/elastos-server/Cargo.toml b/elastos/crates/elastos-server/Cargo.toml index 436be0be..d571679c 100644 --- a/elastos/crates/elastos-server/Cargo.toml +++ b/elastos/crates/elastos-server/Cargo.toml @@ -91,6 +91,7 @@ tempfile = "3.10" # Archive extraction (for capsule artifacts) flate2 = "1.0" tar = "0.4" +zip = { version = "2.4.2", default-features = false, features = ["deflate-flate2", "flate2"] } libc = "0.2" # Built-in Carrier transport (P2P for updates, gossip messaging, DHT discovery) diff --git a/elastos/crates/elastos-server/src/api/gateway.rs b/elastos/crates/elastos-server/src/api/gateway.rs index 606a038b..b6d77484 100644 --- a/elastos/crates/elastos-server/src/api/gateway.rs +++ b/elastos/crates/elastos-server/src/api/gateway.rs @@ -12,16 +12,19 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::documents::DocumentsClient; use axum::body::Bytes; -use axum::extract::{DefaultBodyLimit, Path, Query, State}; +use axum::extract::{DefaultBodyLimit, Path, Query, RawQuery, State}; use axum::http::{ - header::{AUTHORIZATION, CONTENT_TYPE, COOKIE, SET_COOKIE}, + header::{ + ACCEPT_RANGES, AUTHORIZATION, CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_RANGE, + CONTENT_TYPE, COOKIE, RANGE, SET_COOKIE, + }, HeaderMap, HeaderValue, StatusCode, }; use axum::response::{ sse::{Event as SseEvent, KeepAlive, Sse}, Html, IntoResponse, Redirect, Response, }; -use axum::routing::{delete, get, post}; +use axum::routing::{delete, get, post, put}; use axum::Json; use axum::Router; use base64::Engine as _; @@ -92,6 +95,7 @@ use gateway_wallet::*; /// Maximum size for a single file fetched through the gateway (100 MB). const MAX_GATEWAY_FILE_SIZE: usize = 100 * 1024 * 1024; +const LIBRARY_UPLOAD_CHUNK_MAX_BYTES: usize = 768 * 1024; const GATEWAY_VERSION: &str = env!("ELASTOS_VERSION"); const MANAGED_WALLET_CHAIN_NAMESPACES: &[&str] = &[ "eip155:20", @@ -326,6 +330,35 @@ pub fn gateway_router(state: GatewayState) -> Router { "/api/browser/session/request/:request_id", get(super::browser_sessions::browser_session_request_status), ) + .route( + "/api/provider/object/events/stream", + get(gateway_library_events_stream), + ) + .route( + "/api/provider/object/download/raw", + get(gateway_library_download), + ) + .route( + "/api/provider/object/upload", + put(gateway_library_upload).layer(DefaultBodyLimit::max(MAX_GATEWAY_FILE_SIZE)), + ) + .route( + "/api/provider/object/upload/start", + post(gateway_library_upload_start), + ) + .route( + "/api/provider/object/upload/:upload_id/chunk", + put(gateway_library_upload_chunk) + .layer(DefaultBodyLimit::max(LIBRARY_UPLOAD_CHUNK_MAX_BYTES)), + ) + .route( + "/api/provider/object/upload/:upload_id/finish", + post(gateway_library_upload_finish), + ) + .route( + "/api/provider/object/upload/:upload_id", + delete(gateway_library_upload_cancel), + ) .route("/api/provider/:scheme/:op", post(gateway_provider_proxy)) .route("/release.json", get(serve_release_manifest)) .route("/release-head.json", get(serve_release_head)) @@ -586,6 +619,16 @@ pub fn gateway_router(state: GatewayState) -> Router { "/api/viewers/:viewer/content/:capsule", get(super::viewer_gateway::viewer_content), ) + .route( + "/api/viewers/:viewer/library-object", + get(super::viewer_gateway::viewer_library_object_get) + .post(super::viewer_gateway::viewer_library_object_post) + .put(super::viewer_gateway::viewer_library_object_put), + ) + .route( + "/api/viewers/:viewer/library-roots", + get(super::viewer_gateway::viewer_library_roots_get), + ) .route( "/api/viewers/:viewer/storage/:capsule/:scope/:name", get(super::viewer_gateway::viewer_storage_get) diff --git a/elastos/crates/elastos-server/src/api/gateway_home_runtime.rs b/elastos/crates/elastos-server/src/api/gateway_home_runtime.rs index 91142a19..224b2ba3 100644 --- a/elastos/crates/elastos-server/src/api/gateway_home_runtime.rs +++ b/elastos/crates/elastos-server/src/api/gateway_home_runtime.rs @@ -574,6 +574,7 @@ fn app_shell_title(name: &str) -> String { SYSTEM_CAPSULE_ID => "System".to_string(), BROWSER_CAPSULE_ID => "Browser".to_string(), WALLET_CAPSULE_ID => "Wallet".to_string(), + "archive-manager" => "Archive".to_string(), "gba-emulator" => "GBA Emulator".to_string(), _ if is_wallet_connector_capsule_id(name) => wallet_connector_label(name).to_string(), _ => title_case_capsule_name(name), @@ -597,6 +598,7 @@ fn app_shell_description(name: &str, manifest_description: Option) -> St WALLET_CAPSULE_ID => { "View accounts, balances, approvals, and approval methods.".to_string() } + "archive-manager" => "Open archives selected from Library.".to_string(), _ if is_wallet_connector_capsule_id(name) => format!( "Add {} as an approval method.", wallet_connector_label(name) @@ -608,6 +610,9 @@ fn app_shell_description(name: &str, manifest_description: Option) -> St } pub(crate) fn viewer_object_shell_title(name: &str, description: Option<&str>) -> String { + if name == "archive-manager" { + return "Archive".to_string(); + } let Some(description) = description.map(str::trim).filter(|value| !value.is_empty()) else { return title_case_capsule_name(name); }; diff --git a/elastos/crates/elastos-server/src/api/gateway_home_system.rs b/elastos/crates/elastos-server/src/api/gateway_home_system.rs index 5555fcf1..9cd0664b 100644 --- a/elastos/crates/elastos-server/src/api/gateway_home_system.rs +++ b/elastos/crates/elastos-server/src/api/gateway_home_system.rs @@ -10,6 +10,8 @@ const HOME_EVENTS_MAX_WAIT_MS: u64 = 30_000; const HOME_EVENTS_POLL_MS: u64 = 1_000; const HOME_EVENTS_RETRY_MS: u64 = 250; const HOME_EVENTS_STREAM_KEEPALIVE_SECS: u64 = 15; +const HOME_DESKTOP_OBJECTS_SCHEMA: &str = "elastos.home.desktop-objects/v1"; +const HOME_SYSTEM_DESKTOP_OBJECT_SCHEMA: &str = "elastos.home.system-desktop-object/v1"; #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] @@ -42,6 +44,7 @@ struct HomeRealtimeSnapshot { notification_signature: Vec, wallet_request_signature: Vec, capability_request_count: usize, + desktop_signature: Vec, room_signature: String, browser_sessions: serde_json::Value, } @@ -89,6 +92,11 @@ pub(super) async fn home_summary( }; let mut notifications = home_state.notifications; + let desktop_objects = if let Some(context) = context.as_ref() { + home_desktop_objects_summary(&state, context).await + } else { + standard_home_desktop_objects_summary() + }; if let Some(context) = context.as_ref() { let wallet_approvals = system_wallet_approvals_summary(&state, &context.principal_id, false).await; @@ -119,6 +127,7 @@ pub(super) async fn home_summary( site: home_state.site, room: home_state.room, notifications, + desktop_objects, targets: home_targets(&state.data_dir), }) .into_response() @@ -285,11 +294,13 @@ async fn home_realtime_snapshot( &context.principal_id, ) .await; + let desktop_signature = home_desktop_events_signature(state, context).await; HomeRealtimeSnapshot { principal_id: context.principal_id.clone(), notification_signature, wallet_request_signature, capability_request_count, + desktop_signature, room_signature, browser_sessions, } @@ -298,8 +309,8 @@ async fn home_realtime_snapshot( fn home_realtime_cursor(snapshot: &HomeRealtimeSnapshot) -> String { let parts = home_realtime_cursor_parts(snapshot); format!( - "v1:home={};inbox={};wallet={};browser={};chat-room={}", - parts.home, parts.inbox, parts.wallet, parts.browser, parts.chat_room + "v1:home={};inbox={};wallet={};browser={};desktop={};chat-room={}", + parts.home, parts.inbox, parts.wallet, parts.browser, parts.desktop, parts.chat_room ) } @@ -308,6 +319,7 @@ struct HomeRealtimeCursorParts { inbox: String, wallet: String, browser: String, + desktop: String, chat_room: String, } @@ -321,6 +333,7 @@ fn home_realtime_cursor_parts(snapshot: &HomeRealtimeSnapshot) -> HomeRealtimeCu )), wallet: stable_cursor_hash(&snapshot.wallet_request_signature), browser: stable_cursor_hash(&snapshot.browser_sessions), + desktop: stable_cursor_hash(&snapshot.desktop_signature), chat_room: stable_cursor_hash(&snapshot.room_signature), } } @@ -383,6 +396,7 @@ fn home_realtime_events( ("inbox", "inbox.changed", current.inbox), ("wallet", "wallet.requests.changed", current.wallet), ("browser", "browser.sessions.changed", current.browser), + ("desktop", "home.desktop.changed", current.desktop), ("chat-room", "chat-room.changed", current.chat_room), ] .into_iter() @@ -510,6 +524,200 @@ fn home_browser_localhost_root(context: &HomeLaunchTokenContext) -> String { crate::auth::principal_localhost_root(&context.principal_id) } +fn home_desktop_uri(context: &HomeLaunchTokenContext) -> String { + format!("{}/Desktop", home_browser_localhost_root(context)) +} + +fn standard_home_desktop_objects_summary() -> HomeDesktopObjectsSummary { + HomeDesktopObjectsSummary { + schema: HOME_DESKTOP_OBJECTS_SCHEMA.to_string(), + uri: String::new(), + objects: Vec::new(), + stale: false, + error: None, + } +} + +async fn home_desktop_objects_summary( + state: &GatewayState, + context: &HomeLaunchTokenContext, +) -> HomeDesktopObjectsSummary { + let uri = home_desktop_uri(context); + let Some(registry) = state.provider_registry.as_ref() else { + return HomeDesktopObjectsSummary { + schema: HOME_DESKTOP_OBJECTS_SCHEMA.to_string(), + uri, + objects: Vec::new(), + stale: true, + error: Some("object provider registry unavailable".to_string()), + }; + }; + let request = serde_json::json!({ + "op": "list", + "principal_id": &context.principal_id, + "uri": uri, + }); + let response = match registry.send_raw("object", &request).await { + Ok(response) => response, + Err(err) => { + return HomeDesktopObjectsSummary { + schema: HOME_DESKTOP_OBJECTS_SCHEMA.to_string(), + uri, + objects: Vec::new(), + stale: true, + error: Some(format!("object provider failed to list Desktop: {err}")), + } + } + }; + if response.get("status").and_then(serde_json::Value::as_str) != Some("ok") { + return HomeDesktopObjectsSummary { + schema: HOME_DESKTOP_OBJECTS_SCHEMA.to_string(), + uri: request + .get("uri") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + objects: Vec::new(), + stale: true, + error: Some( + response + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("object provider failed to list Desktop") + .to_string(), + ), + }; + } + let data = response.get("data").and_then(serde_json::Value::as_object); + let mut objects = data + .and_then(|data| data.get("objects")) + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + if let Some(trash) = home_trash_desktop_object(state, context).await { + objects.push(trash); + } + let uri = data + .and_then(|data| data.get("uri")) + .and_then(serde_json::Value::as_str) + .unwrap_or_else(|| { + request + .get("uri") + .and_then(serde_json::Value::as_str) + .unwrap_or("") + }) + .to_string(); + HomeDesktopObjectsSummary { + schema: HOME_DESKTOP_OBJECTS_SCHEMA.to_string(), + uri, + objects, + stale: false, + error: None, + } +} + +async fn home_trash_desktop_object( + state: &GatewayState, + context: &HomeLaunchTokenContext, +) -> Option { + let registry = state.provider_registry.as_ref()?; + let request = serde_json::json!({ + "op": "roots", + "principal_id": &context.principal_id, + }); + let response = registry.send_raw("object", &request).await.ok()?; + if response.get("status").and_then(serde_json::Value::as_str) != Some("ok") { + return None; + } + let roots = response + .get("data") + .and_then(|data| data.get("roots")) + .and_then(serde_json::Value::as_array)?; + let trash = roots + .iter() + .find(|root| root.get("id").and_then(serde_json::Value::as_str) == Some("trash"))?; + let uri = trash + .get("uri") + .and_then(serde_json::Value::as_str) + .filter(|uri| !uri.trim().is_empty())?; + let metadata = trash.get("metadata").and_then(serde_json::Value::as_object); + let empty = metadata + .and_then(|metadata| metadata.get("empty")) + .and_then(serde_json::Value::as_bool) + .unwrap_or(true); + let item_count = metadata + .and_then(|metadata| metadata.get("item_count")) + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + Some(serde_json::json!({ + "uri": uri, + "name": "Trash", + "kind": "directory", + "mime": "inode/directory", + "capabilities": trash + .get("capabilities") + .cloned() + .unwrap_or_else(|| serde_json::json!(["open", "list", "properties"])), + "metadata": { + "schema": HOME_SYSTEM_DESKTOP_OBJECT_SCHEMA, + "system_kind": "trash", + "empty": empty, + "item_count": item_count, + "provider_root": trash, + }, + })) +} + +async fn home_desktop_events_signature( + state: &GatewayState, + context: &HomeLaunchTokenContext, +) -> Vec { + let Some(registry) = state.provider_registry.as_ref() else { + return Vec::new(); + }; + let mut signature = Vec::new(); + for uri in [ + home_desktop_uri(context), + format!("{}/.Trash", home_browser_localhost_root(context)), + ] { + let request = serde_json::json!({ + "op": "events", + "principal_id": &context.principal_id, + "uri": uri, + "limit": 32, + }); + let Ok(response) = registry.send_raw("object", &request).await else { + continue; + }; + let Some(events) = response + .get("data") + .and_then(|data| data.get("events")) + .and_then(serde_json::Value::as_array) + else { + continue; + }; + signature.extend(events.iter().map(|event| { + format!( + "{}:{}:{}", + event + .get("event_id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(), + event + .get("op") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(), + event + .get("at") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default() + ) + })); + } + signature.sort(); + signature +} + fn default_home_browser_state(context: &HomeLaunchTokenContext) -> HomeBrowserStateSummary { HomeBrowserStateSummary { schema: HOME_BROWSER_STATE_SCHEMA.to_string(), @@ -659,10 +867,11 @@ fn sanitize_home_browser_state_targets( state .recent_targets .retain(|target| known_targets.contains(target)); + let localhost_root = state.localhost_root.clone(); state.layout = state .layout .take() - .and_then(|layout| sanitize_home_layout_targets(layout, &known_targets)); + .and_then(|layout| sanitize_home_layout_targets(layout, &known_targets, &localhost_root)); state.session = state .session .take() @@ -672,13 +881,17 @@ fn sanitize_home_browser_state_targets( fn sanitize_home_layout_targets( mut layout: serde_json::Value, known_targets: &BTreeSet, + localhost_root: &str, ) -> Option { let layout_object = layout.as_object_mut()?; if let Some(desktop) = layout_object .get_mut("desktop") .and_then(|value| value.as_object_mut()) { - desktop.retain(|target, _position| known_targets.contains(target)); + desktop.retain(|entry, _position| { + known_targets.contains(entry) + || is_home_desktop_object_layout_entry(localhost_root, entry) + }); } if let Some(labels) = layout_object .get_mut("desktopLabels") @@ -695,6 +908,20 @@ fn sanitize_home_layout_targets( Some(layout) } +fn is_home_desktop_object_layout_entry(localhost_root: &str, entry: &str) -> bool { + let Some(uri) = entry.strip_prefix("object:") else { + return false; + }; + if uri.len() > 2048 || uri.contains('\0') { + return false; + } + let root = localhost_root.trim_end_matches('/'); + if root.is_empty() { + return false; + } + uri == format!("{root}/.Trash") || uri.starts_with(&format!("{root}/Desktop/")) +} + fn sanitize_home_session_targets( mut session: serde_json::Value, known_targets: &BTreeSet, @@ -1395,6 +1622,7 @@ mod home_realtime_tests { notification_signature: Vec::new(), wallet_request_signature: Vec::new(), capability_request_count: 0, + desktop_signature: Vec::new(), room_signature: String::new(), browser_sessions: serde_json::json!({ "schema": "elastos.browser.session-capacity/v1", diff --git a/elastos/crates/elastos-server/src/api/gateway_models.rs b/elastos/crates/elastos-server/src/api/gateway_models.rs index 5cca6c4f..a6051902 100644 --- a/elastos/crates/elastos-server/src/api/gateway_models.rs +++ b/elastos/crates/elastos-server/src/api/gateway_models.rs @@ -10,6 +10,7 @@ struct HomeSummaryResponse { site: HomeSiteSummary, room: HomeRoomSummary, notifications: HomeNotificationsSummary, + desktop_objects: HomeDesktopObjectsSummary, targets: Vec, } @@ -59,6 +60,17 @@ struct HomeBrowserStateUpdate { recent_targets: Option>, } +#[derive(Debug, Clone, Serialize)] +struct HomeDesktopObjectsSummary { + schema: String, + uri: String, + #[serde(default)] + objects: Vec, + stale: bool, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct SystemHandleUpdateRequest { diff --git a/elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs b/elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs index 9549a43b..eac1ec81 100644 --- a/elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs +++ b/elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs @@ -1,5 +1,1277 @@ +use std::io::{Read, Seek, SeekFrom, Write}; +use std::sync::atomic::{AtomicU64, Ordering}; + use super::*; +const LIBRARY_EVENTS_STREAM_KEEPALIVE_SECS: u64 = 15; +const LIBRARY_TRANSFER_RECEIPT_SCHEMA: &str = "elastos.object.transfer.receipt/v1"; +const LIBRARY_TRANSFER_REQUEST_ID_HEADER: &str = "x-elastos-request-id"; +const LIBRARY_TRANSFER_RECEIPT_HEADER: &str = "x-elastos-transfer-receipt"; +const LIBRARY_DOWNLOAD_STREAM_CHUNK_BYTES: usize = 64 * 1024; +const LIBRARY_UPLOAD_SESSION_SCHEMA: &str = "elastos.object.upload-session/v1"; +const LIBRARY_UPLOAD_SESSION_TTL_SECS: u64 = 24 * 60 * 60; +static LIBRARY_UPLOAD_SESSION_COUNTER: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Deserialize)] +pub(super) struct LibraryEventsStreamQuery { + #[serde(default)] + home_token: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct LibraryUploadQuery { + uri: String, + #[serde(default)] + if_revision: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct LibraryUploadStartBody { + uri: String, + #[serde(default)] + mime: Option, + #[serde(default)] + size_bytes: Option, + #[serde(default)] + if_revision: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct LibraryUploadSession { + schema: String, + upload_id: String, + principal_id: String, + session_id: String, + uri: String, + #[serde(default)] + mime: Option, + #[serde(default)] + if_revision: Option, + #[serde(default)] + total_bytes: Option, + received_bytes: u64, + chunk_count: u64, + created_at: u64, + updated_at: u64, +} + +pub(super) async fn gateway_library_upload( + State(state): State, + headers: HeaderMap, + Query(query): Query, + body: Bytes, +) -> Response { + let uri = query.uri.trim(); + if uri.is_empty() { + return (StatusCode::BAD_REQUEST, "library upload uri is required").into_response(); + } + let context = match require_home_launch_token_for_any_context( + &state.data_dir, + &headers, + &[LIBRARY_CAPSULE_ID], + ) { + Ok(context) => context, + Err(err) => return gateway_provider_error_response("object", err), + }; + let registry = match state.provider_registry.as_ref().cloned() { + Some(registry) => registry, + None => { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider unavailable"), + ) + } + }; + if registry.get("object").await.is_none() { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider unavailable"), + ); + } + + let request_id = format!("object:upload:{}", now_ts()); + if let Err(err) = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: "object.provider.requested", + principal_id: &context.principal_id, + session_id: &context.session_id, + request_id: &request_id, + result: "requested", + reason: "Library requested object provider operation upload", + }, + ) { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider audit failed: {}", err), + ); + } + + let mime = headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let mut response = match crate::library::handle_library_upload_bytes_runtime( + &state.data_dir, + registry, + &context.principal_id, + uri, + mime, + query.if_revision.as_deref(), + &body, + ) + .await + { + Ok(value) => value, + Err(err) => serde_json::json!({ + "status": "error", + "code": "library_error", + "message": err.to_string(), + }), + }; + let completed = response.get("status").and_then(|value| value.as_str()) == Some("ok"); + let transfer_receipt = if completed { + let receipt = library_transfer_receipt( + "upload", + &request_id, + uri, + body.len(), + Some(body.len()), + None, + "completed", + ); + if let Some(data) = response + .get_mut("data") + .and_then(serde_json::Value::as_object_mut) + { + data.insert("request_id".to_string(), serde_json::json!(request_id)); + data.insert("receipt".to_string(), receipt.clone()); + } + Some(receipt) + } else { + None + }; + if completed { + crate::library::library_event_notifier().notify_waiters(); + } + if let Err(err) = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: if completed { + "object.provider.completed" + } else { + "object.provider.failed" + }, + principal_id: &context.principal_id, + session_id: &context.session_id, + request_id: &request_id, + result: if completed { "completed" } else { "failed" }, + reason: if completed { + "Library completed object provider operation upload" + } else { + "Library failed object provider operation upload" + }, + }, + ) { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider audit failed: {}", err), + ); + } + + let mut response = Json(response).into_response(); + if let Some(receipt) = transfer_receipt.as_ref() { + insert_library_transfer_headers(response.headers_mut(), receipt); + } + response +} + +pub(super) async fn gateway_library_upload_start( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Response { + let uri = body.uri.trim(); + if uri.is_empty() { + return library_upload_json_error( + StatusCode::BAD_REQUEST, + "missing_uri", + "library upload uri is required", + ); + } + if let Some(size) = body.size_bytes { + if size as usize > MAX_GATEWAY_FILE_SIZE { + return library_upload_json_error( + StatusCode::PAYLOAD_TOO_LARGE, + "object_too_large", + format!( + "Library upload exceeds Runtime object upload limit of {} bytes", + MAX_GATEWAY_FILE_SIZE + ), + ); + } + } + let context = match require_home_launch_token_for_any_context( + &state.data_dir, + &headers, + &[LIBRARY_CAPSULE_ID], + ) { + Ok(context) => context, + Err(err) => return gateway_provider_error_response("object", err), + }; + let registry = match state.provider_registry.as_ref().cloned() { + Some(registry) => registry, + None => { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider unavailable"), + ) + } + }; + if registry.get("object").await.is_none() { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider unavailable"), + ); + } + + let upload_id = new_library_upload_session_id(); + let now = now_ts(); + let _ = cleanup_expired_library_upload_sessions(&state.data_dir, now); + let session = LibraryUploadSession { + schema: LIBRARY_UPLOAD_SESSION_SCHEMA.to_string(), + upload_id: upload_id.clone(), + principal_id: context.principal_id.clone(), + session_id: context.session_id.clone(), + uri: uri.to_string(), + mime: body + .mime + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string), + if_revision: body + .if_revision + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string), + total_bytes: body.size_bytes, + received_bytes: 0, + chunk_count: 0, + created_at: now, + updated_at: now, + }; + if let Err(err) = create_library_upload_session(&state.data_dir, &session) { + return library_upload_json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "upload_session_error", + err.to_string(), + ); + } + let _ = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: "object.provider.upload_session_started", + principal_id: &context.principal_id, + session_id: &context.session_id, + request_id: &upload_id, + result: "started", + reason: "Library started chunked object provider upload session", + }, + ); + Json(serde_json::json!({ + "status": "ok", + "data": library_upload_session_status(&session), + })) + .into_response() +} + +pub(super) async fn gateway_library_upload_chunk( + State(state): State, + Path(upload_id): Path, + headers: HeaderMap, + body: Bytes, +) -> Response { + if body.len() > LIBRARY_UPLOAD_CHUNK_MAX_BYTES { + return library_upload_json_error( + StatusCode::PAYLOAD_TOO_LARGE, + "chunk_too_large", + format!( + "Library upload chunk exceeds Runtime chunk limit of {} bytes", + LIBRARY_UPLOAD_CHUNK_MAX_BYTES + ), + ); + } + let context = match require_home_launch_token_for_any_context( + &state.data_dir, + &headers, + &[LIBRARY_CAPSULE_ID], + ) { + Ok(context) => context, + Err(err) => return gateway_provider_error_response("object", err), + }; + let offset = match library_upload_offset_from_headers(&headers) { + Ok(offset) => offset, + Err(err) => { + return library_upload_json_error(StatusCode::BAD_REQUEST, "invalid_offset", err) + } + }; + let mut session = match read_library_upload_session(&state.data_dir, &upload_id) { + Ok(session) => session, + Err(err) => { + return library_upload_json_error( + StatusCode::NOT_FOUND, + "upload_session_not_found", + err.to_string(), + ) + } + }; + if let Err(err) = require_library_upload_session_context(&session, &context) { + return library_upload_json_error(StatusCode::FORBIDDEN, "upload_session_forbidden", err); + } + if offset != session.received_bytes { + return library_upload_json_error( + StatusCode::BAD_REQUEST, + "upload_offset_mismatch", + format!( + "expected upload offset {}, got {}", + session.received_bytes, offset + ), + ); + } + let projected = session.received_bytes.saturating_add(body.len() as u64); + if projected as usize > MAX_GATEWAY_FILE_SIZE { + return library_upload_json_error( + StatusCode::PAYLOAD_TOO_LARGE, + "object_too_large", + format!( + "Library upload exceeds Runtime object upload limit of {} bytes", + MAX_GATEWAY_FILE_SIZE + ), + ); + } + if let Some(total) = session.total_bytes { + if projected > total { + return library_upload_json_error( + StatusCode::BAD_REQUEST, + "upload_exceeds_declared_size", + "upload chunk exceeds declared total size", + ); + } + } + if let Err(err) = append_library_upload_chunk(&state.data_dir, &mut session, &body) { + return library_upload_json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "upload_session_error", + err.to_string(), + ); + } + Json(serde_json::json!({ + "status": "ok", + "data": library_upload_session_status(&session), + })) + .into_response() +} + +pub(super) async fn gateway_library_upload_finish( + State(state): State, + Path(upload_id): Path, + headers: HeaderMap, +) -> Response { + let context = match require_home_launch_token_for_any_context( + &state.data_dir, + &headers, + &[LIBRARY_CAPSULE_ID], + ) { + Ok(context) => context, + Err(err) => return gateway_provider_error_response("object", err), + }; + let registry = match state.provider_registry.as_ref().cloned() { + Some(registry) => registry, + None => { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider unavailable"), + ) + } + }; + if registry.get("object").await.is_none() { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider unavailable"), + ); + } + let session = match read_library_upload_session(&state.data_dir, &upload_id) { + Ok(session) => session, + Err(err) => { + return library_upload_json_error( + StatusCode::NOT_FOUND, + "upload_session_not_found", + err.to_string(), + ) + } + }; + if let Err(err) = require_library_upload_session_context(&session, &context) { + return library_upload_json_error(StatusCode::FORBIDDEN, "upload_session_forbidden", err); + } + if let Some(total) = session.total_bytes { + if session.received_bytes != total { + return library_upload_json_error( + StatusCode::BAD_REQUEST, + "upload_incomplete", + format!( + "upload received {} bytes but expected {}", + session.received_bytes, total + ), + ); + } + } + let bytes = match read_library_upload_session_bytes(&state.data_dir, &upload_id) { + Ok(bytes) => bytes, + Err(err) => { + return library_upload_json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "upload_session_error", + err.to_string(), + ) + } + }; + if bytes.len() as u64 != session.received_bytes { + return library_upload_json_error( + StatusCode::BAD_REQUEST, + "upload_incomplete", + "upload session byte count does not match received count", + ); + } + + let request_id = format!("object:upload:{}", now_ts()); + if let Err(err) = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: "object.provider.requested", + principal_id: &context.principal_id, + session_id: &context.session_id, + request_id: &request_id, + result: "requested", + reason: "Library requested chunked object provider operation upload", + }, + ) { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider audit failed: {}", err), + ); + } + + let mut response = match crate::library::handle_library_upload_bytes_runtime( + &state.data_dir, + registry, + &context.principal_id, + &session.uri, + session.mime.as_deref(), + session.if_revision.as_deref(), + &bytes, + ) + .await + { + Ok(value) => value, + Err(err) => serde_json::json!({ + "status": "error", + "code": "library_error", + "message": err.to_string(), + }), + }; + let completed = response.get("status").and_then(|value| value.as_str()) == Some("ok"); + let transfer_receipt = if completed { + let receipt = library_chunked_upload_transfer_receipt(&request_id, &session); + if let Some(data) = response + .get_mut("data") + .and_then(serde_json::Value::as_object_mut) + { + data.insert("request_id".to_string(), serde_json::json!(request_id)); + data.insert("receipt".to_string(), receipt.clone()); + data.insert( + "upload_session".to_string(), + library_upload_session_status(&session), + ); + data.insert( + "browser_transport".to_string(), + serde_json::json!("http-chunk-session"), + ); + } + Some(receipt) + } else { + None + }; + if completed { + let _ = remove_library_upload_session(&state.data_dir, &upload_id); + crate::library::library_event_notifier().notify_waiters(); + } + if let Err(err) = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: if completed { + "object.provider.completed" + } else { + "object.provider.failed" + }, + principal_id: &context.principal_id, + session_id: &context.session_id, + request_id: &request_id, + result: if completed { "completed" } else { "failed" }, + reason: if completed { + "Library completed chunked object provider operation upload" + } else { + "Library failed chunked object provider operation upload" + }, + }, + ) { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider audit failed: {}", err), + ); + } + + let mut response = Json(response).into_response(); + if let Some(receipt) = transfer_receipt.as_ref() { + insert_library_transfer_headers(response.headers_mut(), receipt); + } + response +} + +pub(super) async fn gateway_library_upload_cancel( + State(state): State, + Path(upload_id): Path, + headers: HeaderMap, +) -> Response { + let context = match require_home_launch_token_for_any_context( + &state.data_dir, + &headers, + &[LIBRARY_CAPSULE_ID], + ) { + Ok(context) => context, + Err(err) => return gateway_provider_error_response("object", err), + }; + let session = match read_library_upload_session(&state.data_dir, &upload_id) { + Ok(session) => session, + Err(err) => { + return library_upload_json_error( + StatusCode::NOT_FOUND, + "upload_session_not_found", + err.to_string(), + ) + } + }; + if let Err(err) = require_library_upload_session_context(&session, &context) { + return library_upload_json_error(StatusCode::FORBIDDEN, "upload_session_forbidden", err); + } + if let Err(err) = remove_library_upload_session(&state.data_dir, &upload_id) { + return library_upload_json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "upload_session_error", + err.to_string(), + ); + } + Json(serde_json::json!({ + "status": "ok", + "data": { + "schema": LIBRARY_UPLOAD_SESSION_SCHEMA, + "upload_id": upload_id, + "status": "cancelled", + }, + })) + .into_response() +} + +pub(super) async fn gateway_library_download( + State(state): State, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, +) -> Response { + let mut uris = Vec::new(); + let mut archive_format_value = None; + for (key, value) in form_urlencoded::parse(raw_query.as_deref().unwrap_or_default().as_bytes()) + { + match key.as_ref() { + "uri" => { + let uri = value.trim().to_string(); + if !uri.is_empty() { + uris.push(uri); + } + } + "archive" => archive_format_value = Some(value.into_owned()), + _ => {} + } + } + if uris.is_empty() { + return (StatusCode::BAD_REQUEST, "library download uri is required").into_response(); + } + let archive_format = + match crate::library::LibraryArchiveFormat::parse(archive_format_value.as_deref()) { + Ok(format) => format, + Err(err) => return (StatusCode::BAD_REQUEST, err.to_string()).into_response(), + }; + let context = match require_home_launch_token_for_any_context( + &state.data_dir, + &headers, + &[LIBRARY_CAPSULE_ID], + ) { + Ok(context) => context, + Err(err) => return gateway_provider_error_response("object", err), + }; + let registry = match state.provider_registry.as_ref().cloned() { + Some(registry) => registry, + None => { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider unavailable"), + ) + } + }; + if registry.get("object").await.is_none() { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider unavailable"), + ); + } + + let request_id = format!("object:download:{}", now_ts()); + if let Err(err) = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: "object.provider.requested", + principal_id: &context.principal_id, + session_id: &context.session_id, + request_id: &request_id, + result: "requested", + reason: "Library requested object provider operation download", + }, + ) { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider audit failed: {}", err), + ); + } + + let receipt_uri = if uris.len() == 1 { + uris[0].clone() + } else { + format!("selection:{}", uris.len()) + }; + let download_result = if uris.len() == 1 { + crate::library::handle_library_download_bytes_runtime( + &state.data_dir, + registry, + &context.principal_id, + &uris[0], + archive_format, + ) + .await + } else { + crate::library::handle_library_download_selection_bytes_runtime( + &state.data_dir, + &context.principal_id, + &uris, + archive_format, + ) + .await + }; + let download = match download_result { + Ok(download) => download, + Err(err) => { + let _ = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: "object.provider.failed", + principal_id: &context.principal_id, + session_id: &context.session_id, + request_id: &request_id, + result: "failed", + reason: "Library failed object provider operation download", + }, + ); + return gateway_provider_error_response("object", err); + } + }; + + let (response, range_ok) = + library_download_response(download, headers.get(RANGE), &request_id, &receipt_uri); + if !range_ok { + let _ = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: "object.provider.failed", + principal_id: &context.principal_id, + session_id: &context.session_id, + request_id: &request_id, + result: "failed", + reason: "Library failed object provider operation download", + }, + ); + return response; + } + + if let Err(err) = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: "object.provider.completed", + principal_id: &context.principal_id, + session_id: &context.session_id, + request_id: &request_id, + result: "completed", + reason: "Library completed object provider operation download", + }, + ) { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider audit failed: {}", err), + ); + } + + response +} + +fn library_download_response( + download: crate::library::LibraryDownloadBytes, + range_header: Option<&HeaderValue>, + request_id: &str, + uri: &str, +) -> (Response, bool) { + let byte_range = match range_header { + Some(value) => match library_download_byte_range(value, download.bytes.len()) { + Ok(range) => Some(range), + Err(()) => { + return ( + library_download_range_not_satisfiable( + download.bytes.len(), + request_id, + uri, + value.to_str().unwrap_or_default(), + ), + false, + ) + } + }, + None => None, + }; + let total_bytes = download.bytes.len(); + let mut response = if let Some((start, end)) = byte_range { + let bytes = download.bytes[start..=end].to_vec(); + let mut response = Response::new(library_download_stream_body(bytes)); + *response.status_mut() = StatusCode::PARTIAL_CONTENT; + response.headers_mut().insert( + CONTENT_RANGE, + HeaderValue::from_str(&format!("bytes {start}-{end}/{total_bytes}")) + .unwrap_or_else(|_| HeaderValue::from_static("bytes */0")), + ); + response.headers_mut().insert( + CONTENT_LENGTH, + HeaderValue::from_str(&(end - start + 1).to_string()) + .unwrap_or_else(|_| HeaderValue::from_static("0")), + ); + response + } else { + let length = total_bytes; + let mut response = Response::new(library_download_stream_body(download.bytes)); + response.headers_mut().insert( + CONTENT_LENGTH, + HeaderValue::from_str(&length.to_string()) + .unwrap_or_else(|_| HeaderValue::from_static("0")), + ); + response + }; + let headers = response.headers_mut(); + headers.insert(ACCEPT_RANGES, HeaderValue::from_static("bytes")); + headers.insert( + CONTENT_TYPE, + HeaderValue::from_str(&download.mime) + .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")), + ); + headers.insert( + CONTENT_DISPOSITION, + HeaderValue::from_str(&library_download_content_disposition(&download.filename)) + .unwrap_or_else(|_| HeaderValue::from_static("attachment; filename=\"download\"")), + ); + let served_bytes = byte_range + .map(|(start, end)| end - start + 1) + .unwrap_or(total_bytes); + let receipt = library_transfer_receipt( + "download", + request_id, + uri, + served_bytes, + Some(total_bytes), + byte_range, + "completed", + ); + insert_library_transfer_headers(headers, &receipt); + (response, true) +} + +fn library_download_stream_body(bytes: Vec) -> axum::body::Body { + let bytes = Bytes::from(bytes); + let stream = futures_lite::stream::unfold((bytes, 0usize), |(bytes, offset)| async move { + if offset >= bytes.len() { + return None; + } + let end = (offset + LIBRARY_DOWNLOAD_STREAM_CHUNK_BYTES).min(bytes.len()); + let chunk = bytes.slice(offset..end); + Some((Ok::(chunk), (bytes, end))) + }); + axum::body::Body::from_stream(stream) +} + +fn library_download_range_not_satisfiable( + total_len: usize, + request_id: &str, + uri: &str, + requested_range: &str, +) -> Response { + let mut response = StatusCode::RANGE_NOT_SATISFIABLE.into_response(); + response + .headers_mut() + .insert(ACCEPT_RANGES, HeaderValue::from_static("bytes")); + response.headers_mut().insert( + CONTENT_RANGE, + HeaderValue::from_str(&format!("bytes */{total_len}")) + .unwrap_or_else(|_| HeaderValue::from_static("bytes */0")), + ); + let receipt = serde_json::json!({ + "schema": LIBRARY_TRANSFER_RECEIPT_SCHEMA, + "op": "download", + "request_id": request_id, + "uri": uri, + "transport": "raw-body", + "status": "failed", + "bytes": 0, + "total_bytes": total_len, + "requested_range": requested_range, + "error": "range_not_satisfiable", + }); + insert_library_transfer_headers(response.headers_mut(), &receipt); + response +} + +fn library_transfer_receipt( + op: &str, + request_id: &str, + uri: &str, + bytes: usize, + total_bytes: Option, + range: Option<(usize, usize)>, + status: &str, +) -> serde_json::Value { + serde_json::json!({ + "schema": LIBRARY_TRANSFER_RECEIPT_SCHEMA, + "op": op, + "request_id": request_id, + "uri": uri, + "transport": if op == "download" { + "http-body-stream" + } else { + "raw-body" + }, + "stream": if op == "download" { + serde_json::json!({ + "schema": "elastos.object.download-stream/v1", + "mode": "response_body_chunks", + "chunk_size": LIBRARY_DOWNLOAD_STREAM_CHUNK_BYTES, + "backpressure": "http_body_poll", + "cancel": "drop_body", + "progress_mode": "transfer_receipt", + }) + } else { + serde_json::Value::Null + }, + "status": status, + "bytes": bytes, + "total_bytes": total_bytes, + "range": range.map(|(start, end)| serde_json::json!({ + "start": start, + "end": end, + })), + }) +} + +fn insert_library_transfer_headers(headers: &mut HeaderMap, receipt: &serde_json::Value) { + if let Some(request_id) = receipt + .get("request_id") + .and_then(serde_json::Value::as_str) + { + if let Ok(value) = HeaderValue::from_str(request_id) { + headers.insert(LIBRARY_TRANSFER_REQUEST_ID_HEADER, value); + } + } + if let Ok(value) = HeaderValue::from_str(&receipt.to_string()) { + headers.insert(LIBRARY_TRANSFER_RECEIPT_HEADER, value); + } +} + +fn new_library_upload_session_id() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let counter = LIBRARY_UPLOAD_SESSION_COUNTER.fetch_add(1, Ordering::Relaxed); + format!("object-upload-{nanos}-{counter}") +} + +fn library_upload_sessions_dir(data_dir: &FsPath) -> PathBuf { + data_dir.join("Runtime").join("ObjectUploadSessions") +} + +fn library_upload_session_dir(data_dir: &FsPath, upload_id: &str) -> anyhow::Result { + if !library_upload_safe_id(upload_id) { + anyhow::bail!("invalid upload session id"); + } + Ok(library_upload_sessions_dir(data_dir).join(upload_id)) +} + +fn library_upload_safe_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 120 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) +} + +fn library_upload_session_metadata_path( + data_dir: &FsPath, + upload_id: &str, +) -> anyhow::Result { + Ok(library_upload_session_dir(data_dir, upload_id)?.join("session.json")) +} + +fn library_upload_session_data_path(data_dir: &FsPath, upload_id: &str) -> anyhow::Result { + Ok(library_upload_session_dir(data_dir, upload_id)?.join("payload.bin")) +} + +fn create_library_upload_session( + data_dir: &FsPath, + session: &LibraryUploadSession, +) -> anyhow::Result<()> { + let dir = library_upload_session_dir(data_dir, &session.upload_id)?; + std::fs::create_dir_all(&dir)?; + std::fs::File::create(dir.join("payload.bin"))?; + write_library_upload_session(data_dir, session) +} + +fn read_library_upload_session( + data_dir: &FsPath, + upload_id: &str, +) -> anyhow::Result { + let path = library_upload_session_metadata_path(data_dir, upload_id)?; + let bytes = std::fs::read(path)?; + serde_json::from_slice(&bytes).map_err(|err| anyhow::anyhow!("invalid upload session: {err}")) +} + +fn write_library_upload_session( + data_dir: &FsPath, + session: &LibraryUploadSession, +) -> anyhow::Result<()> { + let path = library_upload_session_metadata_path(data_dir, &session.upload_id)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, serde_json::to_vec_pretty(session)?)?; + std::fs::rename(tmp, path)?; + Ok(()) +} + +fn append_library_upload_chunk( + data_dir: &FsPath, + session: &mut LibraryUploadSession, + bytes: &[u8], +) -> anyhow::Result<()> { + let path = library_upload_session_data_path(data_dir, &session.upload_id)?; + let mut file = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .read(true) + .open(&path)?; + file.seek(SeekFrom::Start(session.received_bytes))?; + file.write_all(bytes)?; + session.received_bytes = session.received_bytes.saturating_add(bytes.len() as u64); + session.chunk_count = session.chunk_count.saturating_add(1); + session.updated_at = now_ts(); + write_library_upload_session(data_dir, session) +} + +fn read_library_upload_session_bytes( + data_dir: &FsPath, + upload_id: &str, +) -> anyhow::Result> { + let path = library_upload_session_data_path(data_dir, upload_id)?; + let mut file = std::fs::File::open(path)?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + Ok(bytes) +} + +fn remove_library_upload_session(data_dir: &FsPath, upload_id: &str) -> anyhow::Result<()> { + let dir = library_upload_session_dir(data_dir, upload_id)?; + if dir.exists() { + std::fs::remove_dir_all(dir)?; + } + Ok(()) +} + +fn cleanup_expired_library_upload_sessions(data_dir: &FsPath, now: u64) -> anyhow::Result<()> { + let dir = library_upload_sessions_dir(data_dir); + let Ok(entries) = std::fs::read_dir(dir) else { + return Ok(()); + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let Some(upload_id) = path.file_name().and_then(|value| value.to_str()) else { + continue; + }; + if !library_upload_safe_id(upload_id) { + continue; + } + let Ok(session) = read_library_upload_session(data_dir, upload_id) else { + continue; + }; + if now.saturating_sub(session.updated_at) > LIBRARY_UPLOAD_SESSION_TTL_SECS { + let _ = std::fs::remove_dir_all(path); + } + } + Ok(()) +} + +fn require_library_upload_session_context( + session: &LibraryUploadSession, + context: &HomeLaunchTokenContext, +) -> Result<(), String> { + if session.principal_id != context.principal_id || session.session_id != context.session_id { + return Err("upload session belongs to a different Runtime launch context".to_string()); + } + Ok(()) +} + +fn library_upload_offset_from_headers(headers: &HeaderMap) -> Result { + headers + .get("x-elastos-upload-offset") + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| "missing x-elastos-upload-offset header".to_string())? + .parse::() + .map_err(|_| "invalid x-elastos-upload-offset header".to_string()) +} + +fn library_upload_session_status(session: &LibraryUploadSession) -> serde_json::Value { + serde_json::json!({ + "schema": LIBRARY_UPLOAD_SESSION_SCHEMA, + "upload_id": session.upload_id, + "uri": session.uri, + "received_bytes": session.received_bytes, + "total_bytes": session.total_bytes, + "chunk_count": session.chunk_count, + "chunk_size": LIBRARY_UPLOAD_CHUNK_MAX_BYTES, + "transport": "http-chunk-session", + "backpressure": "client_waits_for_chunk_ack", + "cancel": "DELETE /api/provider/object/upload/:upload_id", + }) +} + +fn library_chunked_upload_transfer_receipt( + request_id: &str, + session: &LibraryUploadSession, +) -> serde_json::Value { + let mut receipt = library_transfer_receipt( + "upload", + request_id, + &session.uri, + session.received_bytes as usize, + session.total_bytes.map(|value| value as usize), + None, + "completed", + ); + if let Some(receipt) = receipt.as_object_mut() { + receipt.insert( + "transport".to_string(), + serde_json::json!("http-chunk-session"), + ); + receipt.insert("stream".to_string(), library_upload_session_status(session)); + } + receipt +} + +fn library_upload_json_error( + status: StatusCode, + code: &str, + message: impl Into, +) -> Response { + let mut response = Json(serde_json::json!({ + "status": "error", + "code": code, + "message": message.into(), + })) + .into_response(); + *response.status_mut() = status; + response +} + +fn library_download_byte_range( + header: &HeaderValue, + total_len: usize, +) -> Result<(usize, usize), ()> { + let value = header.to_str().map_err(|_| ())?.trim(); + let Some(spec) = value.strip_prefix("bytes=") else { + return Err(()); + }; + if spec.contains(',') { + return Err(()); + } + let (start, end) = spec.split_once('-').ok_or(())?; + if total_len == 0 { + return Err(()); + } + if start.is_empty() { + let suffix_len = end.parse::().map_err(|_| ())?; + if suffix_len == 0 { + return Err(()); + } + let start = total_len.saturating_sub(suffix_len); + return Ok((start, total_len - 1)); + } + let start = start.parse::().map_err(|_| ())?; + if start >= total_len { + return Err(()); + } + let end = if end.is_empty() { + total_len - 1 + } else { + end.parse::().map_err(|_| ())?.min(total_len - 1) + }; + if start > end { + return Err(()); + } + Ok((start, end)) +} + +fn library_download_content_disposition(filename: &str) -> String { + let clean = filename + .chars() + .map(|ch| { + if ch.is_ascii() && !ch.is_ascii_control() && ch != '"' && ch != '\\' { + ch + } else { + '_' + } + }) + .collect::(); + let clean = clean.trim(); + if clean.is_empty() { + "attachment; filename=\"download\"".to_string() + } else { + format!("attachment; filename=\"{clean}\"") + } +} + +pub(super) async fn gateway_library_events_stream( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> Response { + let headers = match library_events_stream_headers(headers, query.home_token.as_deref()) { + Ok(headers) => headers, + Err(err) => return gateway_provider_error_response("object", err), + }; + let context = match require_home_launch_token_for_any_context( + &state.data_dir, + &headers, + &[LIBRARY_CAPSULE_ID], + ) { + Ok(context) => context, + Err(err) => return gateway_provider_error_response("object", err), + }; + let registry = match state.provider_registry.as_ref().cloned() { + Some(registry) => registry, + None => { + return gateway_provider_error_response( + "object", + anyhow::anyhow!("object provider unavailable"), + ) + } + }; + + let stream_state = LibraryEventsStreamState { + registry, + principal_id: context.principal_id, + cursor: String::new(), + initialized: false, + }; + let stream = futures_lite::stream::unfold(stream_state, |mut stream_state| async move { + loop { + match library_events_since_cursor( + &stream_state.registry, + &stream_state.principal_id, + &stream_state.cursor, + ) + .await + { + Ok((_events, cursor)) if !stream_state.initialized => { + stream_state.cursor = cursor; + stream_state.initialized = true; + } + Ok((events, cursor)) if !events.is_empty() => { + stream_state.cursor = cursor.clone(); + let event = library_events_sse_event(serde_json::json!({ + "schema": "elastos.library.events/v1", + "cursor": cursor, + "events": events, + })); + return Some((Ok::(event), stream_state)); + } + Ok(_) => {} + Err(err) => { + let event = library_events_sse_event(serde_json::json!({ + "schema": "elastos.library.events/v1", + "status": "error", + "message": err.to_string(), + "events": [], + })); + return Some((Ok::(event), stream_state)); + } + } + crate::library::library_event_notifier().notified().await; + } + }); + + let mut response = Sse::new(stream) + .keep_alive( + KeepAlive::new() + .interval(Duration::from_secs(LIBRARY_EVENTS_STREAM_KEEPALIVE_SECS)) + .text("keepalive"), + ) + .into_response(); + let headers = response.headers_mut(); + headers.insert( + axum::http::header::CACHE_CONTROL, + HeaderValue::from_static("no-cache, no-transform"), + ); + headers.insert( + axum::http::HeaderName::from_static("x-accel-buffering"), + HeaderValue::from_static("no"), + ); + response +} + pub(super) async fn gateway_provider_proxy( State(state): State, Path((scheme, op)): Path<(String, String)>, @@ -11,6 +1283,42 @@ pub(super) async fn gateway_provider_proxy( "summary" | "get" => &[DOCUMENTS_CAPSULE_ID, LIBRARY_CAPSULE_ID], _ => &[DOCUMENTS_CAPSULE_ID], }, + "object" => match op.as_str() { + "roots" + | "list" + | "stat" + | "read" + | "download" + | "write" + | "mkdir" + | "rename" + | "move" + | "copy" + | "trash" + | "restore" + | "delete_permanently" + | "empty_trash" + | "status" + | "sync" + | "extract_archive" + | "archive_entries" + | "archive_preview_entry" + | "archive_extract_entries" + | "compress_archive" + | "publish" + | "unpublish" + | "repair" + | "share" + | "shared_access" + | "events" => &[LIBRARY_CAPSULE_ID], + _ => { + return ( + StatusCode::NOT_FOUND, + "Gateway provider operation not found", + ) + .into_response() + } + }, "chain" => match op.as_str() { "networks" | "status" | "block_number" | "sync_health" | "node_lifecycle" => { &[SYSTEM_CAPSULE_ID] @@ -68,9 +1376,33 @@ pub(super) async fn gateway_provider_proxy( } }; request["op"] = serde_json::Value::String(op.clone()); - if scheme == "documents" || scheme == "net" { + if scheme == "documents" || scheme == "object" || scheme == "net" { request["principal_id"] = serde_json::Value::String(principal_id.clone()); } + if scheme == "object" && op == "shared_access" { + if let Some(object) = request.as_object_mut() { + object.remove("recipient_proof"); + if object + .get("recipient") + .and_then(serde_json::Value::as_str) + .map(str::trim) + == Some(principal_id.as_str()) + && context.proof_binding_id.is_some() + { + object.insert( + "recipient_proof".to_string(), + serde_json::json!({ + "schema": "elastos.library.recipient-proof/v1", + "source": "runtime-launch-grant", + "recipient": principal_id, + "principal_id": principal_id, + "proof_binding_id": context.proof_binding_id.as_deref().unwrap_or_default(), + "session_id": session_id, + }), + ); + } + } + } if scheme == "net" && op == "http" { return gateway_browser::gateway_browser_net_http(registry.as_ref(), &request).await; @@ -80,6 +1412,7 @@ pub(super) async fn gateway_provider_proxy( } let chain_lifecycle_audit = chain_lifecycle_effect_audit(&scheme, &op, &request); + let library_audit = (scheme == "object").then(|| format!("object:{op}:{}", now_ts())); if let Some(audit) = &chain_lifecycle_audit { if let Err(err) = append_provider_effect_audit( &state.data_dir, @@ -102,32 +1435,64 @@ pub(super) async fn gateway_provider_proxy( ); } } - - let response = match registry.send_raw(&scheme, &request).await { - Ok(value) - if scheme == "net" && value.get("status").and_then(|v| v.as_str()) == Some("error") => - { - let message = value - .get("message") - .and_then(|v| v.as_str()) - .unwrap_or("net provider unavailable"); + if let Some(request_id) = &library_audit { + if let Err(err) = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: "object.provider.requested", + principal_id: &principal_id, + session_id: &session_id, + request_id, + result: "requested", + reason: &format!("Library requested object provider operation {op}"), + }, + ) { return gateway_provider_error_response( &scheme, - anyhow::anyhow!("net provider unavailable: {}", message), + anyhow::anyhow!("object provider audit failed: {}", err), ); } - Ok(value) => value, - Err(err) if scheme == "net" => { - return gateway_provider_error_response( - &scheme, - anyhow::anyhow!("net provider unavailable: {}", err), - ) - } - Err(err) => serde_json::json!({ + } + + let response = if scheme == "object" + && (library_operation_needs_runtime_coordinator(&op) + || library_request_targets_webspace(&request)) + { + crate::library::handle_object_provider_runtime_request( + &state.data_dir, + Arc::clone(®istry), + &request, + ) + .await + } else { + match registry.send_raw(&scheme, &request).await { + Ok(value) + if scheme == "net" + && value.get("status").and_then(|v| v.as_str()) == Some("error") => + { + let message = value + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("net provider unavailable"); + return gateway_provider_error_response( + &scheme, + anyhow::anyhow!("net provider unavailable: {}", message), + ); + } + Ok(value) => value, + Err(err) if scheme == "net" => { + return gateway_provider_error_response( + &scheme, + anyhow::anyhow!("net provider unavailable: {}", err), + ) + } + Err(err) => serde_json::json!({ "status": "error", "code": "provider_error", - "message": err.to_string(), - }), + "message": err.to_string(), + }), + } }; if let Some(audit) = &chain_lifecycle_audit { @@ -159,6 +1524,154 @@ pub(super) async fn gateway_provider_proxy( ); } } + if let Some(request_id) = &library_audit { + let completed = response.get("status").and_then(|value| value.as_str()) == Some("ok"); + if completed && library_operation_emits_events(&op) { + crate::library::library_event_notifier().notify_waiters(); + } + if let Err(err) = append_provider_effect_audit( + &state.data_dir, + ProviderEffectAuditInput { + capsule_id: LIBRARY_CAPSULE_ID, + event_type: if completed { + "object.provider.completed" + } else { + "object.provider.failed" + }, + principal_id: &principal_id, + session_id: &session_id, + request_id, + result: if completed { "completed" } else { "failed" }, + reason: &format!( + "Library {} object provider operation {op}", + if completed { "completed" } else { "failed" } + ), + }, + ) { + return gateway_provider_error_response( + &scheme, + anyhow::anyhow!("object provider audit failed: {}", err), + ); + } + } Json(response).into_response() } + +fn library_operation_emits_events(op: &str) -> bool { + matches!( + op, + "write" + | "mkdir" + | "rename" + | "move" + | "copy" + | "trash" + | "restore" + | "delete_permanently" + | "empty_trash" + | "extract_archive" + | "archive_extract_entries" + | "compress_archive" + | "publish" + | "unpublish" + | "repair" + | "share" + ) +} + +fn library_operation_needs_runtime_coordinator(op: &str) -> bool { + matches!(op, "publish" | "unpublish" | "repair" | "sync") +} + +fn library_request_targets_webspace(request: &serde_json::Value) -> bool { + ["uri", "parent_uri", "target_uri", "target_parent_uri"] + .iter() + .filter_map(|field| request.get(field).and_then(|value| value.as_str())) + .map(str::trim) + .map(|value| value.trim_end_matches('/')) + .any(|value| { + value == "localhost://WebSpaces" + || value + .strip_prefix("localhost://WebSpaces/") + .is_some_and(|rest| !rest.is_empty()) + }) +} + +struct LibraryEventsStreamState { + registry: Arc, + principal_id: String, + cursor: String, + initialized: bool, +} + +fn library_events_stream_headers( + mut headers: HeaderMap, + query_token: Option<&str>, +) -> anyhow::Result { + if headers.get("x-elastos-home-token").is_none() { + if let Some(token) = query_token.map(str::trim).filter(|token| !token.is_empty()) { + headers.insert("x-elastos-home-token", HeaderValue::from_str(token)?); + } + } + Ok(headers) +} + +async fn library_events_since_cursor( + registry: &ProviderRegistry, + principal_id: &str, + cursor: &str, +) -> anyhow::Result<(Vec, String)> { + let response = registry + .send_raw( + "object", + &serde_json::json!({ + "op": "events", + "principal_id": principal_id, + "limit": 256, + }), + ) + .await + .map_err(|err| anyhow::anyhow!("object provider unavailable: {err}"))?; + if response.get("status").and_then(|value| value.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("library events failed"); + anyhow::bail!("{message}"); + } + let events = response + .get("data") + .and_then(|data| data.get("events")) + .and_then(|value| value.as_array()) + .cloned() + .unwrap_or_default(); + let next_cursor = events + .last() + .and_then(|event| event.get("event_id")) + .and_then(|value| value.as_str()) + .unwrap_or(cursor) + .to_string(); + if cursor.is_empty() { + return Ok((events, next_cursor)); + } + let cursor_index = events.iter().position(|event| { + event + .get("event_id") + .and_then(|value| value.as_str()) + .is_some_and(|event_id| event_id == cursor) + }); + let filtered = if let Some(index) = cursor_index { + events.into_iter().skip(index + 1).collect() + } else { + events + }; + Ok((filtered, next_cursor)) +} + +fn library_events_sse_event(payload: serde_json::Value) -> SseEvent { + let data = serde_json::to_string(&payload).unwrap_or_else(|_| { + r#"{"schema":"elastos.library.events/v1","status":"error","events":[]}"#.to_string() + }); + SseEvent::default().event("library-events").data(data) +} diff --git a/elastos/crates/elastos-server/src/api/gateway_tests/home_system.rs b/elastos/crates/elastos-server/src/api/gateway_tests/home_system.rs index f4e3d60a..ef169458 100644 --- a/elastos/crates/elastos-server/src/api/gateway_tests/home_system.rs +++ b/elastos/crates/elastos-server/src/api/gateway_tests/home_system.rs @@ -100,8 +100,10 @@ async fn test_home_static_route_serves_browser_surface() { async fn test_home_summary_reports_identity_and_launch_targets() { let dir = tempfile::tempdir().unwrap(); - let app = gateway_router(test_state(dir.path())); + let state = library_test_state(dir.path()).await; + let app = gateway_router(state); let authority = passkey_authority_with_name(dir.path(), Some("anders")); + let library_token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); let public = app .clone() .oneshot( @@ -125,6 +127,15 @@ async fn test_home_summary_reports_identity_and_launch_targets() { assert!(public_payload["identity"]["device_did"].is_null()); assert_eq!(public_payload["browser_state"]["principal_id"], ""); assert_eq!(public_payload["browser_state"]["localhost_root"], ""); + assert_eq!( + public_payload["desktop_objects"]["schema"], + "elastos.home.desktop-objects/v1" + ); + assert_eq!(public_payload["desktop_objects"]["uri"], ""); + assert!(public_payload["desktop_objects"]["objects"] + .as_array() + .unwrap() + .is_empty()); assert!(public_payload["browser_state"]["layout"].is_null()); assert!(public_payload["browser_state"]["session"].is_null()); assert!(public_payload["browser_state"]["recent_targets"] @@ -144,6 +155,27 @@ async fn test_home_summary_reports_identity_and_launch_targets() { .iter() .any(|target| target["target"] == "system")); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/mkdir") + .header("x-elastos-home-token", library_token.as_str()) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + json!({ + "parent_uri": format!("{}/Desktop", crate::auth::principal_localhost_root(&authority.principal_id)), + "name": "Test Folder", + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let resp = app .oneshot( Request::builder() @@ -172,6 +204,30 @@ async fn test_home_summary_reports_identity_and_launch_targets() { assert_eq!(payload["site"]["root_uri"], MY_WEBSITE_URI); assert_eq!(payload["room"]["pending_count"], 0); assert_eq!(payload["notifications"]["unread_count"], 0); + assert_eq!( + payload["desktop_objects"]["schema"], + "elastos.home.desktop-objects/v1" + ); + let localhost_root = crate::auth::principal_localhost_root(&authority.principal_id); + assert_eq!( + payload["desktop_objects"]["uri"], + format!("{localhost_root}/Desktop") + ); + assert!(payload["desktop_objects"]["objects"] + .as_array() + .unwrap() + .iter() + .any(|object| object["name"] == "Test Folder" && object["kind"] == "directory")); + assert!(payload["desktop_objects"]["objects"] + .as_array() + .unwrap() + .iter() + .any(|object| { + object["name"] == "Trash" + && object["kind"] == "directory" + && object["uri"] == format!("{localhost_root}/.Trash") + && object["metadata"]["system_kind"] == "trash" + })); let targets = payload["targets"].as_array().unwrap(); let system = targets .iter() @@ -1464,6 +1520,29 @@ async fn test_home_browser_state_is_encrypted_for_protected_principal_root() { async fn test_home_browser_state_drops_unknown_targets() { let dir = tempfile::tempdir().unwrap(); let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let localhost_root = crate::auth::principal_localhost_root(&authority.principal_id); + let desktop_object_entry = format!("object:{localhost_root}/Desktop/Test Folder"); + let trash_entry = format!("object:{localhost_root}/.Trash"); + let foreign_object_entry = "object:localhost://Users/foreign/Desktop/Bad".to_string(); + let mut layout = json!({ + "desktop": { + "system": { "x": 12, "y": 12 }, + "obsolete-wallet": { "x": 24, "y": 24 } + }, + "desktopHidden": ["system", "obsolete-wallet"], + "desktopLabels": { + "system": "System", + "obsolete-wallet": "Old Wallet" + }, + "taskbar": ["system", "obsolete-wallet"], + "desktopIconsVisible": true + }); + { + let desktop = layout["desktop"].as_object_mut().unwrap(); + desktop.insert(desktop_object_entry.clone(), json!({ "x": 36, "y": 36 })); + desktop.insert(trash_entry.clone(), json!({ "x": 48, "y": 48 })); + desktop.insert(foreign_object_entry.clone(), json!({ "x": 60, "y": 60 })); + } let app = gateway_router(test_state(dir.path())); let updated = app @@ -1476,19 +1555,7 @@ async fn test_home_browser_state_drops_unknown_targets() { .header(CONTENT_TYPE, "application/json") .body(Body::from( json!({ - "layout": { - "desktop": { - "system": { "x": 12, "y": 12 }, - "obsolete-wallet": { "x": 24, "y": 24 } - }, - "desktopHidden": ["system", "obsolete-wallet"], - "desktopLabels": { - "system": "System", - "obsolete-wallet": "Old Wallet" - }, - "taskbar": ["system", "obsolete-wallet"], - "desktopIconsVisible": true - }, + "layout": layout, "session": { "browser_context_id": "browser:test", "windows": [ @@ -1510,6 +1577,15 @@ async fn test_home_browser_state_drops_unknown_targets() { .unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); assert!(json["layout"]["desktop"].get("obsolete-wallet").is_none()); + assert!(json["layout"]["desktop"] + .get(desktop_object_entry.as_str()) + .is_some()); + assert!(json["layout"]["desktop"] + .get(trash_entry.as_str()) + .is_some()); + assert!(json["layout"]["desktop"] + .get(foreign_object_entry.as_str()) + .is_none()); assert!(json["layout"]["desktopLabels"] .get("obsolete-wallet") .is_none()); diff --git a/elastos/crates/elastos-server/src/api/gateway_tests/library.rs b/elastos/crates/elastos-server/src/api/gateway_tests/library.rs new file mode 100644 index 00000000..e3d532fc --- /dev/null +++ b/elastos/crates/elastos-server/src/api/gateway_tests/library.rs @@ -0,0 +1,5627 @@ +use super::*; + +fn provider_body(value: serde_json::Value) -> Body { + Body::from(serde_json::to_vec(&value).unwrap()) +} + +async fn post_library( + app: axum::Router, + token: &str, + op: &str, + body: serde_json::Value, +) -> (StatusCode, serde_json::Value) { + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/provider/object/{op}")) + .header("x-elastos-home-token", token) + .header(CONTENT_TYPE, "application/json") + .body(provider_body(body)) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let payload = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).unwrap() + }; + (status, payload) +} + +async fn put_library_upload( + app: axum::Router, + token: &str, + uri: &str, + body: &'static [u8], +) -> (StatusCode, HeaderMap, serde_json::Value) { + let encoded_uri = uri.replace(':', "%3A").replace('/', "%2F"); + let response = app + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/api/provider/object/upload?uri={encoded_uri}")) + .header("x-elastos-home-token", token) + .header(CONTENT_TYPE, "text/plain") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let payload = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).unwrap() + }; + (status, headers, payload) +} + +async fn post_library_upload_start( + app: axum::Router, + token: &str, + body: serde_json::Value, +) -> (StatusCode, serde_json::Value) { + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/upload/start") + .header("x-elastos-home-token", token) + .header(CONTENT_TYPE, "application/json") + .body(provider_body(body)) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let payload = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).unwrap() + }; + (status, payload) +} + +async fn put_library_upload_chunk( + app: axum::Router, + token: &str, + upload_id: &str, + offset: u64, + body: &'static [u8], +) -> (StatusCode, serde_json::Value) { + let response = app + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/api/provider/object/upload/{upload_id}/chunk")) + .header("x-elastos-home-token", token) + .header("x-elastos-upload-offset", offset.to_string()) + .header(CONTENT_TYPE, "text/plain") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let payload = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).unwrap() + }; + (status, payload) +} + +async fn post_library_upload_finish( + app: axum::Router, + token: &str, + upload_id: &str, +) -> (StatusCode, HeaderMap, serde_json::Value) { + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/provider/object/upload/{upload_id}/finish")) + .header("x-elastos-home-token", token) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let payload = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).unwrap() + }; + (status, headers, payload) +} + +async fn get_library_download( + app: axum::Router, + token: &str, + uri: &str, +) -> (StatusCode, HeaderMap, Vec) { + get_library_download_with_range(app, token, uri, None).await +} + +async fn get_library_download_with_range( + app: axum::Router, + token: &str, + uri: &str, + range: Option<&str>, +) -> (StatusCode, HeaderMap, Vec) { + let encoded_uri = uri.replace(':', "%3A").replace('/', "%2F"); + let mut request = Request::builder() + .method("GET") + .uri(format!( + "/api/provider/object/download/raw?uri={encoded_uri}" + )) + .header("x-elastos-home-token", token); + if let Some(range) = range { + request = request.header(axum::http::header::RANGE, range); + } + let response = app + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap() + .to_vec(); + (status, headers, bytes) +} + +async fn get_library_download_many( + app: axum::Router, + token: &str, + uris: &[String], +) -> (StatusCode, HeaderMap, Vec) { + get_library_download_many_with_archive(app, token, uris, None).await +} + +async fn get_library_download_many_with_archive( + app: axum::Router, + token: &str, + uris: &[String], + archive: Option<&str>, +) -> (StatusCode, HeaderMap, Vec) { + let mut query_parts = uris + .iter() + .map(|uri| { + let encoded_uri = uri.replace(':', "%3A").replace('/', "%2F"); + format!("uri={encoded_uri}") + }) + .collect::>(); + if let Some(archive) = archive { + query_parts.push(format!("archive={archive}")); + } + let query = query_parts.join("&"); + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/api/provider/object/download/raw?{query}")) + .header("x-elastos-home-token", token) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap() + .to_vec(); + (status, headers, bytes) +} + +fn transfer_receipt(headers: &HeaderMap) -> serde_json::Value { + serde_json::from_str( + headers + .get("x-elastos-transfer-receipt") + .and_then(|value| value.to_str().ok()) + .unwrap(), + ) + .unwrap() +} + +fn zip_text_files(bytes: &[u8]) -> std::collections::BTreeMap { + use std::io::Read as _; + + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut files = std::collections::BTreeMap::new(); + for index in 0..archive.len() { + let mut entry = archive.by_index(index).unwrap(); + if entry.is_dir() { + continue; + } + let name = entry.name().to_string(); + let mut body = String::new(); + entry.read_to_string(&mut body).unwrap(); + files.insert(name, body); + } + files +} + +#[tokio::test] +async fn test_library_provider_requires_library_token() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + + let denied = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/roots") + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + + let documents_token = issue_home_launch_token(dir.path(), DOCUMENTS_CAPSULE_ID).unwrap(); + let rejected = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/roots") + .header("x-elastos-home-token", documents_token) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn test_library_download_route_requires_library_token_and_streams_download_bytes() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let documents_token = app_token_for_authority(dir.path(), DOCUMENTS_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/raw-download.txt"); + let encoded_uri = uri.replace(':', "%3A").replace('/', "%2F"); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "mime": "text/plain", + "data": base64::engine::general_purpose::STANDARD.encode(b"raw download body"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + + let denied = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/api/provider/object/download/raw?uri={encoded_uri}" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + + let (rejected_status, _headers, _bytes) = + get_library_download(app.clone(), &documents_token, &uri).await; + assert_eq!(rejected_status, StatusCode::FORBIDDEN); + + let (download_status, headers, bytes) = get_library_download(app.clone(), &token, &uri).await; + assert_eq!(download_status, StatusCode::OK); + assert_eq!(bytes, b"raw download body"); + assert_eq!( + headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("text/plain"), + ); + assert_eq!( + headers + .get(axum::http::header::CONTENT_DISPOSITION) + .and_then(|value| value.to_str().ok()), + Some("attachment; filename=\"raw-download.txt\""), + ); + assert_eq!( + headers + .get(axum::http::header::ACCEPT_RANGES) + .and_then(|value| value.to_str().ok()), + Some("bytes"), + ); + assert!(headers + .get("x-elastos-request-id") + .and_then(|value| value.to_str().ok()) + .unwrap() + .starts_with("object:download:")); + let receipt = transfer_receipt(&headers); + assert_eq!(receipt["schema"], "elastos.object.transfer.receipt/v1"); + assert_eq!(receipt["op"], "download"); + assert_eq!(receipt["status"], "completed"); + assert_eq!(receipt["bytes"], 17); + assert_eq!(receipt["total_bytes"], 17); + assert_eq!(receipt["transport"], "http-body-stream"); + assert_eq!( + receipt["stream"]["schema"], + "elastos.object.download-stream/v1" + ); + assert_eq!(receipt["stream"]["mode"], "response_body_chunks"); + assert_eq!(receipt["stream"]["backpressure"], "http_body_poll"); + assert_eq!(receipt["stream"]["cancel"], "drop_body"); + + let (range_status, range_headers, range_bytes) = + get_library_download_with_range(app, &token, &uri, Some("bytes=4-11")).await; + assert_eq!(range_status, StatusCode::PARTIAL_CONTENT); + assert_eq!(range_bytes, b"download"); + assert_eq!( + range_headers + .get(axum::http::header::CONTENT_RANGE) + .and_then(|value| value.to_str().ok()), + Some("bytes 4-11/17"), + ); + assert_eq!( + range_headers + .get(axum::http::header::ACCEPT_RANGES) + .and_then(|value| value.to_str().ok()), + Some("bytes"), + ); + let range_receipt = transfer_receipt(&range_headers); + assert_eq!( + range_receipt["schema"], + "elastos.object.transfer.receipt/v1" + ); + assert_eq!(range_receipt["op"], "download"); + assert_eq!(range_receipt["status"], "completed"); + assert_eq!(range_receipt["bytes"], 8); + assert_eq!(range_receipt["total_bytes"], 17); + assert_eq!(range_receipt["range"]["start"], 4); + assert_eq!(range_receipt["range"]["end"], 11); + assert_eq!(range_receipt["transport"], "http-body-stream"); + assert_eq!(range_receipt["stream"]["mode"], "response_body_chunks"); +} + +#[tokio::test] +async fn test_library_upload_route_requires_library_token_and_writes_raw_body() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let documents_token = app_token_for_authority(dir.path(), DOCUMENTS_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/raw-upload.txt"); + let encoded_uri = uri.replace(':', "%3A").replace('/', "%2F"); + + let denied = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/api/provider/object/upload?uri={encoded_uri}")) + .header(CONTENT_TYPE, "text/plain") + .body(Body::from("no token")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + + let rejected = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/api/provider/object/upload?uri={encoded_uri}")) + .header("x-elastos-home-token", documents_token) + .header(CONTENT_TYPE, "text/plain") + .body(Body::from("wrong app")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::FORBIDDEN); + + let (upload_status, upload_headers, upload) = + put_library_upload(app.clone(), &token, &uri, b"raw upload body").await; + assert_eq!(upload_status, StatusCode::OK); + assert_eq!(upload["status"], "ok"); + assert_eq!(upload["data"]["transport"], "raw-body"); + assert_eq!(upload["data"]["object"]["uri"], uri); + assert!(upload["data"]["object"]["content_cid"] + .as_str() + .unwrap() + .starts_with("bafkrei")); + assert_eq!(upload["data"]["object"].get("published_cid"), None); + assert_eq!(upload["data"]["object"]["published"], false); + assert!(upload["data"]["request_id"] + .as_str() + .unwrap() + .starts_with("object:upload:")); + assert_eq!( + upload["data"]["receipt"]["schema"], + "elastos.object.transfer.receipt/v1" + ); + assert_eq!(upload["data"].get("provider_receipt"), None); + assert_eq!(upload["data"]["receipt"]["op"], "upload"); + assert_eq!(upload["data"]["receipt"]["status"], "completed"); + assert_eq!(upload["data"]["receipt"]["bytes"], 15); + assert_eq!(upload["data"]["receipt"]["total_bytes"], 15); + assert_eq!( + upload_headers + .get("x-elastos-request-id") + .and_then(|value| value.to_str().ok()), + upload["data"]["request_id"].as_str() + ); + assert_eq!( + transfer_receipt(&upload_headers)["schema"], + "elastos.object.transfer.receipt/v1" + ); + + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let data = read["data"]["data"].as_str().unwrap(); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(data) + .unwrap(), + b"raw upload body" + ); +} + +#[tokio::test] +async fn test_library_chunked_upload_session_writes_object_and_emits_receipt() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/chunked-upload.txt"); + let body = b"chunked upload body"; + + let (start_status, start) = post_library_upload_start( + app.clone(), + &token, + json!({ + "uri": uri, + "mime": "text/plain", + "size_bytes": body.len(), + }), + ) + .await; + assert_eq!(start_status, StatusCode::OK); + assert_eq!(start["status"], "ok"); + assert_eq!(start["data"]["schema"], "elastos.object.upload-session/v1"); + assert_eq!(start["data"]["transport"], "http-chunk-session"); + assert_eq!(start["data"]["received_bytes"], 0); + assert!(start["data"]["chunk_size"].as_u64().unwrap() < 1024 * 1024); + let upload_id = start["data"]["upload_id"].as_str().unwrap(); + + let (first_status, first_chunk) = + put_library_upload_chunk(app.clone(), &token, upload_id, 0, b"chunked ").await; + assert_eq!(first_status, StatusCode::OK); + assert_eq!(first_chunk["data"]["received_bytes"], 8); + assert_eq!(first_chunk["data"]["chunk_count"], 1); + + let (second_status, second_chunk) = + put_library_upload_chunk(app.clone(), &token, upload_id, 8, b"upload body").await; + assert_eq!(second_status, StatusCode::OK); + assert_eq!(second_chunk["data"]["received_bytes"], body.len()); + assert_eq!(second_chunk["data"]["chunk_count"], 2); + + let (finish_status, finish_headers, finish) = + post_library_upload_finish(app.clone(), &token, upload_id).await; + assert_eq!(finish_status, StatusCode::OK); + assert_eq!(finish["status"], "ok"); + assert_eq!(finish["data"]["object"]["uri"], uri); + assert!(finish["data"]["object"]["content_cid"] + .as_str() + .unwrap() + .starts_with("bafkrei")); + assert_eq!(finish["data"]["object"].get("published_cid"), None); + assert_eq!(finish["data"]["object"]["published"], false); + assert_eq!(finish["data"]["transport"], "raw-body"); + assert_eq!(finish["data"]["browser_transport"], "http-chunk-session"); + assert_eq!(finish["data"]["upload_session"]["chunk_count"], 2); + assert_eq!(finish["data"]["receipt"]["op"], "upload"); + assert_eq!(finish["data"]["receipt"]["transport"], "http-chunk-session"); + assert_eq!( + finish["data"]["receipt"]["stream"]["backpressure"], + "client_waits_for_chunk_ack" + ); + assert_eq!( + transfer_receipt(&finish_headers)["transport"], + "http-chunk-session" + ); + + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let data = read["data"]["data"].as_str().unwrap(); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(data) + .unwrap(), + body + ); +} + +#[tokio::test] +async fn test_documents_viewer_route_can_read_and_save_library_file_only() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let library_token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let documents_token = app_token_for_authority(dir.path(), DOCUMENTS_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/from-library.md"); + write_test_static_capsule( + dir.path(), + DOCUMENTS_CAPSULE_ID, + "viewer", + "Test Documents viewer", + "Documents Viewer", + ); + let encoded_uri = uri.replace(':', "%3A").replace('/', "%2F"); + + let (write_status, write) = post_library( + app.clone(), + &library_token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"# From Library"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + + let direct_read = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/read") + .header("x-elastos-home-token", documents_token.clone()) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "uri": uri, + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(direct_read.status(), StatusCode::FORBIDDEN); + + let read = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/api/viewers/documents/library-object?uri={encoded_uri}" + )) + .header("x-elastos-home-token", documents_token.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(read.status(), StatusCode::OK); + let read_body = axum::body::to_bytes(read.into_body(), usize::MAX) + .await + .unwrap(); + let read: serde_json::Value = serde_json::from_slice(&read_body).unwrap(); + let decoded = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(decoded, b"# From Library"); + + let revision = read["data"]["object"]["revision"].as_str().unwrap(); + let save = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(format!( + "/api/viewers/documents/library-object?uri={encoded_uri}" + )) + .header("x-elastos-home-token", documents_token.clone()) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "if_revision": revision, + "data": base64::engine::general_purpose::STANDARD + .encode(b"# Saved From Documents"), + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(save.status(), StatusCode::OK); + let save_body = axum::body::to_bytes(save.into_body(), usize::MAX) + .await + .unwrap(); + let save: serde_json::Value = serde_json::from_slice(&save_body).unwrap(); + assert_eq!(save["status"], "ok"); +} + +#[tokio::test] +async fn test_library_provider_rejects_unknown_operation() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let token = issue_home_launch_token(dir.path(), LIBRARY_CAPSULE_ID).unwrap(); + + let rejected = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/raw_host_path") + .header("x-elastos-home-token", token) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(rejected.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn test_library_provider_object_lifecycle() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + + let (roots_status, roots) = post_library(app.clone(), &token, "roots", json!({})).await; + assert_eq!(roots_status, StatusCode::OK); + assert_eq!(roots["status"], "ok"); + assert!(roots["data"]["roots"] + .as_array() + .unwrap() + .iter() + .any(|root| root["id"] == "documents" && root["label"] == "Documents")); + assert!(roots["data"]["roots"] + .as_array() + .unwrap() + .iter() + .any(|root| root["id"] == "desktop" && root["label"] == "Desktop")); + assert!(roots["data"]["roots"] + .as_array() + .unwrap() + .iter() + .any(|root| { + root["id"] == "webspaces" + && root["label"] == "Spaces" + && root["uri"] == "localhost://WebSpaces" + })); + assert!(roots["data"]["roots"] + .as_array() + .unwrap() + .iter() + .any(|entry| { + entry["id"] == "trash" + && entry["label"] == "Trash" + && entry["uri"] == format!("{root}/.Trash") + && entry["metadata"]["empty"] == true + })); + + let (mkdir_status, mkdir) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + assert_eq!(mkdir["status"], "ok"); + assert_eq!(mkdir["data"]["object"]["uri"], documents_uri); + + let notes_uri = format!("{documents_uri}/notes.txt"); + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": notes_uri, + "mime": "text/plain", + "data": base64::engine::general_purpose::STANDARD.encode(b"hello library"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + assert_eq!(write["data"]["object"]["name"], "notes.txt"); + + let (list_status, list) = post_library( + app.clone(), + &token, + "list", + json!({ + "uri": documents_uri, + }), + ) + .await; + assert_eq!(list_status, StatusCode::OK); + assert!(list["data"]["objects"] + .as_array() + .unwrap() + .iter() + .any(|object| object["name"] == "notes.txt" && object["kind"] == "file")); + + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": notes_uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let data = read["data"]["data"].as_str().unwrap(); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(data) + .unwrap(), + b"hello library" + ); + + let (rename_status, rename) = post_library( + app.clone(), + &token, + "rename", + json!({ + "uri": notes_uri, + "name": "renamed.txt", + }), + ) + .await; + assert_eq!(rename_status, StatusCode::OK); + let renamed_uri = rename["data"]["object"]["uri"] + .as_str() + .unwrap() + .to_string(); + assert!(renamed_uri.ends_with("/Documents/renamed.txt")); + + let (trash_status, trash) = post_library( + app.clone(), + &token, + "trash", + json!({ + "uri": renamed_uri, + }), + ) + .await; + assert_eq!(trash_status, StatusCode::OK); + let trash_uri = trash["data"]["object"]["uri"].as_str().unwrap().to_string(); + assert!(trash_uri.contains("/.Trash/")); + assert_eq!( + trash["data"]["object"]["metadata"]["trash"]["original_uri"], + renamed_uri + ); + + let (restore_status, restore) = post_library( + app.clone(), + &token, + "restore", + json!({ + "uri": trash_uri, + }), + ) + .await; + assert_eq!(restore_status, StatusCode::OK); + assert_eq!(restore["data"]["object"]["uri"], renamed_uri); + + let (trash_again_status, trash_again) = post_library( + app.clone(), + &token, + "trash", + json!({ + "uri": renamed_uri, + }), + ) + .await; + assert_eq!(trash_again_status, StatusCode::OK); + let deleted_uri = trash_again["data"]["object"]["uri"].as_str().unwrap(); + let (delete_status, deleted) = post_library( + app.clone(), + &token, + "delete_permanently", + json!({ + "uri": deleted_uri, + }), + ) + .await; + assert_eq!(delete_status, StatusCode::OK); + assert_eq!(deleted["data"]["deleted_uri"], deleted_uri); + + let cleanup_uri = format!("{documents_uri}/cleanup.txt"); + let (cleanup_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": cleanup_uri, + "mime": "text/plain", + "data": base64::engine::general_purpose::STANDARD.encode(b"cleanup"), + }), + ) + .await; + assert_eq!(cleanup_status, StatusCode::OK); + let (trash_cleanup_status, _) = post_library( + app.clone(), + &token, + "trash", + json!({ + "uri": cleanup_uri, + }), + ) + .await; + assert_eq!(trash_cleanup_status, StatusCode::OK); + let (empty_status, empty) = post_library(app, &token, "empty_trash", json!({})).await; + assert_eq!(empty_status, StatusCode::OK); + assert_eq!(empty["data"]["deleted_count"], 1); +} + +#[tokio::test] +async fn test_library_provider_separates_public_placement_from_publish_visibility() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let public_uri = format!("{root}/Public"); + + let (mkdir_status, mkdir) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Public", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + assert_eq!( + mkdir["data"]["object"]["metadata"]["visibility"]["schema"], + "elastos.library.visibility/v1" + ); + assert_eq!( + mkdir["data"]["object"]["metadata"]["visibility"]["placement"], + "public_folder" + ); + assert_eq!( + mkdir["data"]["object"]["metadata"]["visibility"]["effective_access"], + "principal_private" + ); + + let file_uri = format!("{public_uri}/draft.txt"); + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": file_uri, + "mime": "text/plain", + "data": base64::engine::general_purpose::STANDARD.encode(b"public placement draft"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + assert_eq!( + write["data"]["object"]["metadata"]["visibility"]["schema"], + "elastos.library.visibility/v1" + ); + assert_eq!( + write["data"]["object"]["metadata"]["visibility"]["placement"], + "public_folder" + ); + assert_eq!( + write["data"]["object"]["metadata"]["visibility"]["effective_access"], + "principal_private" + ); + assert_eq!( + write["data"]["object"]["metadata"]["visibility"]["publish_required_for_public_link"], + true + ); + assert_eq!(write["data"]["object"]["published"], false); + assert!(write["data"]["object"]["published_cid"].is_null()); + assert!(write["data"]["object"]["metadata"]["visibility"]["published_cid"].is_null()); + + let (publish_status, publish) = post_library( + app, + &token, + "publish", + json!({ + "uri": file_uri, + "if_revision": write["data"]["object"]["revision"], + }), + ) + .await; + assert_eq!(publish_status, StatusCode::OK); + assert_eq!(publish["status"], "ok"); + assert_eq!(publish["data"]["cid"], TEST_CIDV1); + assert_eq!(publish["data"]["uri"], format!("elastos://{TEST_CIDV1}")); + assert_eq!( + publish["data"]["object"]["metadata"]["visibility"]["placement"], + "public_folder" + ); + assert_eq!( + publish["data"]["object"]["metadata"]["visibility"]["effective_access"], + "public_content_link" + ); + assert_eq!( + publish["data"]["object"]["metadata"]["visibility"]["publish_required_for_public_link"], + false + ); + assert_eq!(publish["data"]["object"]["published"], true); + assert_eq!(publish["data"]["object"]["published_cid"], TEST_CIDV1); + assert_eq!( + publish["data"]["object"]["metadata"]["visibility"]["published_cid"], + TEST_CIDV1 + ); + assert_eq!( + publish["data"]["object"]["metadata"]["visibility"]["published_link"], + format!("elastos://{TEST_CIDV1}") + ); +} + +#[tokio::test] +async fn test_library_provider_downloads_directory_archive() { + use std::io::Read as _; + + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let nested_uri = format!("{documents_uri}/Nested"); + + let (mkdir_status, mkdir) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + assert!(mkdir["data"]["object"]["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "download")); + + let (root_stat_status, root_stat) = post_library( + app.clone(), + &token, + "stat", + json!({ + "uri": root, + }), + ) + .await; + assert_eq!(root_stat_status, StatusCode::OK); + assert!(!root_stat["data"]["object"]["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "download")); + + let (nested_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Nested", + }), + ) + .await; + assert_eq!(nested_status, StatusCode::OK); + + for (uri, bytes) in [ + ( + format!("{documents_uri}/notes.txt"), + b"folder archive".as_slice(), + ), + ( + format!("{nested_uri}/deep.txt"), + b"nested archive".as_slice(), + ), + ] { + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "mime": "text/plain", + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + } + + let (download_status, download) = post_library( + app, + &token, + "download", + json!({ + "uri": documents_uri, + }), + ) + .await; + assert_eq!(download_status, StatusCode::OK); + assert_eq!(download["data"]["filename"], "Documents.tar.gz"); + assert_eq!(download["data"]["object"]["mime"], "application/gzip"); + let archive_bytes = base64::engine::general_purpose::STANDARD + .decode(download["data"]["data"].as_str().unwrap()) + .unwrap(); + let decoder = flate2::read::GzDecoder::new(archive_bytes.as_slice()); + let mut archive = tar::Archive::new(decoder); + let mut files = std::collections::BTreeMap::new(); + for entry in archive.entries().unwrap() { + let mut entry = entry.unwrap(); + if !entry.header().entry_type().is_file() { + continue; + } + let path = entry.path().unwrap().to_string_lossy().to_string(); + let mut body = String::new(); + entry.read_to_string(&mut body).unwrap(); + files.insert(path, body); + } + assert_eq!( + files.get("Documents/notes.txt").map(String::as_str), + Some("folder archive") + ); + assert_eq!( + files.get("Documents/Nested/deep.txt").map(String::as_str), + Some("nested archive") + ); +} + +#[tokio::test] +async fn test_library_download_route_archives_selected_objects() { + use std::io::Read as _; + + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let nested_uri = format!("{documents_uri}/Nested"); + let alpha_uri = format!("{documents_uri}/alpha.txt"); + let deep_uri = format!("{nested_uri}/deep.txt"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + let (nested_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Nested", + }), + ) + .await; + assert_eq!(nested_status, StatusCode::OK); + + for (uri, bytes) in [ + (alpha_uri.clone(), b"selected alpha".as_slice()), + (deep_uri, b"selected nested".as_slice()), + ] { + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "mime": "text/plain", + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + } + + let selected = vec![alpha_uri, nested_uri]; + let (download_status, headers, archive_bytes) = + get_library_download_many(app, &token, &selected).await; + assert_eq!(download_status, StatusCode::OK); + assert_eq!( + headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/gzip"), + ); + assert_eq!( + headers + .get(axum::http::header::CONTENT_DISPOSITION) + .and_then(|value| value.to_str().ok()), + Some("attachment; filename=\"Documents Selection.tar.gz\""), + ); + let receipt = transfer_receipt(&headers); + assert_eq!(receipt["schema"], "elastos.object.transfer.receipt/v1"); + assert_eq!(receipt["op"], "download"); + assert_eq!(receipt["status"], "completed"); + assert_eq!(receipt["uri"], "selection:2"); + + let decoder = flate2::read::GzDecoder::new(archive_bytes.as_slice()); + let mut archive = tar::Archive::new(decoder); + let mut files = std::collections::BTreeMap::new(); + for entry in archive.entries().unwrap() { + let mut entry = entry.unwrap(); + if !entry.header().entry_type().is_file() { + continue; + } + let path = entry.path().unwrap().to_string_lossy().to_string(); + let mut body = String::new(); + entry.read_to_string(&mut body).unwrap(); + files.insert(path, body); + } + assert_eq!( + files.get("alpha.txt").map(String::as_str), + Some("selected alpha") + ); + assert_eq!( + files.get("Nested/deep.txt").map(String::as_str), + Some("selected nested") + ); +} + +#[tokio::test] +async fn test_library_download_route_archives_directory_as_zip() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let nested_uri = format!("{documents_uri}/Nested"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + let (nested_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Nested", + }), + ) + .await; + assert_eq!(nested_status, StatusCode::OK); + + for (uri, bytes) in [ + ( + format!("{documents_uri}/alpha.txt"), + b"zip alpha".as_slice(), + ), + (format!("{nested_uri}/deep.txt"), b"zip nested".as_slice()), + ] { + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "mime": "text/plain", + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + } + + let (download_status, headers, archive_bytes) = + get_library_download_many_with_archive(app, &token, &[documents_uri], Some("zip")).await; + assert_eq!(download_status, StatusCode::OK); + assert_eq!( + headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/zip"), + ); + assert_eq!( + headers + .get(axum::http::header::CONTENT_DISPOSITION) + .and_then(|value| value.to_str().ok()), + Some("attachment; filename=\"Documents.zip\""), + ); + let files = zip_text_files(&archive_bytes); + assert_eq!( + files.get("Documents/alpha.txt").map(String::as_str), + Some("zip alpha") + ); + assert_eq!( + files.get("Documents/Nested/deep.txt").map(String::as_str), + Some("zip nested") + ); +} + +#[tokio::test] +async fn test_library_download_route_archives_selected_objects_as_zip() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let nested_uri = format!("{documents_uri}/Nested"); + let alpha_uri = format!("{documents_uri}/alpha.txt"); + let deep_uri = format!("{nested_uri}/deep.txt"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + let (nested_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Nested", + }), + ) + .await; + assert_eq!(nested_status, StatusCode::OK); + + for (uri, bytes) in [ + (alpha_uri.clone(), b"selected zip alpha".as_slice()), + (deep_uri, b"selected zip nested".as_slice()), + ] { + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "mime": "text/plain", + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + } + + let selected = vec![alpha_uri, nested_uri]; + let (download_status, headers, archive_bytes) = + get_library_download_many_with_archive(app, &token, &selected, Some("zip")).await; + assert_eq!(download_status, StatusCode::OK); + assert_eq!( + headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/zip"), + ); + assert_eq!( + headers + .get(axum::http::header::CONTENT_DISPOSITION) + .and_then(|value| value.to_str().ok()), + Some("attachment; filename=\"Documents Selection.zip\""), + ); + let receipt = transfer_receipt(&headers); + assert_eq!(receipt["schema"], "elastos.object.transfer.receipt/v1"); + assert_eq!(receipt["op"], "download"); + assert_eq!(receipt["status"], "completed"); + assert_eq!(receipt["uri"], "selection:2"); + + let files = zip_text_files(&archive_bytes); + assert_eq!( + files.get("alpha.txt").map(String::as_str), + Some("selected zip alpha") + ); + assert_eq!( + files.get("Nested/deep.txt").map(String::as_str), + Some("selected zip nested") + ); +} + +#[tokio::test] +async fn test_library_download_route_rejects_unknown_archive_format() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + + let (download_status, _headers, body) = + get_library_download_many_with_archive(app, &token, &[documents_uri], Some("rar")).await; + assert_eq!(download_status, StatusCode::BAD_REQUEST); + assert!(String::from_utf8(body) + .unwrap() + .contains("unsupported Library archive format: rar")); +} + +#[tokio::test] +async fn test_library_provider_marks_generic_archive_families_policy_gated() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let archive_token = app_token_for_authority(dir.path(), "archive-manager", &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/Bundle.7z"); + let encoded_uri = uri.replace(':', "%3A").replace('/', "%2F"); + write_test_static_capsule( + dir.path(), + "archive-manager", + "viewer", + "Test Archive viewer", + "Archive", + ); + write_test_static_capsule( + dir.path(), + "documents", + "viewer", + "Test Documents viewer", + "Documents", + ); + + let (write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"not a real 7z archive"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + + let (stat_status, stat) = post_library( + app.clone(), + &token, + "stat", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(stat_status, StatusCode::OK); + let object = &stat["data"]["object"]; + assert_eq!( + object["metadata"]["archive_support"]["schema"], + "elastos.library.archive-support/v1" + ); + assert_eq!(object["metadata"]["archive_support"]["family"], "7z"); + assert_eq!( + object["metadata"]["archive_support"]["status"], + "policy_gated_unsupported_archive_family" + ); + assert!(!object["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "extract_archive")); + assert_eq!(object["viewer"], "archive-manager"); + assert_eq!(object["viewers"][0]["id"], "archive-manager"); + + let direct_read = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/read") + .header("x-elastos-home-token", archive_token.clone()) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "uri": uri, + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(direct_read.status(), StatusCode::FORBIDDEN); + + let viewer_stat = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/api/viewers/archive-manager/library-object?uri={encoded_uri}&stat_only=true" + )) + .header("x-elastos-home-token", archive_token) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(viewer_stat.status(), StatusCode::OK); + let viewer_body = axum::body::to_bytes(viewer_stat.into_body(), usize::MAX) + .await + .unwrap(); + let viewer_stat: serde_json::Value = serde_json::from_slice(&viewer_body).unwrap(); + assert_eq!( + viewer_stat["data"]["object"]["metadata"]["archive_support"]["status"], + "policy_gated_unsupported_archive_family" + ); + assert!(viewer_stat["data"].get("data").is_none()); + + let viewer_read = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/api/viewers/archive-manager/library-object?uri={encoded_uri}" + )) + .header( + "x-elastos-home-token", + app_token_for_authority(dir.path(), "archive-manager", &authority), + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(viewer_read.status(), StatusCode::FORBIDDEN); + + let (extract_status, extract) = post_library( + app.clone(), + &token, + "extract_archive", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(extract_status, StatusCode::OK); + assert_eq!(extract["status"], "error"); + assert!(extract["message"] + .as_str() + .unwrap() + .contains("only supports .tar, .tar.gz, .tgz, and .zip")); + + let (entries_status, entries) = post_library( + app, + &token, + "archive_entries", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(entries_status, StatusCode::OK); + assert_eq!(entries["status"], "error"); + assert!(entries["message"] + .as_str() + .unwrap() + .contains("archive listing only supports .tar, .tar.gz, .tgz, and .zip")); +} + +#[tokio::test] +async fn test_library_provider_compresses_folder_to_zip_object() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let projects_uri = format!("{documents_uri}/Projects"); + let nested_uri = format!("{projects_uri}/Nested"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + let (projects_status, projects) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Projects", + }), + ) + .await; + assert_eq!(projects_status, StatusCode::OK); + assert!(projects["data"]["object"]["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "compress_archive")); + let (nested_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": projects_uri, + "name": "Nested", + }), + ) + .await; + assert_eq!(nested_status, StatusCode::OK); + + for (uri, bytes) in [ + (format!("{projects_uri}/alpha.txt"), b"zip alpha".as_slice()), + (format!("{nested_uri}/deep.txt"), b"zip nested".as_slice()), + ] { + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "mime": "text/plain", + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + assert!(write["data"]["object"]["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "compress_archive")); + } + + let (compress_status, compressed) = post_library( + app.clone(), + &token, + "compress_archive", + json!({ + "uri": projects_uri, + }), + ) + .await; + assert_eq!(compress_status, StatusCode::OK); + assert_eq!(compressed["status"], "ok"); + assert_eq!( + compressed["data"]["object"]["uri"], + format!("{documents_uri}/Projects.zip") + ); + assert_eq!(compressed["data"]["object"]["mime"], "application/zip"); + assert!(compressed["data"]["object"]["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "extract_archive")); + + let (compress_again_status, compressed_again) = post_library( + app.clone(), + &token, + "compress_archive", + json!({ + "uri": projects_uri, + }), + ) + .await; + assert_eq!(compress_again_status, StatusCode::OK); + let second_zip_uri = compressed_again["data"]["object"]["uri"].as_str().unwrap(); + assert_ne!(second_zip_uri, format!("{documents_uri}/Projects.zip")); + assert!(second_zip_uri.starts_with(&format!("{documents_uri}/Projects ("))); + assert!(second_zip_uri.ends_with(").zip")); + + let zip_uri = compressed["data"]["object"]["uri"].as_str().unwrap(); + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": zip_uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let archive_bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + let files = zip_text_files(&archive_bytes); + assert_eq!( + files.get("Projects/alpha.txt").map(String::as_str), + Some("zip alpha") + ); + assert_eq!( + files.get("Projects/Nested/deep.txt").map(String::as_str), + Some("zip nested") + ); + + let (events_status, events) = post_library( + app, + &token, + "events", + json!({ + "uri": zip_uri, + }), + ) + .await; + assert_eq!(events_status, StatusCode::OK); + assert!(events["data"]["events"] + .as_array() + .unwrap() + .iter() + .any(|event| event["op"] == "compress_archive")); +} + +#[tokio::test] +async fn test_library_provider_stores_incompressible_zip_entries() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let video_uri = format!("{documents_uri}/Screen Recording.mp4"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + let video_bytes = vec![0x5a; 2048]; + let (write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": video_uri, + "mime": "video/mp4", + "data": base64::engine::general_purpose::STANDARD.encode(&video_bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + let (compress_status, compressed) = post_library( + app.clone(), + &token, + "compress_archive", + json!({ + "uri": video_uri, + }), + ) + .await; + assert_eq!(compress_status, StatusCode::OK); + let zip_uri = compressed["data"]["object"]["uri"].as_str().unwrap(); + let (read_status, read) = post_library( + app, + &token, + "read", + json!({ + "uri": zip_uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let archive_bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(archive_bytes)).unwrap(); + let mut entry = archive.by_name("Screen Recording.mp4").unwrap(); + assert_eq!(entry.compression(), zip::CompressionMethod::Stored); + let mut roundtrip = Vec::new(); + std::io::Read::read_to_end(&mut entry, &mut roundtrip).unwrap(); + assert_eq!(roundtrip, video_bytes); +} + +#[tokio::test] +async fn test_library_provider_compresses_selected_objects_to_zip_object() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let nested_uri = format!("{documents_uri}/Nested"); + let alpha_uri = format!("{documents_uri}/alpha.txt"); + let deep_uri = format!("{nested_uri}/deep.txt"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + let (nested_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Nested", + }), + ) + .await; + assert_eq!(nested_status, StatusCode::OK); + + for (uri, bytes) in [ + (alpha_uri.clone(), b"selected zip alpha".as_slice()), + (deep_uri, b"selected zip nested".as_slice()), + ] { + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "mime": "text/plain", + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + } + + let (compress_status, compressed) = post_library( + app.clone(), + &token, + "compress_archive", + json!({ + "uris": [alpha_uri, nested_uri], + }), + ) + .await; + assert_eq!(compress_status, StatusCode::OK); + assert_eq!(compressed["status"], "ok"); + assert_eq!( + compressed["data"]["object"]["uri"], + format!("{documents_uri}/Documents Selection.zip") + ); + assert_eq!(compressed["data"]["object"]["mime"], "application/zip"); + + let zip_uri = compressed["data"]["object"]["uri"].as_str().unwrap(); + let (read_status, read) = post_library( + app, + &token, + "read", + json!({ + "uri": zip_uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let archive_bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + let files = zip_text_files(&archive_bytes); + assert_eq!( + files.get("alpha.txt").map(String::as_str), + Some("selected zip alpha") + ); + assert_eq!( + files.get("Nested/deep.txt").map(String::as_str), + Some("selected zip nested") + ); +} + +#[tokio::test] +async fn test_library_provider_extracts_tar_gz_archive() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let archive_uri = format!("{documents_uri}/Bundle.tar.gz"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + + let encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + let mut builder = tar::Builder::new(encoder); + let mut alpha = b"extracted alpha".as_slice(); + let mut alpha_header = tar::Header::new_gnu(); + alpha_header.set_size(alpha.len() as u64); + alpha_header.set_mode(0o644); + alpha_header.set_cksum(); + builder + .append_data(&mut alpha_header, "alpha.txt", &mut alpha) + .unwrap(); + let mut deep = b"extracted nested".as_slice(); + let mut deep_header = tar::Header::new_gnu(); + deep_header.set_size(deep.len() as u64); + deep_header.set_mode(0o644); + deep_header.set_cksum(); + builder + .append_data(&mut deep_header, "Nested/deep.txt", &mut deep) + .unwrap(); + let archive_bytes = builder.into_inner().unwrap().finish().unwrap(); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": archive_uri, + "mime": "application/gzip", + "data": base64::engine::general_purpose::STANDARD.encode(archive_bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["data"]["object"]["mime"], "application/gzip"); + assert!(write["data"]["object"]["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "extract_archive")); + + let (extract_status, extract) = post_library( + app.clone(), + &token, + "extract_archive", + json!({ + "uri": archive_uri, + }), + ) + .await; + assert_eq!(extract_status, StatusCode::OK); + let extracted_uri = extract["data"]["object"]["uri"].as_str().unwrap(); + assert_eq!(extracted_uri, format!("{documents_uri}/Bundle")); + + for (path, expected) in [ + ("alpha.txt", "extracted alpha"), + ("Nested/deep.txt", "extracted nested"), + ] { + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": format!("{extracted_uri}/{path}"), + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + let body = String::from_utf8(bytes).unwrap(); + assert_eq!(body, expected); + } +} + +#[tokio::test] +async fn test_library_provider_extracts_plain_tar_archive() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let archive_uri = format!("{documents_uri}/Bundle.tar"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + + let mut builder = tar::Builder::new(Vec::new()); + let mut alpha = b"plain tar alpha".as_slice(); + let mut alpha_header = tar::Header::new_gnu(); + alpha_header.set_size(alpha.len() as u64); + alpha_header.set_mode(0o644); + alpha_header.set_cksum(); + builder + .append_data(&mut alpha_header, "alpha.txt", &mut alpha) + .unwrap(); + let archive_bytes = builder.into_inner().unwrap(); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": archive_uri, + "mime": "application/x-tar", + "data": base64::engine::general_purpose::STANDARD.encode(archive_bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["data"]["object"]["mime"], "application/x-tar"); + assert!(write["data"]["object"]["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "extract_archive")); + + let (extract_status, extract) = post_library( + app.clone(), + &token, + "extract_archive", + json!({ + "uri": archive_uri, + }), + ) + .await; + assert_eq!(extract_status, StatusCode::OK); + let extracted_uri = extract["data"]["object"]["uri"].as_str().unwrap(); + assert_eq!(extracted_uri, format!("{documents_uri}/Bundle")); + + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": format!("{extracted_uri}/alpha.txt"), + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + let body = String::from_utf8(bytes).unwrap(); + assert_eq!(body, "plain tar alpha"); +} + +#[tokio::test] +async fn test_library_gateway_lists_webspaces_through_runtime_provider() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + + let (root_status, root) = post_library( + app.clone(), + &token, + "list", + json!({ + "uri": "localhost://WebSpaces", + }), + ) + .await; + assert_eq!(root_status, StatusCode::OK); + assert_eq!(root["status"], "ok"); + assert_eq!(root["data"]["uri"], "localhost://WebSpaces"); + let root_objects = root["data"]["objects"].as_array().unwrap(); + let localhost_root = crate::auth::principal_localhost_root(&authority.principal_id); + assert!(root_objects.iter().any(|object| { + object["uri"] == localhost_root + && object["name"] == "Localhost" + && object["kind"] == "directory" + && object["availability"] == "local-principal" + && object["metadata"]["schema"] == "elastos.library.space-pointer/v1" + && object["metadata"]["space"] == "localhost" + && object["metadata"]["target_uri"] == localhost_root + && object["metadata"]["provider"] == "object-provider" + && object["metadata"]["authority"] == "signed-principal-root" + && object["metadata"]["writable"] == true + && object["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "list") + })); + assert!(root_objects.iter().any(|object| { + object["uri"] == "localhost://WebSpaces/Elastos" + && object["kind"] == "directory" + && object["availability"] == "resolver-owned" + && object["metadata"]["schema"] == "elastos.library.webspace-object/v1" + && object["metadata"]["mount"] == "Elastos" + && object["metadata"]["resolver"] == "builtin" + && object["metadata"]["cache_policy"] == "metadata-only" + && object["metadata"]["sync_policy"] == "manual" + && object["metadata"]["object_id"] == "object:webspace:elastos" + && object["metadata"]["head_id"] == "head:webspace:elastos" + && object["metadata"]["cache_state"] == "metadata_cached" + && object["metadata"]["sync_state"] == "manual_idle" + && object["metadata"]["webspace_kind"] == "dynamic-webspace" + && object["metadata"]["readonly"] == true + && object["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "list") + })); + let google = root_objects + .iter() + .find(|object| object["uri"] == "localhost://WebSpaces/Google") + .expect("indexed Google WebSpace mount should be listed"); + assert_eq!(google["kind"], "directory"); + assert_eq!(google["metadata"]["target_uri"], "google://drive"); + assert_eq!(google["metadata"]["resolver"], "google-drive"); + assert_eq!( + google["metadata"]["cache_policy"], + "metadata-and-thumbnails" + ); + assert_eq!(google["metadata"]["webspace_kind"], "mounted-webspace"); + + let (elastos_status, elastos) = post_library( + app.clone(), + &token, + "list", + json!({ + "uri": "localhost://WebSpaces/Elastos", + }), + ) + .await; + assert_eq!(elastos_status, StatusCode::OK); + assert_eq!(elastos["status"], "ok"); + let names: Vec<&str> = elastos["data"]["objects"] + .as_array() + .unwrap() + .iter() + .filter_map(|object| object["name"].as_str()) + .collect(); + for expected in ["_meta.json", "content", "peer", "did", "ai"] { + assert!( + names.contains(&expected), + "missing WebSpace child {expected}" + ); + } + let content = elastos["data"]["objects"] + .as_array() + .unwrap() + .iter() + .find(|object| object["name"] == "content") + .expect("content WebSpace child should be listed"); + assert_eq!(content["metadata"]["target_uri"], "elastos://"); + assert_eq!(content["metadata"]["resolver"], "builtin"); + assert_eq!(content["metadata"]["object_id"], "object:webspace:content"); + assert_eq!(content["metadata"]["head_id"], "head:webspace:content"); + assert_eq!(content["metadata"]["cache_state"], "metadata_cached"); + assert_eq!(content["metadata"]["sync_state"], "manual_idle"); + assert_eq!(content["metadata"]["webspace_kind"], "folder-handle"); + + let (google_status, google_list) = post_library( + app.clone(), + &token, + "list", + json!({ + "uri": "localhost://WebSpaces/Google", + }), + ) + .await; + assert_eq!(google_status, StatusCode::OK); + let google_names: Vec<&str> = google_list["data"]["objects"] + .as_array() + .unwrap() + .iter() + .filter_map(|object| object["name"].as_str()) + .collect(); + for expected in ["_meta.json", "Drive", "Shared"] { + assert!( + google_names.contains(&expected), + "missing indexed Google child {expected}" + ); + } + + let (project_status, project) = post_library( + app, + &token, + "list", + json!({ + "uri": "localhost://WebSpaces/Google/Drive/Project X", + }), + ) + .await; + assert_eq!(project_status, StatusCode::OK); + let file = project["data"]["objects"] + .as_array() + .unwrap() + .iter() + .find(|object| object["name"] == "file.pdf") + .expect("indexed Google file should be listed"); + assert_eq!( + file["metadata"]["target_uri"], + "google://drive/Drive/Project X/file.pdf" + ); + assert_eq!(file["metadata"]["resolver"], "google-drive"); + assert_eq!(file["metadata"]["webspace_kind"], "indexed-file"); + assert_eq!(file["availability"], "resolver-owned"); +} + +#[tokio::test] +async fn test_library_provider_extracts_zip_archive() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let zip_uri = format!("{documents_uri}/Bundle.zip"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + + let archive_bytes = { + use std::io::Write as _; + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + writer.start_file("alpha.txt", options).unwrap(); + writer.write_all(b"zip alpha").unwrap(); + writer.add_directory("Nested/", options).unwrap(); + writer.start_file("Nested/deep.txt", options).unwrap(); + writer.write_all(b"zip nested").unwrap(); + writer.finish().unwrap().into_inner() + }; + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": zip_uri, + "mime": "application/zip", + "data": base64::engine::general_purpose::STANDARD.encode(archive_bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + assert_eq!(write["data"]["object"]["mime"], "application/zip"); + assert!(write["data"]["object"]["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "extract_archive")); + + let (extract_status, extracted) = post_library( + app.clone(), + &token, + "extract_archive", + json!({ + "uri": zip_uri, + }), + ) + .await; + assert_eq!(extract_status, StatusCode::OK); + assert_eq!(extracted["status"], "ok"); + let extracted_uri = extracted["data"]["object"]["uri"].as_str().unwrap(); + assert_eq!(extracted_uri, format!("{documents_uri}/Bundle")); + + for (path, expected) in [ + ("alpha.txt", "zip alpha"), + ("Nested/deep.txt", "zip nested"), + ] { + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": format!("{extracted_uri}/{path}"), + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(String::from_utf8(bytes).unwrap(), expected); + } +} + +#[tokio::test] +async fn test_library_provider_lists_supported_archive_entries_through_viewer_route() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let archive_token = app_token_for_authority(dir.path(), "archive-manager", &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let zip_uri = format!("{documents_uri}/Bundle.zip"); + let tar_uri = format!("{documents_uri}/Bundle.tar"); + let encoded_zip_uri = zip_uri.replace(':', "%3A").replace('/', "%2F"); + write_test_static_capsule( + dir.path(), + "archive-manager", + "viewer", + "Test Archive viewer", + "Archive", + ); + write_test_static_capsule( + dir.path(), + "documents", + "viewer", + "Test Documents viewer", + "Documents", + ); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + + let zip_bytes = { + use std::io::Write as _; + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + writer.start_file("alpha.txt", options).unwrap(); + writer.write_all(b"zip alpha").unwrap(); + writer.add_directory("Nested/", options).unwrap(); + writer.start_file("Nested/deep.txt", options).unwrap(); + writer.write_all(b"zip nested").unwrap(); + writer.finish().unwrap().into_inner() + }; + let (zip_write_status, zip_write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": zip_uri, + "mime": "application/zip", + "data": base64::engine::general_purpose::STANDARD.encode(zip_bytes), + }), + ) + .await; + assert_eq!(zip_write_status, StatusCode::OK); + assert_eq!(zip_write["data"]["object"]["viewer"], "archive-manager"); + + let (entries_status, entries) = post_library( + app.clone(), + &token, + "archive_entries", + json!({ + "uri": zip_uri, + }), + ) + .await; + assert_eq!(entries_status, StatusCode::OK); + assert_eq!(entries["status"], "ok"); + assert_eq!( + entries["data"]["schema"], + "elastos.library.archive-entries/v1" + ); + assert_eq!(entries["data"]["family"], "zip"); + assert_eq!(entries["data"]["limits"]["truncated"], false); + let entry_rows = entries["data"]["entries"].as_array().unwrap(); + assert!(entry_rows.iter().any(|entry| { + entry["path"] == "alpha.txt" + && entry["kind"] == "file" + && entry["safety"]["status"] == "safe" + && entry["size"] == 9 + && entry["compressed_size"].as_u64().is_some() + })); + assert!(entry_rows + .iter() + .any(|entry| { entry["path"] == "Nested" && entry["kind"] == "directory" })); + assert!(entry_rows.iter().any(|entry| { + entry["path"] == "Nested/deep.txt" && entry["safety"]["status"] == "safe" + })); + + let direct_entries = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/archive_entries") + .header("x-elastos-home-token", archive_token.clone()) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "uri": zip_uri, + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(direct_entries.status(), StatusCode::FORBIDDEN); + + let direct_preview = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/archive_preview_entry") + .header("x-elastos-home-token", archive_token.clone()) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "uri": zip_uri, + "entry": "Nested/deep.txt", + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(direct_preview.status(), StatusCode::FORBIDDEN); + + let direct_roots = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/roots") + .header("x-elastos-home-token", archive_token.clone()) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(direct_roots.status(), StatusCode::FORBIDDEN); + + let viewer_entries = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/api/viewers/archive-manager/library-object?uri={encoded_zip_uri}&entries=true" + )) + .header("x-elastos-home-token", archive_token.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let viewer_status = viewer_entries.status(); + let viewer_body = axum::body::to_bytes(viewer_entries.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!( + viewer_status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&viewer_body) + ); + let viewer_entries: serde_json::Value = serde_json::from_slice(&viewer_body).unwrap(); + assert_eq!( + viewer_entries["data"]["schema"], + "elastos.library.archive-entries/v1" + ); + assert!(viewer_entries["data"]["entries"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["path"] == "Nested/deep.txt")); + + let viewer_preview = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/api/viewers/archive-manager/library-object?uri={encoded_zip_uri}&preview_entry=Nested%2Fdeep.txt" + )) + .header("x-elastos-home-token", archive_token.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let viewer_preview_status = viewer_preview.status(); + let viewer_preview_body = axum::body::to_bytes(viewer_preview.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!( + viewer_preview_status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&viewer_preview_body) + ); + let viewer_preview: serde_json::Value = serde_json::from_slice(&viewer_preview_body).unwrap(); + assert_eq!( + viewer_preview["data"]["schema"], + "elastos.library.archive-preview-entry/v1" + ); + assert_eq!(viewer_preview["data"]["entry"]["path"], "Nested/deep.txt"); + assert_eq!(viewer_preview["data"]["entry"]["mime"], "text/plain"); + assert_eq!( + viewer_preview["data"]["entry"]["viewers"][0]["id"], + "documents" + ); + assert_eq!(viewer_preview["data"]["preview"]["text"], "zip nested"); + assert_eq!(viewer_preview["data"]["preview"]["truncated"], false); + + let viewer_roots = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/viewers/archive-manager/library-roots") + .header("x-elastos-home-token", archive_token) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let viewer_roots_status = viewer_roots.status(); + let viewer_roots_body = axum::body::to_bytes(viewer_roots.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!( + viewer_roots_status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&viewer_roots_body) + ); + let viewer_roots: serde_json::Value = serde_json::from_slice(&viewer_roots_body).unwrap(); + assert!(viewer_roots["data"]["roots"] + .as_array() + .unwrap() + .iter() + .any(|root| root["label"] == "Documents" && root["uri"] == documents_uri)); + + let tar_bytes = { + let mut builder = tar::Builder::new(Vec::new()); + let mut alpha = b"tar alpha".as_slice(); + let mut alpha_header = tar::Header::new_gnu(); + alpha_header.set_size(alpha.len() as u64); + alpha_header.set_mode(0o644); + alpha_header.set_mtime(1_780_000_000); + alpha_header.set_cksum(); + builder + .append_data(&mut alpha_header, "alpha.txt", &mut alpha) + .unwrap(); + builder.into_inner().unwrap() + }; + let (tar_write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": tar_uri, + "mime": "application/x-tar", + "data": base64::engine::general_purpose::STANDARD.encode(tar_bytes), + }), + ) + .await; + assert_eq!(tar_write_status, StatusCode::OK); + let (tar_entries_status, tar_entries) = post_library( + app, + &token, + "archive_entries", + json!({ + "uri": tar_uri, + }), + ) + .await; + assert_eq!(tar_entries_status, StatusCode::OK); + assert_eq!(tar_entries["data"]["family"], "tar"); + assert!(tar_entries["data"]["entries"] + .as_array() + .unwrap() + .iter() + .any(|entry| { + entry["path"] == "alpha.txt" + && entry["size"] == 9 + && entry["modified_at"] == 1_780_000_000u64 + && entry["safety"]["status"] == "safe" + })); +} + +#[tokio::test] +async fn test_library_provider_lists_unsafe_archive_entries_as_blocked() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let zip_uri = format!("{documents_uri}/Unsafe.zip"); + let tar_uri = format!("{documents_uri}/Unsafe.tar"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + + let zip_bytes = { + use std::io::Write as _; + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + writer.start_file("../escape.txt", options).unwrap(); + writer.write_all(b"escape").unwrap(); + writer.start_file("/absolute.txt", options).unwrap(); + writer.write_all(b"absolute").unwrap(); + writer.start_file("safe.txt", options).unwrap(); + writer.write_all(b"safe").unwrap(); + writer.finish().unwrap().into_inner() + }; + let (zip_write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": zip_uri, + "mime": "application/zip", + "data": base64::engine::general_purpose::STANDARD.encode(zip_bytes), + }), + ) + .await; + assert_eq!(zip_write_status, StatusCode::OK); + let (zip_entries_status, zip_entries) = post_library( + app.clone(), + &token, + "archive_entries", + json!({ + "uri": zip_uri, + }), + ) + .await; + assert_eq!(zip_entries_status, StatusCode::OK); + assert_eq!(zip_entries["status"], "ok"); + let zip_rows = zip_entries["data"]["entries"].as_array().unwrap(); + assert!(zip_rows.iter().any(|entry| { + entry["path"] == "../escape.txt" + && entry["kind"] == "blocked" + && entry["safety"]["status"] == "blocked" + && entry["safety"]["reason"] + .as_str() + .unwrap() + .contains("relative and safe") + })); + assert!(zip_rows + .iter() + .any(|entry| entry["path"] == "safe.txt" && entry["safety"]["status"] == "safe")); + assert!(zip_rows.iter().any(|entry| { + entry["path"] == "/absolute.txt" + && entry["kind"] == "blocked" + && entry["safety"]["reason"] + .as_str() + .unwrap() + .contains("relative and safe") + })); + + let tar_bytes = { + let mut builder = tar::Builder::new(Vec::new()); + let mut alpha = b"tar safe".as_slice(); + let mut alpha_header = tar::Header::new_gnu(); + alpha_header.set_size(alpha.len() as u64); + alpha_header.set_mode(0o644); + alpha_header.set_cksum(); + builder + .append_data(&mut alpha_header, "safe.txt", &mut alpha) + .unwrap(); + let mut link_header = tar::Header::new_gnu(); + link_header.set_entry_type(tar::EntryType::Symlink); + link_header.set_size(0); + link_header.set_mode(0o644); + link_header.set_cksum(); + builder + .append_link(&mut link_header, "link.txt", "safe.txt") + .unwrap(); + builder.into_inner().unwrap() + }; + let (tar_write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": tar_uri, + "mime": "application/x-tar", + "data": base64::engine::general_purpose::STANDARD.encode(tar_bytes), + }), + ) + .await; + assert_eq!(tar_write_status, StatusCode::OK); + let (tar_entries_status, tar_entries) = post_library( + app, + &token, + "archive_entries", + json!({ + "uri": tar_uri, + }), + ) + .await; + assert_eq!(tar_entries_status, StatusCode::OK); + let tar_rows = tar_entries["data"]["entries"].as_array().unwrap(); + assert!(tar_rows + .iter() + .any(|entry| entry["path"] == "safe.txt" && entry["safety"]["status"] == "safe")); + assert!(tar_rows.iter().any(|entry| { + entry["path"] == "link.txt" + && entry["kind"] == "blocked" + && entry["safety"]["reason"] + .as_str() + .unwrap() + .contains("non-file") + })); +} + +#[tokio::test] +async fn test_library_provider_selectively_extracts_archive_entries_through_viewer_route() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let archive_token = app_token_for_authority(dir.path(), "archive-manager", &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let imports_uri = format!("{documents_uri}/Imports"); + let nested_imports_uri = format!("{imports_uri}/Nested"); + let zip_uri = format!("{documents_uri}/Bundle.zip"); + let encoded_zip_uri = zip_uri.replace(':', "%3A").replace('/', "%2F"); + write_test_static_capsule( + dir.path(), + "archive-manager", + "viewer", + "Test Archive viewer", + "Archive", + ); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + let (imports_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Imports", + }), + ) + .await; + assert_eq!(imports_status, StatusCode::OK); + let (nested_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": imports_uri, + "name": "Nested", + }), + ) + .await; + assert_eq!(nested_status, StatusCode::OK); + + let zip_bytes = { + use std::io::Write as _; + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + writer.start_file("alpha.txt", options).unwrap(); + writer.write_all(b"zip alpha").unwrap(); + writer.start_file("Nested/deep.txt", options).unwrap(); + writer.write_all(b"zip nested").unwrap(); + writer.finish().unwrap().into_inner() + }; + let (zip_write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": zip_uri, + "mime": "application/zip", + "data": base64::engine::general_purpose::STANDARD.encode(zip_bytes), + }), + ) + .await; + assert_eq!(zip_write_status, StatusCode::OK); + let (existing_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": format!("{nested_imports_uri}/deep.txt"), + "mime": "text/plain", + "data": base64::engine::general_purpose::STANDARD.encode(b"existing"), + }), + ) + .await; + assert_eq!(existing_status, StatusCode::OK); + + let direct_extract = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/provider/object/archive_extract_entries") + .header("x-elastos-home-token", archive_token.clone()) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "uri": zip_uri, + "destination_uri": imports_uri, + "entries": ["Nested/deep.txt"], + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(direct_extract.status(), StatusCode::FORBIDDEN); + + let viewer_extract = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!( + "/api/viewers/archive-manager/library-object?uri={encoded_zip_uri}" + )) + .header("x-elastos-home-token", archive_token) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "destination_uri": imports_uri, + "entries": ["Nested/deep.txt"], + "conflict_policy": "replace", + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + let viewer_status = viewer_extract.status(); + let viewer_body = axum::body::to_bytes(viewer_extract.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!( + viewer_status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&viewer_body) + ); + let viewer_extract: serde_json::Value = serde_json::from_slice(&viewer_body).unwrap(); + assert_eq!(viewer_extract["status"], "ok"); + assert_eq!( + viewer_extract["data"]["schema"], + "elastos.library.archive-extract-entries/v1" + ); + assert_eq!(viewer_extract["data"]["receipt"]["status"], "completed"); + assert_eq!( + viewer_extract["data"]["receipt"]["progress"]["requested_entries"], + 1 + ); + assert_eq!( + viewer_extract["data"]["receipt"]["progress"]["written_entries"], + 1 + ); + assert_eq!( + viewer_extract["data"]["receipt"]["cancel"]["status"], + "not_requested" + ); + + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": format!("{nested_imports_uri}/deep.txt"), + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(String::from_utf8(bytes).unwrap(), "zip nested"); + + let (cancel_status, cancel) = post_library( + app.clone(), + &token, + "archive_extract_entries", + json!({ + "uri": zip_uri, + "destination_uri": imports_uri, + "entries": ["alpha.txt"], + "cancel": true, + }), + ) + .await; + assert_eq!(cancel_status, StatusCode::OK); + assert_eq!(cancel["status"], "ok"); + assert_eq!(cancel["data"]["receipt"]["status"], "cancelled"); + assert_eq!( + cancel["data"]["receipt"]["cancel"]["status"], + "cancelled_before_write" + ); + + let (list_status, list) = post_library( + app, + &token, + "list", + json!({ + "uri": imports_uri, + }), + ) + .await; + assert_eq!(list_status, StatusCode::OK); + assert!(!list["data"]["objects"] + .as_array() + .unwrap() + .iter() + .any(|object| object["name"] == "alpha.txt")); +} + +#[tokio::test] +async fn test_library_provider_selective_extract_blocks_unsafe_entries() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let imports_uri = format!("{documents_uri}/Imports"); + let tar_uri = format!("{documents_uri}/Unsafe.tar"); + let zip_uri = format!("{documents_uri}/Unsafe.zip"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + let (imports_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Imports", + }), + ) + .await; + assert_eq!(imports_status, StatusCode::OK); + + let tar_bytes = { + let mut builder = tar::Builder::new(Vec::new()); + let mut link_header = tar::Header::new_gnu(); + link_header.set_entry_type(tar::EntryType::Symlink); + link_header.set_size(0); + link_header.set_mode(0o644); + link_header.set_cksum(); + builder + .append_link(&mut link_header, "link.txt", "safe.txt") + .unwrap(); + builder.into_inner().unwrap() + }; + let (tar_write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": tar_uri, + "mime": "application/x-tar", + "data": base64::engine::general_purpose::STANDARD.encode(tar_bytes), + }), + ) + .await; + assert_eq!(tar_write_status, StatusCode::OK); + let (extract_status, extract) = post_library( + app.clone(), + &token, + "archive_extract_entries", + json!({ + "uri": tar_uri, + "destination_uri": imports_uri, + "entries": ["link.txt"], + }), + ) + .await; + assert_eq!(extract_status, StatusCode::OK); + assert_eq!(extract["status"], "ok"); + assert_eq!( + extract["data"]["receipt"]["status"], + "completed_with_blocked_entries" + ); + assert_eq!(extract["data"]["receipt"]["progress"]["blocked_entries"], 1); + assert!(extract["data"]["blocked"][0]["reason"] + .as_str() + .unwrap() + .contains("non-file")); + + let zip_bytes = { + use std::io::Write as _; + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + writer.start_file("../escape.txt", options).unwrap(); + writer.write_all(b"escape").unwrap(); + writer.finish().unwrap().into_inner() + }; + let (zip_write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": zip_uri, + "mime": "application/zip", + "data": base64::engine::general_purpose::STANDARD.encode(zip_bytes), + }), + ) + .await; + assert_eq!(zip_write_status, StatusCode::OK); + let (unsafe_select_status, unsafe_select) = post_library( + app, + &token, + "archive_extract_entries", + json!({ + "uri": zip_uri, + "destination_uri": imports_uri, + "entries": ["../escape.txt"], + }), + ) + .await; + assert_eq!(unsafe_select_status, StatusCode::OK); + assert_eq!(unsafe_select["status"], "error"); + assert!(unsafe_select["message"] + .as_str() + .unwrap() + .contains("relative and safe")); +} + +#[tokio::test] +async fn test_library_provider_rejects_unsafe_zip_entries() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let zip_uri = format!("{documents_uri}/Unsafe.zip"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + + let archive_bytes = { + use std::io::Write as _; + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + writer.start_file("../escape.txt", options).unwrap(); + writer.write_all(b"escape").unwrap(); + writer.finish().unwrap().into_inner() + }; + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": zip_uri, + "mime": "application/zip", + "data": base64::engine::general_purpose::STANDARD.encode(archive_bytes), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + + let (extract_status, extracted) = post_library( + app, + &token, + "extract_archive", + json!({ + "uri": zip_uri, + }), + ) + .await; + assert_eq!(extract_status, StatusCode::OK); + assert_eq!(extracted["status"], "error"); + assert!(extracted["message"] + .as_str() + .unwrap() + .contains("relative and safe")); +} + +#[tokio::test] +async fn test_library_gateway_reads_webspace_files_through_runtime_provider() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + + let uri = format!("localhost://WebSpaces/Elastos/content/{TEST_CIDV1}"); + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + assert_eq!(read["status"], "ok"); + assert_eq!(read["data"]["object"]["kind"], "file"); + assert_eq!( + read["data"]["object"]["metadata"]["target_uri"], + format!("elastos://{TEST_CIDV1}") + ); + assert_eq!( + read["data"]["object"]["metadata"]["provider"], + "content-provider" + ); + assert_eq!( + read["data"]["object"]["metadata"]["webspace_kind"], + "file-endpoint" + ); + assert_eq!(read["data"]["object"]["metadata"]["resolver"], "builtin"); + assert_eq!(read["data"]["encoding"], "base64"); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["target_uri"], format!("elastos://{TEST_CIDV1}")); + + let (download_status, headers, download_bytes) = get_library_download(app, &token, &uri).await; + assert_eq!(download_status, StatusCode::OK); + assert_eq!(download_bytes, bytes); + assert_eq!( + headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/json"), + ); + let expected_disposition = format!("attachment; filename=\"{TEST_CIDV1}\""); + assert_eq!( + headers + .get(axum::http::header::CONTENT_DISPOSITION) + .and_then(|value| value.to_str().ok()), + Some(expected_disposition.as_str()), + ); +} + +#[tokio::test] +async fn test_library_gateway_reads_external_webspace_file_through_adapter_cache() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + + let uri = "localhost://WebSpaces/Google/Drive/Project X/file.pdf"; + let (read_status, read) = post_library( + app, + &token, + "read", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + assert_eq!(read["status"], "ok"); + assert_eq!(read["data"]["object"]["kind"], "file"); + assert_eq!( + read["data"]["object"]["metadata"]["resolver"], + "google-drive" + ); + assert_eq!( + read["data"]["object"]["metadata"]["target_uri"], + "google://drive/Drive/Project X/file.pdf" + ); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(bytes, b"google adapter bytes"); +} + +#[tokio::test] +async fn test_library_gateway_operator_webspace_adapter_caches_bytes_and_viewer() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + write_test_static_capsule( + dir.path(), + DOCUMENTS_CAPSULE_ID, + "viewer", + "Test Documents viewer", + "Documents Viewer", + ); + + let uri = "localhost://WebSpaces/Operator/Projects/Brief.md"; + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + assert_eq!(read["status"], "ok"); + let object = &read["data"]["object"]; + assert_eq!(object["kind"], "file"); + assert_eq!(object["mime"], "text/plain"); + assert_eq!(object["viewer"], "documents"); + assert_eq!(object["viewers"][0]["id"], "documents"); + assert_eq!(object["metadata"]["resolver"], "operator-drive"); + assert_eq!( + object["metadata"]["target_uri"], + "operator://drive/Projects/Brief.md" + ); + assert_eq!(object["metadata"]["cache_state"], "content_cached"); + assert_eq!(object["metadata"]["resolver_state"], "materialized-local"); + assert_eq!(object["metadata"]["webspace_kind"], "materialized-file"); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(bytes, b"# Operator Brief\n\nAdapter-backed bytes.\n"); + + let (stat_status, stat) = post_library( + app.clone(), + &token, + "stat", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(stat_status, StatusCode::OK); + assert_eq!(stat["status"], "ok"); + assert_eq!( + stat["data"]["object"]["metadata"]["cache_state"], + "content_cached" + ); + assert_eq!(stat["data"]["object"]["viewer"], "documents"); + + let (second_read_status, second_read) = post_library( + app, + &token, + "read", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(second_read_status, StatusCode::OK); + let second_bytes = base64::engine::general_purpose::STANDARD + .decode(second_read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(second_bytes, bytes); + assert_eq!( + second_read["data"]["object"]["metadata"]["cache_state"], + "content_cached" + ); +} + +#[tokio::test] +async fn test_library_gateway_lists_external_webspace_archive_entries_without_resolver_leak() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let archive_token = app_token_for_authority(dir.path(), "archive-manager", &authority); + write_test_static_capsule( + dir.path(), + "archive-manager", + "viewer", + "Test Archive viewer", + "Archive", + ); + + let uri = "localhost://WebSpaces/Operator/Projects/Bundle.zip"; + let encoded_uri = uri.replace(':', "%3A").replace('/', "%2F"); + let viewer_entries = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/api/viewers/archive-manager/library-object?uri={encoded_uri}&entries=true" + )) + .header("x-elastos-home-token", archive_token.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let viewer_entries_status = viewer_entries.status(); + let viewer_body = axum::body::to_bytes(viewer_entries.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!( + viewer_entries_status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&viewer_body) + ); + let viewer_entries: serde_json::Value = serde_json::from_slice(&viewer_body).unwrap(); + assert_eq!(viewer_entries["status"], "ok"); + assert_eq!( + viewer_entries["data"]["schema"], + "elastos.library.archive-entries/v1" + ); + assert_eq!(viewer_entries["data"]["family"], "zip"); + assert_eq!( + viewer_entries["data"]["object"]["metadata"]["resolver_target_redacted"], + true + ); + assert_eq!( + viewer_entries["data"]["object"]["metadata"]["target_uri"], + serde_json::Value::Null + ); + assert!(viewer_entries["data"]["entries"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["path"] == "Nested/deep.txt" && entry["safety"]["status"] == "safe")); + assert!(!serde_json::to_string(&viewer_entries) + .unwrap() + .contains("operator://")); + + let viewer_preview = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/api/viewers/archive-manager/library-object?uri={encoded_uri}&preview_entry=Nested%2Fdeep.txt" + )) + .header("x-elastos-home-token", archive_token) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let viewer_preview_status = viewer_preview.status(); + let viewer_preview_body = axum::body::to_bytes(viewer_preview.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!( + viewer_preview_status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&viewer_preview_body) + ); + let viewer_preview: serde_json::Value = serde_json::from_slice(&viewer_preview_body).unwrap(); + assert_eq!( + viewer_preview["data"]["schema"], + "elastos.library.archive-preview-entry/v1" + ); + assert_eq!(viewer_preview["data"]["preview"]["text"], "zip nested"); + assert!(!serde_json::to_string(&viewer_preview) + .unwrap() + .contains("operator://")); + + let (provider_status, provider_entries) = post_library( + app, + &token, + "archive_entries", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(provider_status, StatusCode::OK); + assert_eq!(provider_entries["status"], "ok"); + assert!(!serde_json::to_string(&provider_entries) + .unwrap() + .contains("operator://")); +} + +#[tokio::test] +async fn test_library_gateway_imports_external_webspace_archive_entries_to_local_library() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let archive_token = app_token_for_authority(dir.path(), "archive-manager", &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let imports_uri = format!("{documents_uri}/Imports"); + let source_uri = "localhost://WebSpaces/Operator/Projects/Bundle.zip"; + let encoded_source_uri = source_uri.replace(':', "%3A").replace('/', "%2F"); + write_test_static_capsule( + dir.path(), + "archive-manager", + "viewer", + "Test Archive viewer", + "Archive", + ); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Documents", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + let (imports_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Imports", + }), + ) + .await; + assert_eq!(imports_status, StatusCode::OK); + + let viewer_extract = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!( + "/api/viewers/archive-manager/library-object?uri={encoded_source_uri}" + )) + .header("x-elastos-home-token", archive_token) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "destination_uri": imports_uri, + "entries": ["Nested/deep.txt"], + "conflict_policy": "replace", + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + let viewer_extract_status = viewer_extract.status(); + let viewer_body = axum::body::to_bytes(viewer_extract.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!( + viewer_extract_status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&viewer_body) + ); + let viewer_extract: serde_json::Value = serde_json::from_slice(&viewer_body).unwrap(); + assert_eq!(viewer_extract["status"], "ok"); + assert_eq!( + viewer_extract["data"]["schema"], + "elastos.library.archive-extract-entries/v1" + ); + assert_eq!(viewer_extract["data"]["receipt"]["status"], "completed"); + assert_eq!( + viewer_extract["data"]["written"][0]["uri"], + format!("{imports_uri}/Nested/deep.txt") + ); + assert!(!serde_json::to_string(&viewer_extract) + .unwrap() + .contains("operator://")); + + let (read_status, read) = post_library( + app, + &token, + "read", + json!({ + "uri": format!("{imports_uri}/Nested/deep.txt"), + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(String::from_utf8(bytes).unwrap(), "zip nested"); +} + +#[tokio::test] +async fn test_library_gateway_webspace_archive_writeback_requires_mutable_write_adapter() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let source_uri = "localhost://WebSpaces/Operator/Projects/Bundle.zip"; + + let (readonly_status, readonly) = post_library( + app.clone(), + &token, + "archive_extract_entries", + json!({ + "uri": source_uri, + "destination_uri": "localhost://WebSpaces/Operator/Projects", + "entries": ["alpha.txt"], + "conflict_policy": "replace", + }), + ) + .await; + assert_eq!(readonly_status, StatusCode::OK); + assert_eq!(readonly["status"], "error"); + assert!( + readonly["message"] + .as_str() + .unwrap() + .contains("mutable destination Space"), + "{readonly}" + ); + + let (writeback_status, writeback) = post_library( + app.clone(), + &token, + "archive_extract_entries", + json!({ + "uri": source_uri, + "destination_uri": "localhost://WebSpaces/OperatorMutable/Folder", + "entries": ["alpha.txt"], + "conflict_policy": "replace", + }), + ) + .await; + assert_eq!(writeback_status, StatusCode::OK); + assert_eq!(writeback["status"], "ok"); + assert_eq!(writeback["data"]["receipt"]["status"], "completed"); + assert_eq!( + writeback["data"]["written"][0]["webspace"]["write_back"], + "resolver_synced" + ); + assert_eq!( + writeback["data"]["written"][0]["uri"], + "localhost://WebSpaces/OperatorMutable/Folder/alpha.txt" + ); + assert!(!serde_json::to_string(&writeback) + .unwrap() + .contains("operator://")); + + let (read_status, read) = post_library( + app, + &token, + "read", + json!({ + "uri": "localhost://WebSpaces/OperatorMutable/Folder/alpha.txt", + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(String::from_utf8(bytes).unwrap(), "zip alpha"); +} + +#[tokio::test] +async fn test_library_gateway_webspace_sync_caches_adapter_bytes_without_foreground_read() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + write_test_static_capsule( + dir.path(), + DOCUMENTS_CAPSULE_ID, + "viewer", + "Test Documents viewer", + "Documents Viewer", + ); + + let uri = "localhost://WebSpaces/Operator/Projects/Brief.md"; + let expected = b"# Operator Brief\n\nAdapter-backed bytes.\n"; + let (sync_status, sync) = post_library( + app.clone(), + &token, + "sync", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(sync_status, StatusCode::OK); + assert_eq!(sync["status"], "ok"); + let receipt = &sync["data"]["receipt"]; + assert_eq!(receipt["schema"], "elastos.webspace.byte-sync-receipt/v1"); + assert_eq!(receipt["action"], "bytes_cached_from_adapter"); + assert_eq!(receipt["foreground_read"], false); + assert_eq!(receipt["bytes_exposed"], false); + assert_eq!(receipt["content_synced"], true); + assert_eq!(receipt["bytes_cached"], expected.len()); + assert_eq!( + receipt["availability_hint"]["schema"], + "elastos.webspace.availability-hint/v1" + ); + assert_eq!(receipt["availability_hint"]["status"], "resolver_cached"); + assert_eq!( + receipt["availability_hint"]["target_uri"], + "operator://drive/Projects/Brief.md" + ); + assert_eq!( + receipt["availability_hint"]["not_content_availability"], + true + ); + assert!(receipt.get("data").is_none()); + assert_eq!(sync["data"]["object"]["availability"], "resolver-cached"); + assert_eq!( + sync["data"]["object"]["metadata"]["cache_state"], + "content_cached" + ); + assert_eq!( + sync["data"]["object"]["metadata"]["availability_hint"]["status"], + "resolver_cached" + ); + assert_eq!(sync["data"]["object"]["viewer"], "documents"); + + let (stat_status, stat) = post_library( + app.clone(), + &token, + "stat", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(stat_status, StatusCode::OK); + assert_eq!( + stat["data"]["object"]["metadata"]["cache_state"], + "content_cached" + ); + assert_eq!( + stat["data"]["object"]["metadata"]["webspace_kind"], + "materialized-file" + ); + assert_eq!(stat["data"]["object"]["availability"], "resolver-cached"); + assert_eq!( + stat["data"]["object"]["metadata"]["availability_hint"]["scope"], + "resolver" + ); + + let (read_status, read) = post_library( + app, + &token, + "read", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(bytes, expected); + assert_eq!( + read["data"]["object"]["metadata"]["cache_state"], + "content_cached" + ); + assert_eq!(read["data"]["object"]["availability"], "resolver-cached"); +} + +#[tokio::test] +async fn test_library_gateway_syncs_operator_mutable_webspace_file_to_resolver() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let uri = "localhost://WebSpaces/OperatorMutable/Folder/note.txt"; + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"operator mutable bytes"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + assert_eq!( + write["data"]["object"]["metadata"]["sync_state"], + "manual_pending" + ); + assert_eq!( + write["data"]["object"]["metadata"]["target_uri"], + "operator://drive/Writable/Folder/note.txt" + ); + + let (sync_status, sync) = post_library( + app.clone(), + &token, + "sync", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(sync_status, StatusCode::OK); + assert_eq!(sync["status"], "ok"); + let receipt = &sync["data"]["receipt"]; + assert_eq!( + receipt["schema"], + "elastos.webspace.resolver-sync-receipt/v1" + ); + assert_eq!(receipt["action"], "resolver_write_synced"); + assert_eq!(receipt["resolver_synced"], true); + assert_eq!(receipt["content_synced"], true); + assert_eq!(receipt["fail_closed"], false); + assert_eq!(receipt["conflict"], false); + assert_eq!(receipt["bytes_exposed"], false); + assert_eq!(receipt["bytes_synced"], b"operator mutable bytes".len()); + assert_eq!(receipt["provider"], "operator-drive-adapter"); + assert_eq!( + receipt["availability_hint"]["schema"], + "elastos.webspace.availability-hint/v1" + ); + assert_eq!(receipt["availability_hint"]["status"], "resolver_synced"); + assert_eq!( + receipt["availability_hint"]["not_content_availability"], + true + ); + assert_eq!( + receipt["target_uri"], + "operator://drive/Writable/Folder/note.txt" + ); + assert_eq!( + receipt["adapter_receipt"]["schema"], + "elastos.webspace.adapter.write-bytes-receipt/v1" + ); + assert_eq!( + sync["data"]["object"]["metadata"]["sync_state"], + "manual_synced" + ); + assert_eq!(sync["data"]["object"]["availability"], "resolver-synced"); + assert_eq!( + sync["data"]["object"]["metadata"]["availability_hint"]["status"], + "resolver_synced" + ); + + let (stat_status, stat) = post_library( + app, + &token, + "stat", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(stat_status, StatusCode::OK); + assert_eq!( + stat["data"]["object"]["metadata"]["sync_state"], + "manual_synced" + ); + assert_eq!(stat["data"]["object"]["availability"], "resolver-synced"); +} + +#[tokio::test] +async fn test_library_gateway_webspace_sync_fails_closed_without_write_adapter() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let uri = "localhost://WebSpaces/Mutable/Folder/no-adapter.txt"; + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"local mutable bytes"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + assert_eq!( + write["data"]["object"]["metadata"]["sync_state"], + "manual_pending" + ); + + let (sync_status, sync) = post_library( + app, + &token, + "sync", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(sync_status, StatusCode::OK); + assert_eq!(sync["status"], "ok"); + let receipt = &sync["data"]["receipt"]; + assert_eq!( + receipt["schema"], + "elastos.webspace.resolver-sync-receipt/v1" + ); + assert_eq!(receipt["action"], "resolver_write_unavailable"); + assert_eq!(receipt["resolver_synced"], false); + assert_eq!(receipt["content_synced"], false); + assert_eq!(receipt["fail_closed"], true); + assert_eq!(receipt["conflict"], false); + assert_eq!(receipt["bytes_exposed"], false); + assert_eq!( + sync["data"]["object"]["metadata"]["sync_state"], + "manual_pending" + ); +} + +#[tokio::test] +async fn test_library_gateway_webspace_sync_reports_resolver_conflict() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let uri = "localhost://WebSpaces/OperatorMutable/Conflict/stale.txt"; + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"stale fork bytes"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + + let (sync_status, sync) = post_library( + app, + &token, + "sync", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(sync_status, StatusCode::OK); + assert_eq!(sync["status"], "ok"); + let receipt = &sync["data"]["receipt"]; + assert_eq!( + receipt["schema"], + "elastos.webspace.resolver-sync-receipt/v1" + ); + assert_eq!(receipt["action"], "resolver_write_conflict"); + assert_eq!(receipt["resolver_synced"], false); + assert_eq!(receipt["content_synced"], false); + assert_eq!(receipt["fail_closed"], true); + assert_eq!(receipt["conflict"], true); + assert_eq!(receipt["provider"], "operator-drive-adapter"); + assert_eq!( + receipt["adapter_response"]["data"]["schema"], + "elastos.webspace.adapter.write-conflict/v1" + ); + assert_eq!( + sync["data"]["object"]["metadata"]["sync_state"], + "manual_pending" + ); +} + +#[tokio::test] +async fn test_library_gateway_rejects_webspace_mutation_as_read_only() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + + let (write_status, write) = post_library( + app, + &token, + "write", + json!({ + "uri": "localhost://WebSpaces/Elastos/not-allowed.txt", + "data": base64::engine::general_purpose::STANDARD.encode(b"must fail"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "error"); + assert!(write["message"] + .as_str() + .unwrap() + .contains("resolver-owned and read-only")); +} + +#[tokio::test] +async fn test_library_gateway_mutates_writable_webspace_through_runtime_provider() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_webspace_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + + let (list_status, list) = post_library( + app.clone(), + &token, + "list", + json!({ + "uri": "localhost://WebSpaces/Mutable", + }), + ) + .await; + assert_eq!(list_status, StatusCode::OK); + assert_eq!(list["status"], "ok"); + assert_eq!(list["data"]["object"]["metadata"]["readonly"], false); + assert_eq!( + list["data"]["object"]["metadata"]["access_policy"], + "owner-writable" + ); + + let (mkdir_status, mkdir) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": "localhost://WebSpaces/Mutable", + "name": "Folder", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + assert_eq!(mkdir["status"], "ok"); + assert_eq!( + mkdir["data"]["receipt"]["schema"], + "elastos.webspace.mkdir-receipt/v1" + ); + assert_eq!(mkdir["data"]["object"]["metadata"]["readonly"], false); + + let note_uri = "localhost://WebSpaces/Mutable/Folder/note.txt"; + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": note_uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"mutable bytes"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + assert_eq!( + write["data"]["receipt"]["schema"], + "elastos.webspace.write-receipt/v1" + ); + assert_eq!( + write["data"]["object"]["metadata"]["webspace_kind"], + "materialized-file" + ); + assert_eq!(write["data"]["object"]["metadata"]["readonly"], false); + assert!(write["data"]["object"]["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "delete_permanently")); + + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": note_uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + assert_eq!(read["status"], "ok"); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(bytes, b"mutable bytes"); + + let upload_uri = "localhost://WebSpaces/Mutable/Folder/upload.txt"; + let (upload_status, _upload_headers, upload) = + put_library_upload(app.clone(), &token, upload_uri, b"uploaded bytes").await; + assert_eq!(upload_status, StatusCode::OK); + assert_eq!(upload["status"], "ok"); + assert_eq!( + upload["data"]["receipt"]["schema"], + "elastos.object.transfer.receipt/v1" + ); + assert_eq!( + upload["data"]["provider_receipt"]["schema"], + "elastos.webspace.write-receipt/v1" + ); + assert_eq!( + upload["data"]["object"]["metadata"]["webspace_kind"], + "materialized-file" + ); + + let (delete_status, deleted) = post_library( + app, + &token, + "delete_permanently", + json!({ + "uri": note_uri, + }), + ) + .await; + assert_eq!(delete_status, StatusCode::OK); + assert_eq!(deleted["status"], "ok"); + assert_eq!( + deleted["data"]["receipt"]["schema"], + "elastos.webspace.delete-receipt/v1" + ); + assert_eq!(deleted["data"]["deleted_uri"], note_uri); +} + +#[tokio::test] +async fn test_library_provider_move_is_principal_scoped_and_audited() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let target_uri = format!("{documents_uri}/Moved"); + let source_uri = format!("{documents_uri}/move-me.txt"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Moved", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": source_uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"moved bytes"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + let revision = write["data"]["object"]["revision"].as_str().unwrap(); + + let (move_status, moved) = post_library( + app.clone(), + &token, + "move", + json!({ + "uri": source_uri, + "target_parent_uri": target_uri, + "if_revision": revision, + }), + ) + .await; + assert_eq!(move_status, StatusCode::OK); + let moved_uri = moved["data"]["object"]["uri"].as_str().unwrap(); + assert_eq!(moved_uri, format!("{target_uri}/move-me.txt")); + + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": moved_uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(), + b"moved bytes" + ); + + let (events_status, events) = post_library( + app, + &token, + "events", + json!({ + "uri": target_uri, + }), + ) + .await; + assert_eq!(events_status, StatusCode::OK); + assert!(events["data"]["events"] + .as_array() + .unwrap() + .iter() + .any(|event| event["op"] == "move" && event["details"]["old_uri"] == source_uri)); +} + +#[tokio::test] +async fn test_library_provider_copy_preserves_source_and_audits() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let target_uri = format!("{documents_uri}/Copied"); + let source_uri = format!("{documents_uri}/copy-me.txt"); + + let (mkdir_status, _) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": documents_uri, + "name": "Copied", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": source_uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"copied bytes"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + let revision = write["data"]["object"]["revision"].as_str().unwrap(); + + let (copy_status, copied) = post_library( + app.clone(), + &token, + "copy", + json!({ + "uri": source_uri, + "target_parent_uri": target_uri, + "if_revision": revision, + }), + ) + .await; + assert_eq!(copy_status, StatusCode::OK); + let copied_uri = copied["data"]["object"]["uri"].as_str().unwrap(); + assert_eq!(copied_uri, format!("{target_uri}/copy-me.txt")); + + for uri in [&source_uri, copied_uri] { + let (read_status, read) = post_library( + app.clone(), + &token, + "read", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(), + b"copied bytes" + ); + } + + let (events_status, events) = post_library( + app, + &token, + "events", + json!({ + "uri": target_uri, + }), + ) + .await; + assert_eq!(events_status, StatusCode::OK); + assert!(events["data"]["events"] + .as_array() + .unwrap() + .iter() + .any(|event| event["op"] == "copy" && event["details"]["source_uri"] == source_uri)); +} + +#[tokio::test] +async fn test_library_provider_events_returns_typed_object_events() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let notes_uri = format!("{root}/Documents/events.txt"); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": notes_uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"evented"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + + let (mkdir_status, mkdir) = post_library( + app.clone(), + &token, + "mkdir", + json!({ + "parent_uri": root, + "name": "Event Folder", + }), + ) + .await; + assert_eq!(mkdir_status, StatusCode::OK); + assert_eq!(mkdir["status"], "ok"); + + let (events_status, events) = post_library(app.clone(), &token, "events", json!({})).await; + assert_eq!(events_status, StatusCode::OK); + assert_eq!(events["status"], "ok"); + assert_eq!(events["data"]["schema"], "elastos.library.events/v1"); + let events = events["data"]["events"].as_array().unwrap(); + assert_eq!(events.len(), 2); + assert!(events.iter().all(|event| { + event["schema"] == "elastos.library.event/v1" + && event["event_id"] + .as_str() + .unwrap() + .starts_with("library:event:") + })); + assert_eq!(events[0]["op"], "write"); + assert_eq!(events[0]["uri"], notes_uri); + assert_eq!(events[0]["details"]["object"]["name"], "events.txt"); + assert_eq!(events[1]["op"], "mkdir"); + + let (filtered_status, filtered) = post_library( + app.clone(), + &token, + "events", + json!({ + "uri": notes_uri, + }), + ) + .await; + assert_eq!(filtered_status, StatusCode::OK); + let filtered_events = filtered["data"]["events"].as_array().unwrap(); + assert_eq!(filtered_events.len(), 1); + assert_eq!(filtered_events[0]["op"], "write"); + + let (limited_status, limited) = post_library( + app, + &token, + "events", + json!({ + "limit": 1, + }), + ) + .await; + assert_eq!(limited_status, StatusCode::OK); + let limited_events = limited["data"]["events"].as_array().unwrap(); + assert_eq!(limited_events.len(), 1); + assert_eq!(limited_events[0]["op"], "mkdir"); +} + +#[tokio::test] +async fn test_library_provider_events_stream_requires_library_token_and_serves_sse() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + + let unauthorized = app + .clone() + .oneshot( + Request::builder() + .uri("/api/provider/object/events/stream") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unauthorized.status(), StatusCode::FORBIDDEN); + + let authorized = app + .oneshot( + Request::builder() + .uri(format!( + "/api/provider/object/events/stream?home_token={token}" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(authorized.status(), StatusCode::OK); + assert!( + authorized + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("text/event-stream")), + "Library event stream should be served as SSE" + ); + assert_eq!( + authorized + .headers() + .get(axum::http::header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-cache, no-transform"), + "Library SSE must not be cached or transformed by proxies" + ); + assert_eq!( + authorized + .headers() + .get("x-accel-buffering") + .and_then(|value| value.to_str().ok()), + Some("no"), + "nginx must not buffer realtime Library events" + ); +} + +#[tokio::test] +async fn test_library_provider_viewers_only_include_installed_viewer_capsules() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/view-me.txt"); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"viewer routing"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + + let (without_viewer_status, without_viewer) = post_library( + app.clone(), + &token, + "stat", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(without_viewer_status, StatusCode::OK); + assert!(without_viewer["data"]["object"]["viewer"].is_null()); + assert!(without_viewer["data"]["object"]["viewers"].is_null()); + + write_test_static_capsule( + dir.path(), + DOCUMENTS_CAPSULE_ID, + "viewer", + "Test Documents viewer", + "Documents Viewer", + ); + + let (with_viewer_status, with_viewer) = post_library( + app.clone(), + &token, + "stat", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(with_viewer_status, StatusCode::OK); + assert_eq!( + with_viewer["data"]["object"]["viewer"], + DOCUMENTS_CAPSULE_ID + ); + assert_eq!( + with_viewer["data"]["object"]["viewers"][0]["id"], + DOCUMENTS_CAPSULE_ID + ); + assert_eq!( + with_viewer["data"]["object"]["viewers"][0]["label"], + "Documents" + ); + + let rom_uri = format!("{root}/Documents/game.gba"); + let (rom_write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": rom_uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"rom bytes"), + }), + ) + .await; + assert_eq!(rom_write_status, StatusCode::OK); + + write_test_static_capsule( + dir.path(), + GBA_EMULATOR_CAPSULE_ID, + "viewer", + "Test GBA emulator", + "GBA Emulator", + ); + + let (rom_viewer_status, rom_viewer) = post_library( + app, + &token, + "stat", + json!({ + "uri": rom_uri, + }), + ) + .await; + assert_eq!(rom_viewer_status, StatusCode::OK); + assert_eq!( + rom_viewer["data"]["object"]["viewer"], + GBA_EMULATOR_CAPSULE_ID + ); + assert_eq!( + rom_viewer["data"]["object"]["viewers"][0]["label"], + "GBA Emulator" + ); +} + +#[tokio::test] +async fn test_library_provider_rejects_traversal_segments() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + + let (status, payload) = post_library( + app, + &token, + "read", + json!({ + "uri": format!("{root}/Documents/../Secrets.txt"), + }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(payload["status"], "error"); + assert!(payload["message"].as_str().unwrap().contains("traversal")); +} + +#[tokio::test] +async fn test_library_provider_scopes_to_launch_principal() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let admin = passkey_authority_with_name(dir.path(), Some("admin")); + let guest = passkey_authority_with_name_role( + dir.path(), + Some("guest"), + crate::auth::RuntimePrincipalRole::Guest, + ); + let admin_token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &admin); + let guest_token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &guest); + let admin_root = crate::auth::principal_localhost_root(&admin.principal_id); + let guest_root = crate::auth::principal_localhost_root(&guest.principal_id); + let admin_uri = format!("{admin_root}/Documents/private.txt"); + + let (write_status, write) = post_library( + app.clone(), + &admin_token, + "write", + json!({ + "uri": admin_uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"admin only"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + + let (guest_status, guest_read) = post_library( + app, + &guest_token, + "read", + json!({ + "uri": admin_uri, + }), + ) + .await; + assert_eq!(guest_status, StatusCode::OK); + assert_eq!(guest_read["status"], "error"); + assert!(guest_read["message"] + .as_str() + .unwrap() + .contains("outside the active principal root")); + assert_ne!(admin_root, guest_root); +} + +#[tokio::test] +async fn test_library_provider_audits_provider_operations() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + + let (status, payload) = post_library(app, &token, "roots", json!({})).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(payload["status"], "ok"); + + let auth_state = crate::auth::load_auth_state(dir.path()).unwrap(); + let library_events: Vec<_> = auth_state + .audit + .iter() + .filter(|event| event.event_type.starts_with("object.provider.")) + .collect(); + assert_eq!(library_events.len(), 2); + assert_eq!(library_events[0].event_type, "object.provider.requested"); + assert_eq!(library_events[0].result, "requested"); + assert_eq!(library_events[1].event_type, "object.provider.completed"); + assert_eq!(library_events[1].result, "completed"); + assert_eq!( + library_events[0].challenge_id, + library_events[1].challenge_id + ); + assert_eq!( + library_events[0].capsule_id.as_deref(), + Some(LIBRARY_CAPSULE_ID) + ); + assert!(library_events[0].reason.contains("roots")); +} + +#[tokio::test] +async fn test_library_provider_writes_protected_principal_objects() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + crate::auth::store_test_principal_root_protection(dir.path(), &authority.principal_id); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/protected.txt"); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"encrypted object"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + + let raw_path = rooted_localhost_fs_path(dir.path(), &uri).unwrap(); + let raw = std::fs::read_to_string(raw_path).unwrap(); + assert!(!raw.contains("encrypted object")); + assert!(raw.contains("elastos.principal-root.object/v1")); + + let (read_status, read) = post_library( + app, + &token, + "read", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + let decoded = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(decoded, b"encrypted object"); +} + +#[tokio::test] +async fn test_library_provider_auto_protects_plaintext_legacy_objects() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + crate::auth::store_test_principal_root_protection(dir.path(), &authority.principal_id); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let documents_uri = format!("{root}/Documents"); + let secret_uri = format!("{documents_uri}/secret.md"); + let secret_path = + elastos_common::localhost::rooted_localhost_fs_path(dir.path(), &secret_uri).unwrap(); + std::fs::create_dir_all(secret_path.parent().unwrap()).unwrap(); + std::fs::write(&secret_path, b"plaintext from an older runtime").unwrap(); + + let (list_status, list) = post_library( + app.clone(), + &token, + "list", + json!({ + "uri": documents_uri, + }), + ) + .await; + assert_eq!(list_status, StatusCode::OK); + assert_eq!(list["status"], "ok"); + let object = list["data"]["objects"] + .as_array() + .unwrap() + .iter() + .find(|object| object["uri"] == secret_uri) + .expect("legacy object should still appear in folder listing"); + assert!(object["blocked_reason"].is_null()); + assert_eq!(object["availability"], "local-only"); + assert!(object["capabilities"] + .as_array() + .unwrap() + .iter() + .any(|capability| capability == "read")); + assert_ne!( + std::fs::read(&secret_path).unwrap(), + b"plaintext from an older runtime", + "listing should migrate protected-root plaintext to encrypted storage", + ); + + let (read_status, read) = post_library( + app, + &token, + "read", + json!({ + "uri": secret_uri, + }), + ) + .await; + assert_eq!(read_status, StatusCode::OK); + assert_eq!(read["status"], "ok"); + let decoded = base64::engine::general_purpose::STANDARD + .decode(read["data"]["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(decoded, b"plaintext from an older runtime"); +} + +#[tokio::test] +async fn test_library_provider_publish_fails_closed_without_content_provider() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state_without_content(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/no-content.txt"); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"publish me"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + + let (publish_status, publish) = post_library( + app, + &token, + "publish", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(publish_status, StatusCode::OK); + assert_eq!(publish["status"], "error"); + assert!(publish["message"] + .as_str() + .unwrap() + .contains("content provider unavailable")); +} + +#[tokio::test] +async fn test_library_provider_publish_uses_content_provider() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/publish.txt"); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"publish me"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + let local_cid = write["data"]["object"]["content_cid"] + .as_str() + .unwrap() + .to_string(); + assert!(local_cid.starts_with("bafkrei")); + assert_eq!(write["data"]["object"].get("published_cid"), None); + assert_eq!(write["data"]["object"]["published"], false); + + let (publish_status, publish) = post_library( + app.clone(), + &token, + "publish", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(publish_status, StatusCode::OK); + assert_eq!(publish["status"], "ok"); + assert_eq!(publish["data"]["cid"], TEST_CIDV1); + assert_eq!(publish["data"]["uri"], format!("elastos://{TEST_CIDV1}")); + assert_eq!(publish["data"]["object"]["content_cid"], local_cid); + assert_eq!(publish["data"]["object"]["published_cid"], TEST_CIDV1); + + let (status_code, status) = post_library( + app, + &token, + "status", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(status_code, StatusCode::OK); + assert_eq!(status["data"]["object"]["published"], true); + assert_eq!(status["data"]["object"]["content_cid"], local_cid); + assert_eq!(status["data"]["object"]["published_cid"], TEST_CIDV1); + assert_eq!(status["data"]["published"]["cid"], TEST_CIDV1); +} + +#[tokio::test] +async fn test_library_gateway_coordinates_content_for_external_provider() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_external_provider_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/external-publish.txt"); + + let (write_status, write) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"publish me externally"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + assert_eq!(write["status"], "ok"); + + let (publish_status, publish) = post_library( + app.clone(), + &token, + "publish", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(publish_status, StatusCode::OK); + assert_eq!(publish["status"], "ok"); + assert_eq!(publish["data"]["cid"], TEST_CIDV1); + + let (status_code, status) = post_library( + app, + &token, + "status", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(status_code, StatusCode::OK); + assert_eq!(status["status"], "ok"); + assert_eq!(status["data"]["object"]["published"], true); + assert_eq!(status["data"]["published"]["cid"], TEST_CIDV1); +} + +#[tokio::test] +async fn test_library_provider_share_requires_active_publish_record() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/share.txt"); + + let (write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"share me"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + + let (share_draft_status, share_draft) = post_library( + app.clone(), + &token, + "share", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(share_draft_status, StatusCode::OK); + assert_eq!(share_draft["status"], "error"); + assert!(share_draft["message"] + .as_str() + .unwrap() + .contains("published object")); + + let (publish_status, _) = post_library( + app.clone(), + &token, + "publish", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(publish_status, StatusCode::OK); + + let (share_status, share) = post_library( + app.clone(), + &token, + "share", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(share_status, StatusCode::OK); + assert_eq!(share["status"], "ok"); + assert_eq!(share["data"]["schema"], "elastos.library.share/v1"); + assert_eq!(share["data"]["uri"], format!("elastos://{TEST_CIDV1}")); + assert_eq!(share["data"]["policy"], "public_link"); + assert!(share["data"]["recipients"].as_array().unwrap().is_empty()); + assert!(share["data"]["grants"].as_array().unwrap().is_empty()); + assert_eq!(share["data"]["object"]["shared"], true); +} + +#[tokio::test] +async fn test_library_provider_records_recipient_scoped_share_grants() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/scoped-share.txt"); + + let (write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"share me to a recipient"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + + let (publish_status, _) = post_library( + app.clone(), + &token, + "publish", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(publish_status, StatusCode::OK); + + let (share_status, share) = post_library( + app.clone(), + &token, + "share", + json!({ + "uri": uri, + "recipients": [ + authority.principal_id, + "did:key:z6MkRecipient111111111111111111111111111111111", + "did:key:z6MkRecipient111111111111111111111111111111111", + "person:local:alice" + ] + }), + ) + .await; + assert_eq!(share_status, StatusCode::OK); + assert_eq!(share["status"], "ok"); + assert_eq!(share["data"]["policy"], "recipient_scoped"); + assert_eq!(share["data"]["recipients"].as_array().unwrap().len(), 3); + assert_eq!(share["data"]["grants"].as_array().unwrap().len(), 3); + assert_eq!( + share["data"]["grants"][0]["schema"], + "elastos.library.share-grant/v1" + ); + assert_eq!(share["data"]["grants"][0]["cid"], TEST_CIDV1); + assert_eq!(share["data"]["grants"][0]["policy"], "recipient_scoped"); + assert_eq!( + share["data"]["content_security"]["schema"], + "elastos.library.published-content-security/v1" + ); + assert_eq!( + share["data"]["content_security"]["published_payload"], + "plain_content" + ); + assert_eq!( + share["data"]["key_release"]["schema"], + "elastos.library.key-release/v1" + ); + assert_eq!(share["data"]["key_release"]["required"], false); + assert_eq!( + share["data"]["grants"][0]["key_release"]["status"], + "not_required_for_plain_published_content" + ); + assert_eq!( + share["data"]["remote_enforcement"]["required_providers"]["schema"], + "elastos.library.protected-content-provider-requirements/v1" + ); + assert_eq!( + share["data"]["remote_enforcement"]["provider_invocation"]["drm"], + "drm-provider.open" + ); + assert_eq!( + share["data"]["remote_enforcement"]["provider_invocation"]["rights"], + "rights-provider.has_access_by_content_id" + ); + assert_eq!( + share["data"]["protected_content"]["schema"], + "elastos.library.protected-content-provider-status/v1" + ); + assert_eq!( + share["data"]["protected_content"]["encrypted_recipient_sharing"]["status"], + "blocked_until_drm_rights_key_decrypt_providers_configured" + ); + assert_eq!( + share["data"]["protected_content"]["required_provider_count"], + 4 + ); + + let (status_status, status) = post_library( + app.clone(), + &token, + "status", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(status_status, StatusCode::OK); + assert_eq!(status["status"], "ok"); + assert_eq!( + status["data"]["protected_content"]["schema"], + "elastos.library.protected-content-provider-status/v1" + ); + assert_eq!( + status["data"]["published"]["protected_content"]["encrypted_recipient_sharing"]["status"], + "blocked_until_drm_rights_key_decrypt_providers_configured" + ); + + let (access_status, access) = post_library( + app.clone(), + &token, + "shared_access", + json!({ + "uri": uri, + "recipient": authority.principal_id, + }), + ) + .await; + assert_eq!(access_status, StatusCode::OK); + assert_eq!(access["status"], "ok"); + assert_eq!(access["data"]["schema"], "elastos.library.shared-access/v1"); + assert_eq!(access["data"]["uri"], format!("elastos://{TEST_CIDV1}")); + assert_eq!(access["data"]["access"]["policy"], "recipient_scoped"); + assert_eq!( + access["data"]["access"]["recipient"], + authority.principal_id + ); + assert_eq!( + access["data"]["access"]["recipient_proof"]["schema"], + "elastos.library.recipient-proof-state/v1" + ); + assert_eq!( + access["data"]["access"]["recipient_proof"]["verified"], + true + ); + assert_eq!( + access["data"]["access"]["recipient_proof"]["source"], + "runtime-launch-grant" + ); + assert_eq!( + access["data"]["access"]["recipient_proof"]["proof_binding_id"], + authority.proof_binding_id + ); + assert_eq!(access["data"]["access"]["decision"]["allowed"], true); + assert_eq!( + access["data"]["access"]["decision"]["schema"], + "elastos.library.access-decision/v1" + ); + assert_eq!( + access["data"]["access"]["open"]["schema"], + "elastos.library.shared-open/v1" + ); + assert_eq!( + access["data"]["access"]["open"]["provider"], + "content-provider" + ); + assert_eq!( + access["data"]["access"]["open"]["transport"], + "runtime-provider-fetch" + ); + assert_eq!( + access["data"]["access"]["open"]["status"], + "ready_for_plain_content_fetch" + ); + assert_eq!( + access["data"]["access"]["open"]["key_release_required"], + false + ); + assert_eq!( + access["data"]["access"]["open"]["drm_provider_required"], + false + ); + assert_eq!( + access["data"]["access"]["open"]["recipient_proof_verified"], + true + ); + assert_eq!( + access["data"]["access"]["open"]["rights_provider_required"], + false + ); + assert_eq!( + access["data"]["access"]["open"]["key_provider_required"], + false + ); + assert_eq!( + access["data"]["access"]["open"]["decrypt_provider_required"], + false + ); + assert_eq!( + access["data"]["access"]["open"]["required_providers"]["providers"] + .as_array() + .unwrap() + .len(), + 4 + ); + assert_eq!( + access["data"]["protected_content"]["schema"], + "elastos.library.protected-content-provider-status/v1" + ); + assert_eq!( + access["data"]["access"]["key_release"]["status"], + "not_required_for_plain_published_content" + ); + assert_eq!( + access["data"]["access"]["content_security"]["published_payload"], + "plain_content" + ); + + let (missing_proof_status, missing_proof) = post_library( + app.clone(), + &token, + "shared_access", + json!({ + "uri": uri, + "recipient": "person:local:alice", + "recipient_proof": { + "schema": "elastos.library.recipient-proof/v1", + "source": "runtime-launch-grant", + "recipient": "person:local:alice" + } + }), + ) + .await; + assert_eq!(missing_proof_status, StatusCode::OK); + assert_eq!(missing_proof["status"], "error"); + assert!(missing_proof["message"] + .as_str() + .unwrap() + .contains("requires Runtime recipient_proof")); + + let (blocked_status, blocked) = post_library( + app.clone(), + &token, + "shared_access", + json!({ + "uri": uri, + "recipient": "person:local:bob", + }), + ) + .await; + assert_eq!(blocked_status, StatusCode::OK); + assert_eq!(blocked["status"], "error"); + assert!(blocked["message"] + .as_str() + .unwrap() + .contains("not authorized")); + + let (events_status, events) = post_library( + app.clone(), + &token, + "events", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(events_status, StatusCode::OK); + assert_eq!(events["status"], "ok"); + let shared_access_events = events["data"]["events"] + .as_array() + .unwrap() + .iter() + .filter(|event| event["op"] == "shared_access") + .collect::>(); + assert!(shared_access_events.iter().any(|event| { + event["details"]["recipient"] == authority.principal_id + && event["details"]["allowed"] == true + && event["details"]["open"]["status"] == "ready_for_plain_content_fetch" + && event["details"]["open"]["recipient_proof_verified"] == true + && event["details"]["key_release"]["required"] == false + })); + assert!(shared_access_events.iter().any(|event| { + event["details"]["recipient"] == "person:local:bob" + && event["details"]["allowed"] == false + && event["details"]["reason"] + .as_str() + .unwrap_or_default() + .contains("not authorized") + })); + assert!(shared_access_events.iter().any(|event| { + event["details"]["recipient"] == "person:local:alice" + && event["details"]["allowed"] == false + && event["details"]["reason"] + .as_str() + .unwrap_or_default() + .contains("requires Runtime recipient_proof") + })); + + let (status_code, status) = post_library( + app, + &token, + "status", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(status_code, StatusCode::OK); + assert_eq!(status["status"], "ok"); + assert_eq!( + status["data"]["published"]["share_policy"], + "recipient_scoped" + ); + assert_eq!( + status["data"]["published"]["share_grants"] + .as_array() + .unwrap() + .len(), + 3 + ); +} + +#[tokio::test] +async fn test_library_provider_rejects_key_release_policy_until_provider_exists() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/protected-share.txt"); + + let (write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"share with key release"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + + let (publish_status, _) = post_library( + app.clone(), + &token, + "publish", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(publish_status, StatusCode::OK); + + let (share_status, share) = post_library( + app, + &token, + "share", + json!({ + "uri": uri, + "recipients": ["person:local:alice"], + "key_release_policy": "recipient_key_release", + }), + ) + .await; + assert_eq!(share_status, StatusCode::OK); + assert_eq!(share["status"], "error"); + assert!(share["message"] + .as_str() + .unwrap() + .contains("drm/rights/key/decrypt providers")); +} + +#[tokio::test] +async fn test_library_provider_runs_protected_content_receipt_chain_for_recipient() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_protected_content_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/protected-fixture.txt"); + + let (write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"protected fixture"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + + let (publish_status, publish) = post_library( + app.clone(), + &token, + "publish", + json!({ + "uri": uri, + "protected_content_fixture": true, + }), + ) + .await; + assert_eq!(publish_status, StatusCode::OK); + assert_eq!(publish["status"], "ok"); + assert_eq!( + publish["data"]["content_security"]["published_payload"], + "protected_content_fixture" + ); + assert_eq!( + publish["data"]["content_security"]["key_release_required"], + true + ); + assert_eq!( + publish["data"]["content_security"]["production_encryption"], + false + ); + assert_eq!( + publish["data"]["content_security"]["sealed_object"]["schema"], + "elastos.sealed.object/v1" + ); + + let (share_status, share) = post_library( + app.clone(), + &token, + "share", + json!({ + "uri": uri, + "recipients": [authority.principal_id], + "key_release_policy": "recipient_key_release", + }), + ) + .await; + assert_eq!(share_status, StatusCode::OK); + assert_eq!(share["status"], "ok"); + assert_eq!(share["data"]["policy"], "recipient_scoped"); + assert_eq!(share["data"]["key_release"]["required"], true); + assert_eq!( + share["data"]["key_release"]["status"], + "provider_receipt_chain_required" + ); + assert_eq!( + share["data"]["protected_content"]["configured_provider_count"], + 4 + ); + assert_eq!( + share["data"]["protected_content"]["encrypted_recipient_sharing"]["status"], + "provider_chain_ready" + ); + + let (access_status, access) = post_library( + app.clone(), + &token, + "shared_access", + json!({ + "uri": uri, + "recipient": authority.principal_id, + }), + ) + .await; + assert_eq!(access_status, StatusCode::OK); + assert_eq!( + access["status"], "ok", + "protected access response: {access}" + ); + assert_eq!( + access["data"]["access"]["open"]["status"], + "ready_for_protected_viewer_session" + ); + assert_eq!( + access["data"]["access"]["open"]["provider"], + "decrypt-provider" + ); + assert_eq!( + access["data"]["access"]["open"]["transport"], + "runtime-protected-provider-chain" + ); + let protected = &access["data"]["access"]["open"]["protected_content"]; + assert_eq!(protected["schema"], "elastos.library.protected-open/v1"); + assert_eq!( + protected["drm_receipt"]["schema"], + "elastos.drm.open.receipt/v1" + ); + assert_eq!( + protected["rights_receipt"]["schema"], + "elastos.rights.decision.receipt/v1" + ); + assert_eq!(protected["rights_receipt"]["allowed"], true); + assert_eq!( + protected["key_release_receipt"]["schema"], + "elastos.release.receipt/v1" + ); + assert_eq!(protected["key_release_receipt"]["provider"], "key-provider"); + assert_eq!( + protected["decrypt_session"]["schema"], + "elastos.decrypt.session/v1" + ); + assert_eq!( + protected["viewer"]["required_interface"], + "elastos.viewer/document@1" + ); + assert_eq!(protected["raw_cek_exposed"], false); + assert_eq!(protected["raw_plaintext_exposed"], false); + let protected_text = protected.to_string(); + assert!(!protected_text.contains("fixture-wrapped")); + assert!(!protected_text.contains("provider_credentials")); +} + +#[tokio::test] +async fn test_library_protected_shared_access_fails_closed_without_providers() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/protected-missing-providers.txt"); + + let (write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"protected fixture"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + + let (publish_status, publish) = post_library( + app.clone(), + &token, + "publish", + json!({ + "uri": uri, + "protected_content_fixture": true, + }), + ) + .await; + assert_eq!(publish_status, StatusCode::OK); + assert_eq!(publish["status"], "ok"); + + let (share_status, share) = post_library( + app.clone(), + &token, + "share", + json!({ + "uri": uri, + "recipients": [authority.principal_id], + "key_release_policy": "recipient_key_release", + }), + ) + .await; + assert_eq!(share_status, StatusCode::OK); + assert_eq!(share["status"], "ok"); + assert_eq!(share["data"]["key_release"]["required"], true); + + let (access_status, access) = post_library( + app, + &token, + "shared_access", + json!({ + "uri": uri, + "recipient": authority.principal_id, + }), + ) + .await; + assert_eq!(access_status, StatusCode::OK); + assert_eq!(access["status"], "error"); + assert!(access["message"] + .as_str() + .unwrap() + .contains("drm provider unavailable")); +} + +#[tokio::test] +async fn test_library_provider_unpublish_and_repair_update_status() { + let dir = tempfile::tempdir().unwrap(); + let app = gateway_router(library_test_state(dir.path()).await); + let authority = passkey_authority_with_name(dir.path(), Some("admin")); + let token = app_token_for_authority(dir.path(), LIBRARY_CAPSULE_ID, &authority); + let root = crate::auth::principal_localhost_root(&authority.principal_id); + let uri = format!("{root}/Documents/availability.txt"); + + let (write_status, _) = post_library( + app.clone(), + &token, + "write", + json!({ + "uri": uri, + "data": base64::engine::general_purpose::STANDARD.encode(b"availability"), + }), + ) + .await; + assert_eq!(write_status, StatusCode::OK); + + let (publish_status, publish) = post_library( + app.clone(), + &token, + "publish", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(publish_status, StatusCode::OK); + let revision = publish["data"]["object"]["revision"].as_str().unwrap(); + + let (unpublish_status, unpublish) = post_library( + app.clone(), + &token, + "unpublish", + json!({ + "uri": uri, + "if_revision": revision, + }), + ) + .await; + assert_eq!(unpublish_status, StatusCode::OK); + assert_eq!(unpublish["status"], "ok"); + assert_eq!(unpublish["data"]["object"]["published"], false); + assert!(unpublish["data"]["object"]["content_cid"] + .as_str() + .unwrap() + .starts_with("bafkrei")); + assert_eq!(unpublish["data"]["object"].get("published_cid"), None); + assert_eq!( + unpublish["data"]["object"]["availability"], + "local_unpinned" + ); + + let (repair_status, repair) = post_library( + app.clone(), + &token, + "repair", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(repair_status, StatusCode::OK); + assert_eq!(repair["status"], "ok"); + assert_eq!(repair["data"]["object"]["published"], true); + assert_eq!(repair["data"]["object"]["availability"], "local_pinned"); + + let (status_code, status) = post_library( + app, + &token, + "status", + json!({ + "uri": uri, + }), + ) + .await; + assert_eq!(status_code, StatusCode::OK); + assert_eq!(status["data"]["object"]["published"], true); + assert_eq!( + status["data"]["published"]["availability"]["status"], + "local_pinned" + ); +} diff --git a/elastos/crates/elastos-server/src/api/gateway_tests/mod.rs b/elastos/crates/elastos-server/src/api/gateway_tests/mod.rs index ef526d21..91aeb3f5 100644 --- a/elastos/crates/elastos-server/src/api/gateway_tests/mod.rs +++ b/elastos/crates/elastos-server/src/api/gateway_tests/mod.rs @@ -18,7 +18,7 @@ use k256::ecdsa::SigningKey as EvmSigningKey; use serde_json::json; use sha2::Sha256; use sha3::Keccak256; -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; #[cfg(unix)] use tokio::io::AsyncReadExt as _; use tokio::net::TcpListener; @@ -59,6 +59,110 @@ async fn documents_test_state(cache_dir: &std::path::Path) -> GatewayState { } } +async fn library_test_state(cache_dir: &std::path::Path) -> GatewayState { + library_test_state_with_content(cache_dir, true).await +} + +async fn library_test_state_without_content(cache_dir: &std::path::Path) -> GatewayState { + library_test_state_with_content(cache_dir, false).await +} + +async fn library_protected_content_test_state(cache_dir: &std::path::Path) -> GatewayState { + seed_test_browser_capsules(cache_dir); + let registry = Arc::new(ProviderRegistry::new()); + registry + .register_sub_provider("content", Arc::new(MockContentProvider)) + .await + .unwrap(); + registry.register(Arc::new(MockDrmProvider)).await; + registry.register(Arc::new(MockRightsProvider)).await; + registry.register(Arc::new(MockKeyProvider)).await; + registry.register(Arc::new(MockDecryptProvider)).await; + registry + .register(Arc::new(crate::library::ObjectProvider::new( + cache_dir.to_path_buf(), + Arc::downgrade(®istry), + ))) + .await; + GatewayState { + provider_registry: Some(registry), + identity_manager: Arc::new(std::sync::OnceLock::new()), + cache_dir: cache_dir.to_path_buf(), + data_dir: cache_dir.to_path_buf(), + } +} + +async fn library_external_provider_test_state(cache_dir: &std::path::Path) -> GatewayState { + seed_test_browser_capsules(cache_dir); + let registry = Arc::new(ProviderRegistry::new()); + registry + .register_sub_provider("content", Arc::new(MockContentProvider)) + .await + .unwrap(); + registry + .register(Arc::new(MockExternalObjectProvider { + data_dir: cache_dir.to_path_buf(), + })) + .await; + GatewayState { + provider_registry: Some(registry), + identity_manager: Arc::new(std::sync::OnceLock::new()), + cache_dir: cache_dir.to_path_buf(), + data_dir: cache_dir.to_path_buf(), + } +} + +async fn library_webspace_test_state(cache_dir: &std::path::Path) -> GatewayState { + seed_test_browser_capsules(cache_dir); + let registry = Arc::new(ProviderRegistry::new()); + registry + .register(Arc::new(MockExternalObjectProvider { + data_dir: cache_dir.to_path_buf(), + })) + .await; + registry + .register(Arc::new(MockWebSpaceProvider::default())) + .await; + registry + .register(Arc::new(MockWebSpaceAdapterProvider)) + .await; + registry + .register(Arc::new(MockOperatorWebSpaceAdapterProvider)) + .await; + GatewayState { + provider_registry: Some(registry), + identity_manager: Arc::new(std::sync::OnceLock::new()), + cache_dir: cache_dir.to_path_buf(), + data_dir: cache_dir.to_path_buf(), + } +} + +async fn library_test_state_with_content( + cache_dir: &std::path::Path, + with_content: bool, +) -> GatewayState { + seed_test_browser_capsules(cache_dir); + let registry = Arc::new(ProviderRegistry::new()); + if with_content { + registry + .register_sub_provider("content", Arc::new(MockContentProvider)) + .await + .unwrap(); + } + registry + .register(Arc::new(crate::library::ObjectProvider::new( + cache_dir.to_path_buf(), + Arc::downgrade(®istry), + ))) + .await; + GatewayState { + provider_registry: Some(registry), + identity_manager: Arc::new(std::sync::OnceLock::new()), + cache_dir: cache_dir.to_path_buf(), + data_dir: cache_dir.to_path_buf(), + } +} + async fn chain_test_state(cache_dir: &std::path::Path) -> GatewayState { seed_test_browser_capsules(cache_dir); let registry = Arc::new(ProviderRegistry::new()); @@ -273,6 +377,7 @@ mod documents; #[path = "../gateway_browser_route_tests.rs"] mod gateway_browser_route_tests; mod home_system; +mod library; mod recovery; mod room; mod site_publication; diff --git a/elastos/crates/elastos-server/src/api/gateway_tests/site_publication.rs b/elastos/crates/elastos-server/src/api/gateway_tests/site_publication.rs index c715cf0d..9c5e8882 100644 --- a/elastos/crates/elastos-server/src/api/gateway_tests/site_publication.rs +++ b/elastos/crates/elastos-server/src/api/gateway_tests/site_publication.rs @@ -264,6 +264,35 @@ async fn test_cid_file_fetches_through_content_provider() { assert_eq!(&body[..], b"content provider"); } +#[tokio::test] +async fn test_content_cid_root_fetches_raw_file_through_content_provider() { + let dir = tempfile::tempdir().unwrap(); + let state = content_test_state(dir.path()).await; + let app = gateway_router(state); + + let resp = app + .oneshot( + Request::builder() + .uri(format!("/content/{}", TEST_CIDV1)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + assert_eq!(ct, "application/octet-stream"); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(&body[..], b"raw-content-provider-bytes"); +} + #[tokio::test] async fn test_ipfs_cid_root_without_provider_registry_fails_closed() { let dir = tempfile::tempdir().unwrap(); diff --git a/elastos/crates/elastos-server/src/api/gateway_tests/support_providers.rs b/elastos/crates/elastos-server/src/api/gateway_tests/support_providers.rs index 2d9764e4..d6bb4ed7 100644 --- a/elastos/crates/elastos-server/src/api/gateway_tests/support_providers.rs +++ b/elastos/crates/elastos-server/src/api/gateway_tests/support_providers.rs @@ -348,6 +348,86 @@ impl Provider for MockContentProvider { } } })), + (Some("fetch"), Some(TEST_CIDV1), None) => Ok(json!({ + "status": "ok", + "data": { + "cid": TEST_CIDV1, + "path": "", + "data": base64::engine::general_purpose::STANDARD.encode(b"raw-content-provider-bytes"), + "availability": { + "status": "local_pinned", + "provider": "mock-content-provider", + "replicas": 1 + } + } + })), + (Some("publish"), _, _) => { + if request.get("object_kind").and_then(|value| value.as_str()) == Some("sealed") { + validate_mock_sealed_publish_request(request)?; + } + Ok(json!({ + "status": "ok", + "data": { + "cid": TEST_CIDV1, + "uri": format!("elastos://{}", TEST_CIDV1), + "availability": { + "status": "local_pinned", + "provider": "mock-content-provider", + "replicas": 1 + }, + "receipt": { + "schema": "elastos.content.availability.receipt/v1", + "cid": TEST_CIDV1 + } + } + })) + } + (Some("unpublish"), _, _) => Ok(json!({ + "status": "ok", + "data": { + "cid": TEST_CIDV1, + "uri": format!("elastos://{}", TEST_CIDV1), + "availability": { + "status": "local_unpinned", + "provider": "mock-content-provider", + "replicas": 0 + }, + "receipt": { + "schema": "elastos.content.availability.receipt/v1", + "cid": TEST_CIDV1, + "status": "local_unpinned" + } + } + })), + (Some("repair"), _, _) => Ok(json!({ + "status": "ok", + "data": { + "cid": TEST_CIDV1, + "uri": format!("elastos://{}", TEST_CIDV1), + "availability": { + "status": "local_pinned", + "provider": "mock-content-provider", + "replicas": 1 + }, + "receipt": { + "schema": "elastos.content.availability.receipt/v1", + "cid": TEST_CIDV1, + "status": "local_pinned" + } + } + })), + (Some("status"), _, _) => Ok(json!({ + "status": "ok", + "data": { + "cid": TEST_CIDV1, + "uri": format!("elastos://{}", TEST_CIDV1), + "availability": { + "status": "local_pinned", + "provider": "mock-content-provider", + "replicas": 1 + } + } + })), _ => Ok(json!({ "status": "error", "code": "not_found", @@ -357,6 +437,1225 @@ impl Provider for MockContentProvider { } } +fn validate_mock_sealed_publish_request(request: &serde_json::Value) -> Result<(), ProviderError> { + let files = request + .get("files") + .and_then(|value| value.as_array()) + .ok_or_else(|| ProviderError::Provider("sealed publish files are required".into()))?; + let sealed_entry = files + .iter() + .find(|entry| entry.get("path").and_then(|value| value.as_str()) == Some("sealed.json")) + .ok_or_else(|| ProviderError::Provider("sealed publish requires sealed.json".into()))?; + let sealed_data = sealed_entry + .get("data") + .and_then(|value| value.as_str()) + .ok_or_else(|| ProviderError::Provider("sealed.json data is required".into()))?; + let sealed_bytes = base64::engine::general_purpose::STANDARD + .decode(sealed_data) + .map_err(|err| ProviderError::Provider(err.to_string()))?; + let sealed_object: elastos_common::protected_content::SealedObjectV1 = + serde_json::from_slice(&sealed_bytes) + .map_err(|err| ProviderError::Provider(err.to_string()))?; + let links = request + .get("links") + .and_then(|value| value.as_array()) + .ok_or_else(|| ProviderError::Provider("sealed publish links are required".into()))?; + for (rel, cid) in [ + ("availability.receipt", sealed_object.availability_receipt_cid.as_str()), + ("payload", sealed_object.payload_cid.as_str()), + ("rights.policy", sealed_object.rights_policy_cid.as_str()), + ] { + if !links.iter().any(|link| { + link.get("rel").and_then(|value| value.as_str()) == Some(rel) + && link.get("cid").and_then(|value| value.as_str()) == Some(cid) + }) { + return Err(ProviderError::Provider(format!( + "sealed publish missing {rel} link" + ))); + } + } + if !links + .iter() + .any(|link| link.get("rel").and_then(|value| value.as_str()) == Some("provenance")) + { + return Err(ProviderError::Provider( + "sealed publish missing provenance link".into(), + )); + } + if serde_json::to_string(&sealed_object) + .map_err(|err| ProviderError::Provider(err.to_string()))? + .contains("raw_cek") + { + return Err(ProviderError::Provider( + "sealed publish must not expose raw CEK".into(), + )); + } + Ok(()) +} + +struct MockDrmProvider; +struct MockRightsProvider; +struct MockKeyProvider; +struct MockDecryptProvider; + +#[async_trait::async_trait] +impl Provider for MockDrmProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "mock drm provider only supports raw requests".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["drm"] + } + + fn name(&self) -> &'static str { + "mock-drm-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + match request.get("op").and_then(|value| value.as_str()) { + Some("status") => Ok(json!({ + "status": "ok", + "data": { + "provider": "drm", + "configured": true, + "supported_operations": ["status", "open"], + "blocked_authority": ["raw_cek", "chain_rpc", "wallet_rpc"], + "contract": { + "schema": "elastos.protected-content.drm-provider/v1", + "fixture": true + } + } + })), + Some("open") => { + let request = request + .get("request") + .ok_or_else(|| ProviderError::Provider("drm request is required".into()))?; + let object = request + .get("object") + .ok_or_else(|| ProviderError::Provider("sealed object is required".into()))?; + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.drm.open.receipt/v1", + "provider": "drm-provider", + "status": "accepted", + "payload_cid": object + .get("payload_cid") + .and_then(|value| value.as_str()) + .unwrap_or(TEST_CIDV1), + "principal_id": required_test_str(request, "principal_id")?, + "session_id": required_test_str(request, "session_id")?, + "action": required_test_str(request, "action")?, + "fixture": true + } + })) + } + _ => Ok(json!({ + "status": "error", + "code": "unsupported", + "message": "unsupported mock drm op" + })), + } + } +} + +#[async_trait::async_trait] +impl Provider for MockRightsProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "mock rights provider only supports raw requests".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["rights"] + } + + fn name(&self) -> &'static str { + "mock-rights-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + match request.get("op").and_then(|value| value.as_str()) { + Some("status") => Ok(json!({ + "status": "ok", + "data": { + "provider": "rights", + "configured": true, + "supported_operations": ["status", "has_access_by_content_id"], + "blocked_authority": ["chain_rpc", "wallet_rpc", "raw_cek"], + "contract": { + "schema": "elastos.protected-content.rights-provider/v1", + "fixture": true + } + } + })), + Some("has_access_by_content_id") => { + let request = request + .get("request") + .ok_or_else(|| ProviderError::Provider("rights request is required".into()))?; + let content_id = required_test_str(request, "content_id")?; + let principal_id = required_test_str(request, "principal_id")?; + let session_id = required_test_str(request, "session_id")?; + let right = required_test_str(request, "right")?; + let allowed = right == "view" && !principal_id.contains("blocked"); + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.rights.decision.receipt/v1", + "request_id": "rights:fixture", + "content_id": content_id, + "principal_id": principal_id, + "session_id": session_id, + "right": right, + "provider": "rights-provider", + "allowed": allowed, + "issued_at": 1_800_000_000u64, + "expires_at": 1_900_000_000u64 + } + })) + } + _ => Ok(json!({ + "status": "error", + "code": "unsupported", + "message": "unsupported mock rights op" + })), + } + } +} + +#[async_trait::async_trait] +impl Provider for MockKeyProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "mock key provider only supports raw requests".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["key"] + } + + fn name(&self) -> &'static str { + "mock-key-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + match request.get("op").and_then(|value| value.as_str()) { + Some("status") => Ok(json!({ + "status": "ok", + "data": { + "provider": "key", + "configured": true, + "supported_operations": ["status", "release"], + "blocked_authority": ["raw_cek", "kms_node_credentials"], + "contract": { + "schema": "elastos.protected-content.key-provider/v1", + "fixture": true + } + } + })), + Some("release") => { + let request = request + .get("request") + .ok_or_else(|| ProviderError::Provider("key request is required".into()))?; + if request + .get("rights_receipt") + .and_then(|receipt| receipt.get("allowed")) + .and_then(|value| value.as_bool()) + != Some(true) + { + return Ok(json!({ + "status": "error", + "code": "denied", + "message": "rights receipt denied key release" + })); + } + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.release.receipt/v1", + "request_id": required_test_str(request, "request_id")?, + "object_cid": required_test_str(request, "object_cid")?, + "principal_id": required_test_str(request, "principal_id")?, + "session_id": required_test_str(request, "session_id")?, + "action": required_test_str(request, "action")?, + "provider": "key-provider", + "status": "released", + "issued_at": 1_800_000_000u64, + "expires_at": request + .get("expires_at") + .and_then(|value| value.as_u64()) + .unwrap_or(1_900_000_000u64) + } + })) + } + _ => Ok(json!({ + "status": "error", + "code": "unsupported", + "message": "unsupported mock key op" + })), + } + } +} + +#[async_trait::async_trait] +impl Provider for MockDecryptProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "mock decrypt provider only supports raw requests".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["decrypt"] + } + + fn name(&self) -> &'static str { + "mock-decrypt-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + match request.get("op").and_then(|value| value.as_str()) { + Some("status") => Ok(json!({ + "status": "ok", + "data": { + "provider": "decrypt", + "configured": true, + "supported_operations": ["status", "open_session"], + "blocked_authority": ["raw_cek", "raw_plaintext", "filesystem"], + "contract": { + "schema": "elastos.protected-content.decrypt-provider/v1", + "fixture": true + } + } + })), + Some("open_session") => { + let request = request + .get("request") + .ok_or_else(|| ProviderError::Provider("decrypt request is required".into()))?; + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.decrypt.session/v1", + "session_id": "decrypt-session:fixture", + "object_cid": required_test_str(request, "object_cid")?, + "viewer_interface": required_test_str(request, "viewer_interface")?, + "output": "viewer_capsule_session:fixture", + "expires_at": request + .get("expires_at") + .and_then(|value| value.as_u64()) + .unwrap_or(1_900_000_000u64) + } + })) + } + _ => Ok(json!({ + "status": "error", + "code": "unsupported", + "message": "unsupported mock decrypt op" + })), + } + } +} + +struct MockExternalObjectProvider { + data_dir: std::path::PathBuf, +} + +#[async_trait::async_trait] +impl Provider for MockExternalObjectProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "mock external object provider only supports raw requests".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["object"] + } + + fn name(&self) -> &'static str { + "mock-external-object-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + Ok(crate::library::handle_object_provider_raw_request( + &self.data_dir, + request, + )) + } +} + +#[derive(Clone)] +struct MockCachedWebSpaceObject { + bytes: Vec, + sync_state: &'static str, +} + +#[derive(Default)] +struct MockWebSpaceProvider { + cached: std::sync::Mutex>, +} + +struct MockWebSpaceAdapterProvider; +struct MockOperatorWebSpaceAdapterProvider; + +fn mock_operator_archive_zip_bytes() -> Vec { + use std::io::Write as _; + + let cursor = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + let options = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + writer.start_file("alpha.txt", options).unwrap(); + writer.write_all(b"zip alpha").unwrap(); + writer.add_directory("Nested/", options).unwrap(); + writer.start_file("Nested/deep.txt", options).unwrap(); + writer.write_all(b"zip nested").unwrap(); + writer.finish().unwrap().into_inner() +} + +impl MockWebSpaceProvider { + fn cached_object(&self, path: &str) -> Option { + self.cached.lock().ok()?.get(path).cloned() + } + + fn store_cached_object( + &self, + path: &str, + request: &serde_json::Value, + ) -> Result { + let bytes = request + .get("content") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| ProviderError::Provider("mock cache missing content".into()))? + .iter() + .map(|value| { + value + .as_u64() + .filter(|byte| *byte <= u8::MAX as u64) + .map(|byte| byte as u8) + .ok_or_else(|| ProviderError::Provider("mock cache byte out of range".into())) + }) + .collect::, _>>()?; + let cached = MockCachedWebSpaceObject { + bytes, + sync_state: "manual_idle", + }; + self.cached + .lock() + .map_err(|_| ProviderError::Provider("mock cache lock poisoned".into()))? + .insert(path.to_string(), cached.clone()); + Ok(cached) + } + + fn store_written_object( + &self, + path: &str, + request: &serde_json::Value, + ) -> Result { + let bytes = request + .get("content") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| ProviderError::Provider("mock write missing content".into()))? + .iter() + .map(|value| { + value + .as_u64() + .filter(|byte| *byte <= u8::MAX as u64) + .map(|byte| byte as u8) + .ok_or_else(|| ProviderError::Provider("mock write byte out of range".into())) + }) + .collect::, _>>()?; + let cached = MockCachedWebSpaceObject { + bytes, + sync_state: "manual_pending", + }; + self.cached + .lock() + .map_err(|_| ProviderError::Provider("mock cache lock poisoned".into()))? + .insert(path.to_string(), cached.clone()); + Ok(cached) + } + + fn mark_synced(&self, path: &str) -> Result, ProviderError> { + let mut cached = self + .cached + .lock() + .map_err(|_| ProviderError::Provider("mock cache lock poisoned".into()))?; + let Some(object) = cached.get_mut(path) else { + return Ok(None); + }; + object.sync_state = "manual_synced"; + Ok(Some(object.clone())) + } +} + +#[async_trait::async_trait] +impl Provider for MockWebSpaceProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "mock WebSpace provider only supports raw requests".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["webspace"] + } + + fn name(&self) -> &'static str { + "mock-webspace-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + let path = request + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or("localhost://WebSpaces"); + match request.get("op").and_then(|value| value.as_str()) { + Some("list") if path == "localhost://WebSpaces" => Ok(json!({ + "status": "ok", + "data": [ + { + "name": "Elastos", + "is_file": false, + "is_dir": true, + "size": 0, + "provider": "mock-webspace-provider", + "resolver_state": "resolved", + "resolver": "builtin", + "cache_policy": "metadata-only", + "sync_policy": "manual", + "object_id": "object:webspace:elastos", + "head_id": "head:webspace:elastos", + "cache_state": "metadata_cached", + "sync_state": "manual_idle", + "kind": "dynamic-webspace", + "readonly": true + }, + { + "name": "Google", + "is_file": false, + "is_dir": true, + "size": 0, + "target_uri": "google://drive", + "provider": "mock-webspace-provider", + "resolver_state": "mounted-readonly", + "resolver": "google-drive", + "cache_policy": "metadata-and-thumbnails", + "sync_policy": "manual", + "object_id": "object:webspace:google", + "head_id": "head:webspace:google", + "cache_state": "metadata_cached", + "sync_state": "manual_idle", + "kind": "mounted-webspace", + "readonly": true + }, + { + "name": "Operator", + "is_file": false, + "is_dir": true, + "size": 0, + "target_uri": "operator://drive", + "provider": "mock-webspace-provider", + "resolver_state": "mounted-readonly", + "resolver": "operator-drive", + "cache_policy": "metadata-and-bytes", + "sync_policy": "manual", + "object_id": "object:webspace:operator", + "head_id": "head:webspace:operator", + "cache_state": "metadata_cached", + "sync_state": "manual_idle", + "kind": "mounted-webspace", + "readonly": true + }, + { + "name": "OperatorMutable", + "is_file": false, + "is_dir": true, + "size": 0, + "target_uri": "operator://drive/Writable", + "provider": "mock-webspace-provider", + "resolver_state": "mounted-mutable", + "resolver": "operator-drive", + "cache_policy": "metadata-and-bytes", + "sync_policy": "manual", + "object_id": "object:webspace:operator-mutable", + "head_id": "head:webspace:operator-mutable", + "cache_state": "content_cached", + "sync_state": "manual_idle", + "kind": "mounted-webspace", + "readonly": false, + "access_policy": "owner-writable" + }, + { + "name": "Mutable", + "is_file": false, + "is_dir": true, + "size": 0, + "target_uri": "local://mutable", + "provider": "mock-webspace-provider", + "resolver_state": "mounted-mutable", + "resolver": "local-materialized", + "cache_policy": "metadata-and-bytes", + "sync_policy": "manual", + "object_id": "object:webspace:mutable", + "head_id": "head:webspace:mutable", + "cache_state": "content_cached", + "sync_state": "manual_idle", + "kind": "mounted-webspace", + "readonly": false, + "access_policy": "owner-writable" + } + ] + })), + Some("list") if path == "localhost://WebSpaces/Elastos" => Ok(json!({ + "status": "ok", + "data": [ + { "name": "_meta.json", "is_file": true, "is_dir": false, "size": 96, "resolver": "builtin", "cache_policy": "metadata-only", "sync_policy": "manual", "object_id": "object:webspace:elastos-meta", "head_id": "head:webspace:elastos-meta", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "metadata" }, + { "name": "content", "is_file": false, "is_dir": true, "size": 0, "target_uri": "elastos://", "resolver": "builtin", "cache_policy": "metadata-only", "sync_policy": "manual", "object_id": "object:webspace:content", "head_id": "head:webspace:content", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "folder-handle" }, + { "name": "peer", "is_file": false, "is_dir": true, "size": 0, "target_uri": "elastos://peer/", "resolver": "builtin", "cache_policy": "metadata-only", "sync_policy": "manual", "object_id": "object:webspace:peer", "head_id": "head:webspace:peer", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "folder-handle" }, + { "name": "did", "is_file": false, "is_dir": true, "size": 0, "target_uri": "elastos://did/", "resolver": "builtin", "cache_policy": "metadata-only", "sync_policy": "manual", "object_id": "object:webspace:did", "head_id": "head:webspace:did", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "folder-handle" }, + { "name": "ai", "is_file": false, "is_dir": true, "size": 0, "target_uri": "elastos://ai/", "resolver": "builtin", "cache_policy": "metadata-only", "sync_policy": "manual", "object_id": "object:webspace:ai", "head_id": "head:webspace:ai", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "folder-handle" } + ] + })), + Some("list") if path == "localhost://WebSpaces/Google" => Ok(json!({ + "status": "ok", + "data": [ + { "name": "_meta.json", "is_file": true, "is_dir": false, "size": 96, "resolver": "google-drive", "cache_policy": "metadata-and-thumbnails", "sync_policy": "manual", "object_id": "object:webspace:google-meta", "head_id": "head:webspace:google-meta", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "metadata" }, + { "name": "Drive", "is_file": false, "is_dir": true, "size": 0, "target_uri": "google://drive/Drive", "resolver": "google-drive", "resolver_state": "indexed", "cache_policy": "metadata-and-thumbnails", "sync_policy": "manual", "object_id": "object:webspace:google-drive", "head_id": "head:webspace:google-drive", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "indexed-directory", "readonly": true }, + { "name": "Shared", "is_file": false, "is_dir": true, "size": 0, "target_uri": "google://drive/shared", "resolver": "google-drive", "resolver_state": "indexed-virtual", "cache_policy": "metadata-and-thumbnails", "sync_policy": "manual", "object_id": "object:webspace:google-shared", "head_id": "head:webspace:google-shared", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "indexed-directory", "readonly": true } + ] + })), + Some("list") if path == "localhost://WebSpaces/Google/Drive" => Ok(json!({ + "status": "ok", + "data": [ + { "name": "_meta.json", "is_file": true, "is_dir": false, "size": 96, "resolver": "google-drive", "cache_policy": "metadata-and-thumbnails", "sync_policy": "manual", "object_id": "object:webspace:google-drive-meta", "head_id": "head:webspace:google-drive-meta", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "metadata" }, + { "name": "Project X", "is_file": false, "is_dir": true, "size": 0, "target_uri": "google://drive/Drive/Project X", "resolver": "google-drive", "resolver_state": "indexed-virtual", "cache_policy": "metadata-and-thumbnails", "sync_policy": "manual", "object_id": "object:webspace:google-project", "head_id": "head:webspace:google-project", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "indexed-directory", "readonly": true } + ] + })), + Some("list") if path == "localhost://WebSpaces/Google/Drive/Project X" => Ok(json!({ + "status": "ok", + "data": [ + { "name": "_meta.json", "is_file": true, "is_dir": false, "size": 96, "resolver": "google-drive", "cache_policy": "metadata-and-thumbnails", "sync_policy": "manual", "object_id": "object:webspace:google-project-meta", "head_id": "head:webspace:google-project-meta", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "metadata" }, + { "name": "file.pdf", "is_file": true, "is_dir": false, "size": 256, "target_uri": "google://drive/Drive/Project X/file.pdf", "resolver": "google-drive", "resolver_state": "indexed", "cache_policy": "metadata-and-thumbnails", "sync_policy": "manual", "object_id": "object:webspace:google-project-file", "head_id": "head:webspace:google-project-file", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "indexed-file", "readonly": true } + ] + })), + Some("list") if path == "localhost://WebSpaces/Operator" => Ok(json!({ + "status": "ok", + "data": [ + { "name": "_meta.json", "is_file": true, "is_dir": false, "size": 96, "resolver": "operator-drive", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:operator-meta", "head_id": "head:webspace:operator-meta", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "metadata" }, + { "name": "Projects", "is_file": false, "is_dir": true, "size": 0, "target_uri": "operator://drive/Projects", "resolver": "operator-drive", "resolver_state": "indexed", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:operator-projects", "head_id": "head:webspace:operator-projects", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "indexed-directory", "readonly": true } + ] + })), + Some("list") if path == "localhost://WebSpaces/Operator/Projects" => Ok(json!({ + "status": "ok", + "data": [ + { "name": "_meta.json", "is_file": true, "is_dir": false, "size": 96, "resolver": "operator-drive", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:operator-projects-meta", "head_id": "head:webspace:operator-projects-meta", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "metadata" }, + { "name": "Brief.md", "is_file": true, "is_dir": false, "size": 512, "target_uri": "operator://drive/Projects/Brief.md", "resolver": "operator-drive", "resolver_state": "indexed", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:operator-brief", "head_id": "head:webspace:operator-brief", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "indexed-file", "readonly": true }, + { "name": "Bundle.zip", "is_file": true, "is_dir": false, "size": mock_operator_archive_zip_bytes().len(), "target_uri": "operator://drive/Projects/Bundle.zip", "resolver": "operator-drive", "resolver_state": "indexed", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:operator-bundle", "head_id": "head:webspace:operator-bundle", "cache_state": "metadata_cached", "sync_state": "manual_idle", "kind": "indexed-file", "readonly": true } + ] + })), + Some("list") if path == "localhost://WebSpaces/OperatorMutable" => Ok(json!({ + "status": "ok", + "data": [ + { "name": "_meta.json", "is_file": true, "is_dir": false, "size": 96, "resolver": "operator-drive", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:operator-mutable-meta", "head_id": "head:webspace:operator-mutable-meta", "cache_state": "content_cached", "sync_state": "manual_idle", "kind": "metadata", "readonly": true, "access_policy": "resolver-readonly" }, + { "name": "Folder", "is_file": false, "is_dir": true, "size": 0, "target_uri": "operator://drive/Writable/Folder", "resolver": "operator-drive", "resolver_state": "materialized-local", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:operator-mutable-folder", "head_id": "head:webspace:operator-mutable-folder", "cache_state": "content_cached", "sync_state": "manual_idle", "kind": "materialized-directory", "readonly": false, "access_policy": "owner-writable" } + ] + })), + Some("list") if path == "localhost://WebSpaces/Mutable" => Ok(json!({ + "status": "ok", + "data": [ + { "name": "_meta.json", "is_file": true, "is_dir": false, "size": 96, "resolver": "local-materialized", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:mutable-meta", "head_id": "head:webspace:mutable-meta", "cache_state": "content_cached", "sync_state": "manual_idle", "kind": "metadata", "readonly": true, "access_policy": "resolver-readonly" }, + { "name": "Folder", "is_file": false, "is_dir": true, "size": 0, "target_uri": "local://mutable/Folder", "resolver": "local-materialized", "resolver_state": "materialized-local", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:mutable-folder", "head_id": "head:webspace:mutable-folder", "cache_state": "content_cached", "sync_state": "manual_pending", "kind": "materialized-directory", "readonly": false, "access_policy": "owner-writable" } + ] + })), + Some("list") if path == "localhost://WebSpaces/Mutable/Folder" => Ok(json!({ + "status": "ok", + "data": [ + { "name": "_meta.json", "is_file": true, "is_dir": false, "size": 96, "resolver": "local-materialized", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:mutable-folder-meta", "head_id": "head:webspace:mutable-folder-meta", "cache_state": "content_cached", "sync_state": "manual_idle", "kind": "metadata", "readonly": true, "access_policy": "resolver-readonly" }, + { "name": "note.txt", "is_file": true, "is_dir": false, "size": 13, "target_uri": "local://mutable/Folder/note.txt", "resolver": "local-materialized", "resolver_state": "materialized-local", "cache_policy": "metadata-and-bytes", "sync_policy": "manual", "object_id": "object:webspace:mutable-note", "head_id": "head:webspace:mutable-note", "cache_state": "content_cached", "sync_state": "manual_pending", "kind": "materialized-file", "readonly": false, "access_policy": "owner-writable" } + ] + })), + Some("list") if path == "localhost://WebSpaces/Elastos/content" => Ok(json!({ + "status": "ok", + "data": [ + { + "name": TEST_CIDV1, + "is_file": true, + "is_dir": false, + "size": 128, + "target_uri": format!("elastos://{TEST_CIDV1}"), + "provider": "content-provider", + "resolver_state": "resolved", + "resolver": "builtin", + "cache_policy": "metadata-only", + "sync_policy": "manual", + "object_id": "object:webspace:content-test-cid", + "head_id": "head:webspace:content-test-cid", + "cache_state": "metadata_cached", + "sync_state": "manual_idle", + "kind": "file-endpoint", + "readonly": true + } + ] + })), + Some("stat") => { + let stat = self + .cached_object(path) + .map(|cached| mock_cached_webspace_stat(path, &cached)) + .unwrap_or_else(|| mock_webspace_stat(path)); + Ok(json!({ + "status": "ok", + "data": stat + })) + }, + Some("health") => Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.health/v1", + "state": "metadata_ready", + "mounts": [ + { + "moniker": "Google", + "resolver": "google-drive", + "live_adapter": true, + "adapter_state": "connected", + "adapter": { + "schema": "elastos.webspace.adapter/v1", + "resolver": "google-drive", + "provider": "google-drive-adapter", + "state": "connected", + "live": true, + "capabilities": ["metadata_index", "read_bytes"] + } + }, + { + "moniker": "Operator", + "resolver": "operator-drive", + "live_adapter": true, + "adapter_state": "connected", + "adapter": { + "schema": "elastos.webspace.adapter/v1", + "resolver": "operator-drive", + "provider": "operator-drive-adapter", + "state": "connected", + "live": true, + "capabilities": ["metadata_index", "read_bytes", "write_bytes"] + } + }, + { + "moniker": "OperatorMutable", + "resolver": "operator-drive", + "live_adapter": true, + "adapter_state": "connected", + "adapter": { + "schema": "elastos.webspace.adapter/v1", + "resolver": "operator-drive", + "provider": "operator-drive-adapter", + "state": "connected", + "live": true, + "capabilities": ["metadata_index", "read_bytes", "write_bytes"] + } + } + ] + } + })), + Some("refresh") => Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.refresh-receipt/v1", + "action": "refreshed", + "handle_uri": path, + "byte_materialized": false + } + })), + Some("cache") => { + let cached = self.store_cached_object(path, request)?; + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.cache-receipt/v1", + "action": "content_cached", + "handle_uri": path, + "content_cached": true, + "dirty": false, + "size": cached.bytes.len() + } + })) + }, + Some("write") + if path.starts_with("localhost://WebSpaces/Mutable/") + || path.starts_with("localhost://WebSpaces/OperatorMutable/") => + { + let cached = self.store_written_object(path, request)?; + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.write-receipt/v1", + "action": "written", + "handle_uri": path, + "byte_materialized": true, + "size": cached.bytes.len() + } + })) + } + Some("mkdir") + if path.starts_with("localhost://WebSpaces/Mutable/") + || path.starts_with("localhost://WebSpaces/OperatorMutable/") => + { + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.mkdir-receipt/v1", + "action": "created", + "handle_uri": path + } + })) + } + Some("delete") + if path.starts_with("localhost://WebSpaces/Mutable/") + || path.starts_with("localhost://WebSpaces/OperatorMutable/") => + { + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.delete-receipt/v1", + "action": "deleted", + "handle_uri": path, + "removed_count": 1 + } + })) + } + Some("sync") + if path.starts_with("localhost://WebSpaces/Mutable/") + || path.starts_with("localhost://WebSpaces/OperatorMutable/") => + { + let synced = self.mark_synced(path)?.ok_or_else(|| { + ProviderError::Provider("mock sync target was not materialized".into()) + })?; + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.sync-receipt/v1", + "action": "resolver_synced", + "handle_uri": path, + "content_synced": true, + "dirty": false, + "size": synced.bytes.len() + } + })) + } + Some("write" | "mkdir" | "delete") => Ok(json!({ + "status": "error", + "code": "readonly", + "message": "built-in or readonly WebSpace is resolver-owned and read-only" + })), + Some("read") if self.cached_object(path).is_some() => { + let bytes = self.cached_object(path).unwrap().bytes; + let size = bytes.len(); + Ok(json!({ + "status": "ok", + "data": { + "content": bytes, + "size": size + } + })) + }, + Some("read") if path.ends_with("_meta.json") || path.contains("/content/") => { + let bytes = serde_json::to_vec_pretty(&json!({ + "handle_uri": path.trim_end_matches("/_meta.json"), + "target_uri": if path.contains("/content/") { + Some(format!( + "elastos://{}", + path.rsplit('/').next().unwrap_or_default() + )) + } else { + None + }, + "resolver_state": "resolved", + "resolver": "builtin", + "cache_policy": "metadata-only", + "sync_policy": "manual", + "object_id": "object:webspace:read", + "head_id": "head:webspace:read", + "cache_state": "metadata_cached", + "sync_state": "manual_idle" + })) + .unwrap(); + Ok(json!({ + "status": "ok", + "data": { + "content": bytes, + "size": bytes.len() + } + })) + } + Some("read") if path.starts_with("localhost://WebSpaces/Mutable/") => { + let bytes = b"mutable bytes".to_vec(); + let size = bytes.len(); + Ok(json!({ + "status": "ok", + "data": { + "content": bytes, + "size": size + } + })) + } + Some(op) => Ok(json!({ + "status": "error", + "code": "unsupported", + "message": format!("unsupported mock WebSpace op: {op}") + })), + None => Ok(json!({ + "status": "error", + "code": "invalid_request", + "message": "missing mock WebSpace op" + })), + } + } +} + +#[async_trait::async_trait] +impl Provider for MockWebSpaceAdapterProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "mock WebSpace adapter provider only supports raw requests".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["google-drive-adapter"] + } + + fn name(&self) -> &'static str { + "mock-webspace-adapter-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + if request.get("_runtime_invocation").is_none() { + return Ok(json!({ + "status": "error", + "code": "missing_runtime_invocation", + "message": "WebSpace adapter requires Runtime provider invocation" + })); + } + match request.get("op").and_then(|value| value.as_str()) { + Some("metadata_index") => Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.adapter.metadata-index/v1", + "entries": [ + { + "path": "Drive/Project X/file.pdf", + "kind": "file", + "target_uri": "google://drive/Drive/Project X/file.pdf", + "resolver_state": "indexed", + "readonly": true, + "description": "Adapter indexed Google Drive file." + } + ], + "receipt": { + "schema": "elastos.webspace.adapter.metadata-index-receipt/v1", + "resolver": "google-drive" + } + } + })), + Some("read_bytes") => Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.adapter.read-bytes/v1", + "data": base64::engine::general_purpose::STANDARD.encode(b"google adapter bytes"), + "mime": "application/pdf", + "receipt": { + "schema": "elastos.webspace.adapter.read-bytes-receipt/v1", + "resolver": "google-drive", + "target_uri": request.get("target_uri").cloned() + } + } + })), + Some(op) => Ok(json!({ + "status": "error", + "code": "unsupported", + "message": format!("unsupported mock WebSpace adapter op: {op}") + })), + None => Ok(json!({ + "status": "error", + "code": "invalid_request", + "message": "missing mock WebSpace adapter op" + })), + } + } +} + +#[async_trait::async_trait] +impl Provider for MockOperatorWebSpaceAdapterProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "mock operator WebSpace adapter only supports raw requests".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["operator-drive-adapter"] + } + + fn name(&self) -> &'static str { + "mock-operator-webspace-adapter" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + if request.get("_runtime_invocation").is_none() { + return Ok(json!({ + "status": "error", + "code": "missing_runtime_invocation", + "message": "Operator WebSpace adapter requires Runtime provider invocation" + })); + } + match request.get("op").and_then(|value| value.as_str()) { + Some("metadata_index") => Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.adapter.metadata-index/v1", + "entries": [ + { + "path": "Projects/Brief.md", + "kind": "file", + "target_uri": "operator://drive/Projects/Brief.md", + "resolver_state": "indexed", + "readonly": true, + "description": "Operator fixture indexed markdown brief." + }, + { + "path": "Projects/Bundle.zip", + "kind": "file", + "target_uri": "operator://drive/Projects/Bundle.zip", + "resolver_state": "indexed", + "readonly": true, + "description": "Operator fixture indexed archive bundle." + } + ], + "receipt": { + "schema": "elastos.webspace.adapter.metadata-index-receipt/v1", + "resolver": "operator-drive", + "operator_fixture": true + } + } + })), + Some("read_bytes") => { + let target_uri = request + .get("target_uri") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let (bytes, mime) = if target_uri.ends_with("/Bundle.zip") { + (mock_operator_archive_zip_bytes(), "application/zip") + } else { + ( + b"# Operator Brief\n\nAdapter-backed bytes.\n".to_vec(), + "text/plain", + ) + }; + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.adapter.read-bytes/v1", + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + "mime": mime, + "receipt": { + "schema": "elastos.webspace.adapter.read-bytes-receipt/v1", + "resolver": "operator-drive", + "operator_fixture": true, + "target_uri": request.get("target_uri").cloned() + } + } + })) + } + Some("write_bytes") => { + let target_uri = request + .get("target_uri") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if target_uri.contains("Conflict") { + return Ok(json!({ + "status": "error", + "code": "conflict", + "message": "operator fixture rejected stale mutable fork write", + "data": { + "schema": "elastos.webspace.adapter.write-conflict/v1", + "resolver": "operator-drive", + "target_uri": target_uri, + "reason": "head_mismatch" + } + })); + } + Ok(json!({ + "status": "ok", + "data": { + "schema": "elastos.webspace.adapter.write-bytes/v1", + "receipt": { + "schema": "elastos.webspace.adapter.write-bytes-receipt/v1", + "resolver": "operator-drive", + "operator_fixture": true, + "target_uri": target_uri, + "bytes_accepted": request + .get("data") + .and_then(serde_json::Value::as_str) + .and_then(|encoded| base64::engine::general_purpose::STANDARD.decode(encoded).ok()) + .map(|bytes| bytes.len()) + .unwrap_or(0) + } + } + })) + } + Some(op) => Ok(json!({ + "status": "error", + "code": "unsupported", + "message": format!("unsupported mock operator WebSpace adapter op: {op}") + })), + None => Ok(json!({ + "status": "error", + "code": "invalid_request", + "message": "missing mock operator WebSpace adapter op" + })), + } + } +} + +fn mock_webspace_stat(path: &str) -> serde_json::Value { + let is_google_file = path == "localhost://WebSpaces/Google/Drive/Project X/file.pdf"; + let is_operator_mutable = path.starts_with("localhost://WebSpaces/OperatorMutable"); + let is_operator = path.starts_with("localhost://WebSpaces/Operator") && !is_operator_mutable; + let is_operator_file = path == "localhost://WebSpaces/Operator/Projects/Brief.md"; + let is_operator_archive_file = path == "localhost://WebSpaces/Operator/Projects/Bundle.zip"; + let is_operator_projects_dir = path == "localhost://WebSpaces/Operator/Projects"; + let is_mutable = path.starts_with("localhost://WebSpaces/Mutable"); + let is_mutable_file = is_mutable && path.ends_with(".txt"); + let is_operator_mutable_file = + is_operator_mutable && (path.ends_with(".txt") || path.ends_with(".md")); + let is_file = path.ends_with("_meta.json") + || path.contains("/content/") + || is_google_file + || is_operator_file + || is_operator_archive_file + || is_operator_mutable_file + || is_mutable_file; + let is_google = path.starts_with("localhost://WebSpaces/Google"); + json!({ + "path": path, + "is_file": is_file, + "is_dir": !is_file, + "size": if is_mutable_file { 13 } else if is_operator_mutable_file { 0 } else if is_file { 128 } else { 0 }, + "readonly": !(is_mutable || is_operator_mutable), + "access_policy": if is_mutable || is_operator_mutable { "owner-writable" } else { "resolver-readonly" }, + "target_uri": if is_google_file { + Some("google://drive/Drive/Project X/file.pdf".to_string()) + } else if is_operator_archive_file { + Some("operator://drive/Projects/Bundle.zip".to_string()) + } else if is_google { + Some(path.replacen("localhost://WebSpaces/Google", "google://drive", 1)) + } else if is_operator_mutable { + Some(path.replacen("localhost://WebSpaces/OperatorMutable", "operator://drive/Writable", 1)) + } else if is_operator { + Some(path.replacen("localhost://WebSpaces/Operator", "operator://drive", 1)) + } else if is_mutable { + Some(path.replacen("localhost://WebSpaces/Mutable", "local://mutable", 1)) + } else if path.contains("/content/") { + Some(format!( + "elastos://{}", + path.rsplit('/').next().unwrap_or_default() + )) + } else { + None + }, + "provider": if path.contains("/content/") { "content-provider" } else { "mock-webspace-provider" }, + "resolver_state": if is_mutable || is_operator_mutable { + if path == "localhost://WebSpaces/Mutable" || path == "localhost://WebSpaces/OperatorMutable" { "mounted-mutable" } else { "materialized-local" } + } else if is_operator_file || is_operator_archive_file || is_operator_projects_dir { + "indexed" + } else if is_operator { + "indexed-virtual" + } else if is_google_file { "indexed" } else if is_google { "indexed-virtual" } else { "resolved" }, + "resolver": if is_mutable { "local-materialized" } else if is_operator || is_operator_mutable { "operator-drive" } else if is_google { "google-drive" } else { "builtin" }, + "cache_policy": if is_mutable || is_operator || is_operator_mutable { "metadata-and-bytes" } else if is_google { "metadata-and-thumbnails" } else { "metadata-only" }, + "sync_policy": "manual", + "object_id": format!("object:webspace:{}", path.replace('/', ":")), + "head_id": format!("head:webspace:{}", path.replace('/', ":")), + "cache_state": if is_mutable || is_operator_mutable { "content_cached" } else { "metadata_cached" }, + "sync_state": if is_mutable || is_operator_mutable { "manual_pending" } else { "manual_idle" }, + "kind": if path.ends_with("_meta.json") { + "metadata" + } else if is_mutable_file || is_operator_mutable_file { + "materialized-file" + } else if is_operator_mutable && path == "localhost://WebSpaces/OperatorMutable" { + "mounted-webspace" + } else if is_operator_mutable { + "materialized-directory" + } else if is_mutable && path == "localhost://WebSpaces/Mutable" { + "mounted-webspace" + } else if is_mutable { + "materialized-directory" + } else if is_operator_file || is_operator_archive_file { + "indexed-file" + } else if is_operator && path == "localhost://WebSpaces/Operator" { + "mounted-webspace" + } else if is_operator { + "indexed-directory" + } else if is_google_file { + "indexed-file" + } else if is_google { + "indexed-directory" + } else if path.contains("/content/") { + "file-endpoint" + } else if path == "localhost://WebSpaces" { + "webspace-root" + } else { + "folder-handle" + }, + "modified": 1, + "created": 1 + }) +} + +fn mock_cached_webspace_stat( + path: &str, + cached: &MockCachedWebSpaceObject, +) -> serde_json::Value { + let mut stat = mock_webspace_stat(path); + stat["size"] = json!(cached.bytes.len()); + stat["resolver_state"] = json!("materialized-local"); + stat["cache_state"] = json!("content_cached"); + stat["sync_state"] = json!(cached.sync_state); + stat["kind"] = json!("materialized-file"); + stat +} + struct MockNetProvider; struct MockMalformedNetProvider; @@ -1595,9 +2894,7 @@ impl Provider for MockWalletProvider { "message": "recovery_key is required" })); }; - if recovery_key - .get("schema") - .and_then(|value| value.as_str()) + if recovery_key.get("schema").and_then(|value| value.as_str()) != Some("elastos.wallet.recovery-key/v1") { return Ok(json!({ @@ -1609,9 +2906,7 @@ impl Provider for MockWalletProvider { let account_id = required_test_str(recovery_key, "account_id")?; let chain_namespace = required_test_str(recovery_key, "chain_namespace")?; let address = required_test_str(recovery_key, "address")?; - let proof_type = if chain_namespace - == "bip122:000000000019d6689c085ae165831e93" - { + let proof_type = if chain_namespace == "bip122:000000000019d6689c085ae165831e93" { "managed_btc_p2wpkh" } else { "managed_evm" @@ -2011,8 +3306,10 @@ impl Provider for MockWalletProvider { "message": "wallet approval request is not a transaction" })); } - let mut signed_result = - approval.get("signed_result").cloned().unwrap_or_else(|| json!({})); + let mut signed_result = approval + .get("signed_result") + .cloned() + .unwrap_or_else(|| json!({})); signed_result["transaction_hash"] = json!(transaction_hash); signed_result["broadcast_recorded_at"] = json!(crate::auth::now_ts()); approval["signed_result"] = signed_result; diff --git a/elastos/crates/elastos-server/src/api/viewer_gateway.rs b/elastos/crates/elastos-server/src/api/viewer_gateway.rs index 0edce46a..61b454a2 100644 --- a/elastos/crates/elastos-server/src/api/viewer_gateway.rs +++ b/elastos/crates/elastos-server/src/api/viewer_gateway.rs @@ -1,16 +1,20 @@ use std::path::{Path as FsPath, PathBuf}; +use std::sync::Arc; use axum::body::Bytes; -use axum::extract::{Path, State}; +use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::Json; use elastos_common::localhost::rooted_localhost_fs_path; -use serde::Serialize; +use elastos_runtime::auth::RuntimeAuditEventV1; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; use super::gateway::{ - content_type, require_home_launch_token_for_any, viewer_object_shell_description, - viewer_object_shell_title, GatewayState, HomeLaunchTokenContext, + content_type, require_home_launch_token_for_any, require_home_launch_token_for_any_context, + viewer_object_shell_description, viewer_object_shell_title, GatewayState, + HomeLaunchTokenContext, }; #[derive(Debug, Serialize)] @@ -26,6 +30,46 @@ struct ViewerLibraryItem { entrypoint: String, } +#[derive(Debug, Deserialize)] +pub struct ViewerLibraryObjectQuery { + uri: String, + #[serde(default)] + stat_only: bool, + #[serde(default)] + entries: bool, + #[serde(default)] + preview_entry: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ViewerLibraryObjectWrite { + data: String, + #[serde(default)] + mime: Option, + #[serde(default)] + if_revision: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ViewerLibraryArchiveExtractEntries { + destination_uri: String, + entries: Vec, + #[serde(default)] + conflict_policy: Option, + #[serde(default)] + if_revision: Option, + #[serde(default)] + cancel: bool, +} + +struct ViewerLibraryObjectRequest { + uri: String, + stat_only: bool, + entries: bool, + preview_entry: Option, + write: Option, +} + pub async fn viewer_library_summary( State(state): State, Path(viewer): Path, @@ -110,6 +154,128 @@ pub async fn viewer_content( .into_response() } +pub async fn viewer_library_object_get( + State(state): State, + Path(viewer): Path, + Query(query): Query, + headers: HeaderMap, +) -> Response { + let viewer = match clean_capsule_ref(&viewer, "viewer") { + Ok(viewer) => viewer, + Err(err) => return viewer_error_response(err), + }; + let context = match require_library_object_viewer_context(&state.data_dir, &headers, &viewer) { + Ok(context) => context, + Err(err) => return viewer_error_response(err), + }; + match viewer_library_object( + &state, + &context, + &viewer, + ViewerLibraryObjectRequest { + uri: query.uri, + stat_only: query.stat_only, + entries: query.entries, + preview_entry: query.preview_entry, + write: None, + }, + ) + .await + { + Ok(response) => Json(response).into_response(), + Err(err) => viewer_error_response(err), + } +} + +pub async fn viewer_library_object_put( + State(state): State, + Path(viewer): Path, + Query(query): Query, + headers: HeaderMap, + Json(input): Json, +) -> Response { + let viewer = match clean_capsule_ref(&viewer, "viewer") { + Ok(viewer) => viewer, + Err(err) => return viewer_error_response(err), + }; + if viewer != "documents" { + return ( + StatusCode::FORBIDDEN, + "viewer does not support Library object writes", + ) + .into_response(); + } + let context = match require_documents_viewer_context(&state.data_dir, &headers, &viewer) { + Ok(context) => context, + Err(err) => return viewer_error_response(err), + }; + match viewer_library_object( + &state, + &context, + &viewer, + ViewerLibraryObjectRequest { + uri: query.uri, + stat_only: false, + entries: false, + preview_entry: None, + write: Some(input), + }, + ) + .await + { + Ok(response) => Json(response).into_response(), + Err(err) => viewer_error_response(err), + } +} + +pub async fn viewer_library_object_post( + State(state): State, + Path(viewer): Path, + Query(query): Query, + headers: HeaderMap, + Json(input): Json, +) -> Response { + let viewer = match clean_capsule_ref(&viewer, "viewer") { + Ok(viewer) => viewer, + Err(err) => return viewer_error_response(err), + }; + let context = match require_library_object_viewer_context(&state.data_dir, &headers, &viewer) { + Ok(context) => context, + Err(err) => return viewer_error_response(err), + }; + match viewer_library_archive_extract_entries(&state, &context, &viewer, &query.uri, input).await + { + Ok(response) => Json(response).into_response(), + Err(err) => viewer_error_response(err), + } +} + +pub async fn viewer_library_roots_get( + State(state): State, + Path(viewer): Path, + headers: HeaderMap, +) -> Response { + let viewer = match clean_capsule_ref(&viewer, "viewer") { + Ok(viewer) => viewer, + Err(err) => return viewer_error_response(err), + }; + if viewer != "archive-manager" { + return ( + StatusCode::FORBIDDEN, + "viewer does not support Library destination roots", + ) + .into_response(); + } + let context = match require_library_object_viewer_context(&state.data_dir, &headers, &viewer) { + Ok(context) => context, + Err(err) => return viewer_error_response(err), + }; + match viewer_object_provider_request(&state, &context, "roots", json!({})).await { + Ok(response) => Json(response).into_response(), + Err(err) => viewer_error_response(err), + } +} + pub async fn viewer_storage_get( State(state): State, Path((viewer, capsule, scope, name)): Path<(String, String, String, String)>, @@ -167,6 +333,215 @@ pub async fn viewer_storage_put( } } +async fn viewer_library_object( + state: &GatewayState, + context: &HomeLaunchTokenContext, + viewer: &str, + request: ViewerLibraryObjectRequest, +) -> anyhow::Result { + let stat = + viewer_object_provider_request(state, context, "stat", json!({ "uri": &request.uri })) + .await?; + ensure_viewer_can_view_library_object(&stat, viewer)?; + if request.stat_only { + return Ok(stat); + } + if request.entries { + if viewer != "archive-manager" { + anyhow::bail!("viewer does not support Library archive entry listing"); + } + return viewer_object_provider_request( + state, + context, + "archive_entries", + json!({ "uri": &request.uri }), + ) + .await; + } + if let Some(entry) = request.preview_entry { + if viewer != "archive-manager" { + anyhow::bail!("viewer does not support Library archive entry preview"); + } + return viewer_object_provider_request( + state, + context, + "archive_preview_entry", + json!({ "uri": &request.uri, "entry": entry }), + ) + .await; + } + if viewer != "documents" { + anyhow::bail!("viewer supports Library object metadata only"); + } + let payload = match request.write { + Some(write) => json!({ + "uri": &request.uri, + "data": write.data, + "mime": write.mime, + "if_revision": write.if_revision, + }), + None => json!({ "uri": &request.uri }), + }; + let op = if payload.get("data").is_some() { + "write" + } else { + "read" + }; + viewer_object_provider_request(state, context, op, payload).await +} + +async fn viewer_object_provider_request( + state: &GatewayState, + context: &HomeLaunchTokenContext, + op: &str, + mut request: Value, +) -> anyhow::Result { + let registry = state + .provider_registry + .as_ref() + .ok_or_else(|| anyhow::anyhow!("object provider unavailable"))?; + request["op"] = Value::String(op.to_string()); + request["principal_id"] = Value::String(context.principal_id.clone()); + let request_id = format!("viewer-library:{op}:{}", crate::auth::now_ts()); + append_viewer_library_audit( + &state.data_dir, + context, + &request_id, + "library.viewer.requested", + "requested", + &format!("Viewer requested Library object operation {op}"), + )?; + let response = crate::library::handle_object_provider_runtime_request( + &state.data_dir, + Arc::clone(registry), + &request, + ) + .await; + let completed = response.get("status").and_then(Value::as_str) == Some("ok"); + if completed && op == "write" { + crate::library::library_event_notifier().notify_waiters(); + } + append_viewer_library_audit( + &state.data_dir, + context, + &request_id, + if completed { + "library.viewer.completed" + } else { + "library.viewer.failed" + }, + if completed { "completed" } else { "failed" }, + &format!( + "Viewer {} Library object operation {op}", + if completed { "completed" } else { "failed" } + ), + )?; + Ok(response) +} + +async fn viewer_library_archive_extract_entries( + state: &GatewayState, + context: &HomeLaunchTokenContext, + viewer: &str, + uri: &str, + input: ViewerLibraryArchiveExtractEntries, +) -> anyhow::Result { + if viewer != "archive-manager" { + anyhow::bail!("viewer does not support Library archive extraction"); + } + let stat = + viewer_object_provider_request(state, context, "stat", json!({ "uri": uri })).await?; + ensure_viewer_can_view_library_object(&stat, viewer)?; + let response = viewer_object_provider_request( + state, + context, + "archive_extract_entries", + json!({ + "uri": uri, + "destination_uri": input.destination_uri, + "entries": input.entries, + "conflict_policy": input.conflict_policy, + "if_revision": input.if_revision, + "cancel": input.cancel, + }), + ) + .await?; + if response.get("status").and_then(Value::as_str) == Some("ok") { + crate::library::library_event_notifier().notify_waiters(); + } + Ok(response) +} + +fn ensure_viewer_can_view_library_object(response: &Value, viewer_id: &str) -> anyhow::Result<()> { + let object = response + .get("data") + .and_then(|data| data.get("object")) + .ok_or_else(|| anyhow::anyhow!("library object not found"))?; + let can_view = object + .get("viewers") + .and_then(Value::as_array) + .into_iter() + .flatten() + .any(|viewer| viewer.get("id").and_then(Value::as_str) == Some(viewer_id)); + if !can_view { + anyhow::bail!("Library object is not viewable by {viewer_id}"); + } + Ok(()) +} + +fn require_library_object_viewer_context( + data_dir: &FsPath, + headers: &HeaderMap, + viewer: &str, +) -> anyhow::Result { + let viewer = clean_capsule_ref(viewer, "viewer")?; + if !super::browser_capsules::is_viewer_capsule(data_dir, &viewer) { + anyhow::bail!("viewer capsule not found"); + } + require_home_launch_token_for_any_context(data_dir, headers, &[viewer.as_str()]) +} + +fn require_documents_viewer_context( + data_dir: &FsPath, + headers: &HeaderMap, + viewer: &str, +) -> anyhow::Result { + let viewer = clean_capsule_ref(viewer, "viewer")?; + if viewer != "documents" || !super::browser_capsules::is_viewer_capsule(data_dir, &viewer) { + anyhow::bail!("viewer capsule not found"); + } + require_home_launch_token_for_any_context(data_dir, headers, &[viewer.as_str()]) +} + +fn append_viewer_library_audit( + data_dir: &FsPath, + context: &HomeLaunchTokenContext, + request_id: &str, + event_type: &str, + result: &str, + reason: &str, +) -> anyhow::Result<()> { + let now = crate::auth::now_ts(); + crate::auth::append_audit_event( + data_dir, + RuntimeAuditEventV1 { + schema: RuntimeAuditEventV1::SCHEMA.to_string(), + event_id: format!("audit:{event_type}:{request_id}:{now}"), + event_type: event_type.to_string(), + principal_id: Some(context.principal_id.clone()), + proof_binding_id: context.proof_binding_id.clone(), + session_id: Some(context.session_id.clone()), + challenge_id: Some(request_id.to_string()), + capsule_id: Some("documents".to_string()), + result: result.to_string(), + reason: reason.to_string(), + occurred_at: now, + signer_did: None, + signature: None, + }, + ) +} + struct ViewerStorageTarget { path: PathBuf, principal_id: String, @@ -303,6 +678,11 @@ fn viewer_error_response(err: anyhow::Error) -> Response { StatusCode::NOT_FOUND } else if text.contains("home launch token") { StatusCode::UNAUTHORIZED + } else if text.contains("does not support") + || text.contains("not viewable") + || text.contains("metadata only") + { + StatusCode::FORBIDDEN } else if text.contains("invalid") || text.contains("must not be empty") || text.contains("no storage grant") diff --git a/elastos/crates/elastos-server/src/carrier.rs b/elastos/crates/elastos-server/src/carrier.rs index f1deee5f..75ef24ef 100644 --- a/elastos/crates/elastos-server/src/carrier.rs +++ b/elastos/crates/elastos-server/src/carrier.rs @@ -18,12 +18,13 @@ //! //! See `docs/CARRIER_TRUST_DECISION.md` for the rationale. -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use std::time::Duration; use anyhow::{Context, Result}; +use base64::Engine as _; use iroh::address_lookup::memory::MemoryLookup; use iroh::protocol::{AcceptError, ProtocolHandler, Router}; use iroh::{Endpoint, SecretKey, Watcher}; @@ -44,13 +45,48 @@ use elastos_common::localhost::{ publisher_artifacts_path, publisher_install_script_path, publisher_publish_state_path, publisher_release_head_path, publisher_release_manifest_path, }; -use elastos_runtime::provider::{Provider, ProviderError, ResourceRequest, ResourceResponse}; +use elastos_runtime::provider::{ + Provider, ProviderCarrierInvoker, ProviderCarrierRoute, ProviderError, ProviderInvocation, + ProviderInvocationTransport, ProviderRegistry, ProviderTransfer, ResourceRequest, + ResourceResponse, +}; +use crate::content::{ContentObjectManifest, CONTENT_OBJECT_MANIFEST_PATH}; use crate::operator_control::{OperatorHandler, OperatorRuntimeContext, OPERATOR_ALPN}; use crate::sources::TrustedSource; const CARRIER_ALPN: &[u8] = b"elastos/carrier/1"; const CHAT_DISCOVERY_TOPIC_GENERAL: &str = "__elastos_internal/chat-presence-v1/#general"; +const CONTENT_AVAILABILITY_ANNOUNCEMENT_SCHEMA: &str = + "elastos.content.availability.announcement/v1"; +const CONTENT_AVAILABILITY_ANNOUNCEMENT_DOMAIN: &str = + "elastos.content.availability.announcement.v1"; +const CONTENT_ADMISSION_DOMAIN: &str = "elastos.content.admission.v1"; +const CONTENT_REPAIR_GRAPH_SCHEMA: &str = "elastos.content.repair-graph/v1"; +const CONTENT_BLOCK_GRAPH_SCHEMA: &str = "elastos.content.block-graph/v1"; +const CONTENT_FEDERATED_QUOTA_LEDGER_POLICY_SCHEMA: &str = + "elastos.content.federated-quota-ledger-policy/v1"; +const CONTENT_STORAGE_MARKET_ADMISSION_POLICY_SCHEMA: &str = + "elastos.content.storage-market-admission-policy/v1"; +const CONTENT_BLOCK_GRAPH_PROVIDER: &str = "content-block-graph-provider"; +const CONTENT_BLOCK_GRAPH_TARGET: &str = "block-graph"; +const CARRIER_PEER_REPUTATION_SCHEMA: &str = "elastos.carrier.peer-reputation/v1"; +const CARRIER_PEER_ATTESTATION_EXCHANGE_POLICY_SCHEMA: &str = + "elastos.carrier.peer-attestation-exchange-policy/v1"; +const CARRIER_PEER_ATTESTATION_EXCHANGE_REQUEST_SCHEMA: &str = + "elastos.carrier.peer-attestation.exchange-request/v1"; +const CARRIER_PEER_ATTESTATION_EXCHANGE_REQUEST_DOMAIN: &str = + "elastos.carrier.peer-attestation.exchange-request.v1"; +const CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA: &str = + "elastos.carrier.peer-attestation.exchange-receipt/v1"; +const CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_DOMAIN: &str = + "elastos.carrier.peer-attestation.exchange-receipt.v1"; +const MAX_CARRIER_REPLICATION_CANDIDATES: usize = 8; +const MAX_CARRIER_AVAILABILITY_TICKET_LEN: usize = 8192; +const MAX_CARRIER_AVAILABILITY_ENDPOINT_ID_LEN: usize = 256; +const MAX_CARRIER_OBJECT_IMPORT_FILES: usize = 512; +const MAX_CARRIER_OBJECT_IMPORT_BYTES: usize = 64 * 1024 * 1024; +const MAX_REMOTE_RECEIPT_REPLICA_SUMMARY_ROWS: usize = 5; /// Well-known secret for topic discovery. Any Carrier node with this secret /// can discover peers on the same topic via DHT. @@ -481,6 +517,15 @@ pub async fn start_carrier_node( signing_key: &ed25519_dalek::SigningKey, did: &str, data_dir: PathBuf, +) -> Result { + start_carrier_node_with_registry(signing_key, did, data_dir, None).await +} + +pub async fn start_carrier_node_with_registry( + signing_key: &ed25519_dalek::SigningKey, + did: &str, + data_dir: PathBuf, + provider_registry: Option>, ) -> Result { let secret_key = SecretKey::from_bytes(&signing_key.to_bytes()); @@ -538,6 +583,7 @@ pub async fn start_carrier_node( let file_handler = FileHandler { data_dir: data_dir.clone(), + provider_registry, }; let operator_handler = OperatorHandler::new(OperatorRuntimeContext { data_dir: data_dir.clone(), @@ -616,6 +662,7 @@ pub async fn start_carrier_node( #[derive(Debug, Clone)] struct FileHandler { data_dir: PathBuf, + provider_registry: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -634,8 +681,9 @@ impl ProtocolHandler for FileHandler { conn: iroh::endpoint::Connection, ) -> futures_lite::future::Boxed> { let data_dir = self.data_dir.clone(); + let provider_registry = self.provider_registry.clone(); Box::pin(async move { - handle_file_connection(conn, &data_dir) + handle_file_connection(conn, &data_dir, provider_registry) .await .map_err(|e| AcceptError::from(std::io::Error::other(e.to_string()))) }) @@ -645,6 +693,7 @@ impl ProtocolHandler for FileHandler { async fn handle_file_connection( conn: iroh::endpoint::Connection, data_dir: &std::path::Path, + provider_registry: Option>, ) -> Result<()> { loop { let (mut send, recv) = match conn.accept_bi().await { @@ -652,8 +701,10 @@ async fn handle_file_connection( Err(_) => break, }; let data_dir = data_dir.to_path_buf(); + let provider_registry = provider_registry.clone(); tokio::spawn(async move { - if let Err(e) = handle_file_stream(&mut send, recv, &data_dir).await { + if let Err(e) = handle_file_stream(&mut send, recv, &data_dir, provider_registry).await + { debug!("carrier file stream error: {:#}", e); } }); @@ -665,6 +716,7 @@ async fn handle_file_stream( send: &mut iroh::endpoint::SendStream, recv: iroh::endpoint::RecvStream, data_dir: &std::path::Path, + provider_registry: Option>, ) -> Result<()> { let mut reader = BufReader::new(recv); let mut line = String::new(); @@ -752,6 +804,71 @@ async fn handle_file_stream( send.stopped().await.ok(); info!("carrier: served file {} ({} bytes)", path, len); } + "content_fetch" => { + let Some(registry) = provider_registry.and_then(|registry| registry.upgrade()) else { + send_json( + send, + &serde_json::json!({ + "ok": false, + "error": "content provider registry unavailable" + }), + ) + .await?; + return Ok(()); + }; + let cid = msg + .data + .get("cid") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let path = msg + .data + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or(""); + match carrier_content_fetch_bytes(®istry, cid, path).await { + Ok(content) => { + let len = content.len() as u64; + send.write_all(&len.to_be_bytes()).await?; + send.write_all(&content).await?; + send.finish()?; + send.stopped().await.ok(); + info!( + "carrier: served content {}{}{} ({} bytes)", + cid, + if path.is_empty() { "" } else { "/" }, + path, + len + ); + } + Err(err) => { + send_json( + send, + &serde_json::json!({ + "ok": false, + "error": err.to_string(), + }), + ) + .await?; + } + } + } + "provider_invoke" => { + let Some(registry) = provider_registry.and_then(|registry| registry.upgrade()) else { + send_json( + send, + &serde_json::json!({ + "ok": false, + "code": "provider_registry_unavailable", + "error": "provider registry unavailable" + }), + ) + .await?; + return Ok(()); + }; + let response = carrier_provider_invoke_registry(®istry, &msg.data).await?; + send_json(send, &response).await?; + } _ => { send_json( send, @@ -763,6 +880,228 @@ async fn handle_file_stream( Ok(()) } +async fn carrier_content_fetch_bytes( + registry: &ProviderRegistry, + cid: &str, + path: &str, +) -> Result> { + validate_content_cid(cid).map_err(anyhow::Error::msg)?; + validate_carrier_content_path(path).map_err(anyhow::Error::msg)?; + let mut request = serde_json::json!({ + "op": "cat", + "cid": cid, + }); + if !path.is_empty() { + request["path"] = serde_json::Value::String(path.to_string()); + } + let response = registry + .send_raw("ipfs", &request) + .await + .map_err(|err| anyhow::anyhow!("ipfs-provider unavailable: {err}"))?; + if response.get("status").and_then(|status| status.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|message| message.as_str()) + .unwrap_or("unknown error"); + anyhow::bail!("ipfs-provider fetch failed: {message}"); + } + let data = response + .get("data") + .and_then(|data| data.get("data")) + .and_then(|data| data.as_str()) + .ok_or_else(|| anyhow::anyhow!("ipfs-provider response missing data"))?; + base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|err| anyhow::anyhow!("ipfs-provider returned invalid base64: {err}")) +} + +async fn carrier_provider_invoke_registry( + registry: &ProviderRegistry, + data: &serde_json::Value, +) -> Result { + let source = data + .get("source") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("provider_invoke missing source"))?; + let target = data + .get("target") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("provider_invoke missing target"))?; + let operation = data + .get("operation") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("provider_invoke missing operation"))?; + let transfer = data + .get("transfer") + .and_then(|value| value.as_str()) + .unwrap_or("json"); + let request = data + .get("request") + .cloned() + .ok_or_else(|| anyhow::anyhow!("provider_invoke missing request"))?; + + if !carrier_provider_target_allowed(target) { + return Ok(serde_json::json!({ + "ok": false, + "code": "unauthorized_provider_target", + "error": "Carrier provider invocation must target an ElastOS service provider, not a raw backend", + })); + } + if let Err(message) = + validate_carrier_provider_invocation(source, target, operation, transfer, &request) + { + return Ok(serde_json::json!({ + "ok": false, + "code": "invalid_provider_invocation", + "error": message, + })); + } + + match registry.send_raw(target, &request).await { + Ok(result) => Ok(serde_json::json!({ + "ok": true, + "result": result, + })), + Err(err) => Ok(serde_json::json!({ + "ok": false, + "code": "provider_error", + "error": err.to_string(), + })), + } +} + +fn carrier_provider_target_allowed(target: &str) -> bool { + matches!( + target, + "content" | "availability" | "rights" | "key" | "decrypt" | "drm" + ) +} + +fn validate_carrier_provider_invocation( + source: &str, + target: &str, + operation: &str, + transfer: &str, + request: &serde_json::Value, +) -> std::result::Result<(), String> { + if !matches!(transfer, "json" | "bytes" | "stream") { + return Err(format!( + "provider_invoke transfer must be json, bytes, or stream, got {transfer}" + )); + } + if request.get("_runtime_transfer").is_some() { + return Err("provider_invoke request must not predeclare _runtime_transfer".to_string()); + } + let request_op = request + .get("op") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if request_op != operation { + return Err(format!( + "provider_invoke op mismatch: envelope={operation}, request={request_op}" + )); + } + let runtime = request + .get("_runtime_invocation") + .and_then(|value| value.as_object()) + .ok_or_else(|| "provider_invoke requires _runtime_invocation".to_string())?; + let expected_capability = format!("provider:{source}->{target}:{operation}"); + for (field, expected) in [ + ("schema", "elastos.provider.invocation/v1"), + ("source", source), + ("target", target), + ("op", operation), + ("capability", expected_capability.as_str()), + ("transport", "carrier-provider-plane"), + ("transfer", transfer), + ] { + let actual = runtime + .get(field) + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if actual != expected { + return Err(format!( + "provider_invoke runtime field {field} mismatch: expected {expected}, got {actual}" + )); + } + } + let carrier = runtime + .get("carrier") + .and_then(|value| value.as_object()) + .ok_or_else(|| "provider_invoke requires carrier route metadata".to_string())?; + if carrier + .get("route") + .and_then(|value| value.as_str()) + .unwrap_or_default() + != "connect_ticket" + { + return Err("provider_invoke carrier route must be connect_ticket".to_string()); + } + if carrier.contains_key("connect_ticket") { + return Err("provider_invoke carrier metadata must not expose connect_ticket".to_string()); + } + if transfer == "stream" { + validate_carrier_provider_stream_contract(runtime)?; + } + Ok(()) +} + +fn validate_carrier_provider_stream_contract( + runtime: &serde_json::Map, +) -> std::result::Result<(), String> { + let stream = runtime + .get("stream") + .and_then(|value| value.as_object()) + .ok_or_else(|| "provider_invoke stream transfer requires stream metadata".to_string())?; + let schema = stream + .get("schema") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if schema != "elastos.provider.stream/v1" { + return Err(format!( + "provider_invoke stream schema mismatch: expected elastos.provider.stream/v1, got {schema}" + )); + } + let encoding = stream + .get("encoding") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if encoding != "base64-chunks" { + return Err(format!( + "provider_invoke stream encoding mismatch: expected base64-chunks, got {encoding}" + )); + } + let chunk_size = stream + .get("chunk_size") + .and_then(|value| value.as_u64()) + .unwrap_or_default(); + if chunk_size == 0 { + return Err("provider_invoke stream chunk_size must be greater than zero".to_string()); + } + Ok(()) +} + +fn validate_carrier_content_path(path: &str) -> Result<(), String> { + if path.is_empty() { + return Ok(()); + } + if path.starts_with('/') || path.starts_with('\\') { + return Err("content fetch path must be relative".to_string()); + } + if path.contains('\\') || path.contains('\0') { + return Err("content fetch path contains invalid characters".to_string()); + } + for segment in path.split('/') { + if segment.is_empty() || segment == "." || segment == ".." { + return Err("content fetch path contains an invalid segment".to_string()); + } + } + Ok(()) +} + async fn send_json(send: &mut iroh::endpoint::SendStream, value: &serde_json::Value) -> Result<()> { let mut bytes = serde_json::to_vec(value)?; bytes.push(b'\n'); @@ -772,723 +1111,6833 @@ async fn send_json(send: &mut iroh::endpoint::SendStream, value: &serde_json::Va Ok(()) } -// ── Gossip Provider (implements Provider trait) ────────────────── - -/// In-process gossip provider for `elastos://peer/*`. -/// Replaces the separate peer-provider subprocess. -pub struct CarrierGossipProvider { +/// Runtime content availability provider backed by Carrier gossip. +/// +/// `content-provider` still owns CID policy, receipts, and local Kubo/IPFS +/// backend use. This provider only announces content availability through the +/// Carrier plane so apps keep using `elastos://content/*` instead of raw +/// peer/topic/IPFS authority. +pub struct CarrierAvailabilityProvider { state: Arc>, + provider_registry: Option>, + peer_reputation: Arc>>, + data_dir: Option, + peer_attestation_exchange: Option, } -impl CarrierGossipProvider { +#[derive(Debug, Clone)] +pub struct CarrierPeerAttestationExchangeClient { + endpoints: Vec, + quorum: usize, +} + +#[derive(Debug, Clone)] +struct CarrierPeerAttestationExchangeEndpoint { + id: String, + url: String, + authorization: Option, + timeout_secs: u64, +} + +impl CarrierAvailabilityProvider { pub fn new(state: Arc>) -> Self { - Self { state } + Self { + state, + provider_registry: None, + peer_reputation: Arc::new(Mutex::new(HashMap::new())), + data_dir: None, + peer_attestation_exchange: None, + } } -} -impl std::fmt::Debug for CarrierGossipProvider { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("CarrierGossipProvider").finish() + pub fn with_provider_registry( + state: Arc>, + provider_registry: Weak, + ) -> Self { + Self { + state, + provider_registry: Some(provider_registry), + peer_reputation: Arc::new(Mutex::new(HashMap::new())), + data_dir: None, + peer_attestation_exchange: None, + } } -} -#[async_trait::async_trait] -impl Provider for CarrierGossipProvider { - async fn handle(&self, _request: ResourceRequest) -> Result { - Err(ProviderError::Provider( - "use send_raw for peer operations".into(), - )) + pub fn with_provider_registry_and_data_dir( + state: Arc>, + provider_registry: Weak, + data_dir: PathBuf, + ) -> Self { + Self::with_provider_registry_data_dir_and_peer_attestation_exchange_config( + state, + provider_registry, + data_dir, + None, + ) } - fn schemes(&self) -> Vec<&'static str> { - vec!["peer"] + pub fn with_provider_registry_data_dir_and_peer_attestation_exchange_config( + state: Arc>, + provider_registry: Weak, + data_dir: PathBuf, + peer_attestation_exchange_config: Option, + ) -> Self { + let peer_reputation = load_carrier_peer_reputation(&data_dir); + let peer_attestation_exchange = peer_attestation_exchange_config.and_then(|config| { + match CarrierPeerAttestationExchangeClient::from_config(config) { + Ok(client) => Some(client), + Err(err) => { + tracing::warn!("carrier peer-attestation exchange disabled: {}", err); + None + } + } + }); + Self { + state, + provider_registry: Some(provider_registry), + peer_reputation: Arc::new(Mutex::new(peer_reputation)), + data_dir: Some(data_dir), + peer_attestation_exchange, + } } - fn name(&self) -> &'static str { - "carrier-gossip" + + async fn record_peer_reputation(&self, node_did: &str, success: bool) { + let snapshot = { + let mut reputation = self.peer_reputation.lock().await; + let entry = reputation.entry(node_did.to_string()).or_default(); + if success { + entry.successes = entry.successes.saturating_add(1); + } else { + entry.failures = entry.failures.saturating_add(1); + } + reputation.clone() + }; + if let Some(data_dir) = &self.data_dir { + if let Err(err) = save_carrier_peer_reputation(data_dir, &snapshot) { + tracing::debug!("carrier peer reputation save failed: {}", err); + } + } } - async fn send_raw( + async fn exchange_peer_attestations( &self, - request: &serde_json::Value, - ) -> Result { - let op = request.get("op").and_then(|v| v.as_str()).unwrap_or(""); - let mut state = self.state.lock().await; - - match op { - "init" => { - let id = state - .did - .clone() - .unwrap_or_else(|| state.endpoint.id().to_string()); - Ok(serde_json::json!({"status": "ok", "data": {"node_id": id}})) + exchange_request: CarrierPeerAttestationExchangeRequest<'_>, + ) -> Option { + let Some(exchange) = &self.peer_attestation_exchange else { + return None; + }; + if exchange_request.remote_proofs.is_empty() { + return None; + } + let request = match carrier_peer_attestation_exchange_request( + exchange_request.signing_key, + exchange_request.cid, + exchange_request.topic_uri, + exchange_request.local_node_did, + exchange_request.remote_proofs, + exchange_request.live_multi_peer_proof, + exchange_request.requested_at, + ) { + Ok(request) => request, + Err(err) => { + return Some(serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA, + "provider": "carrier-availability", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "failed", + "reason": format!("peer-attestation exchange request build failed: {err}"), + "exchange": exchange.redacted_status_json(), + "credential_exposed": false, + })) } + }; + match exchange.exchange(&request).await { + Ok(receipt) => Some(receipt), + Err(err) => Some(serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA, + "provider": "carrier-availability", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "failed", + "reason": err, + "exchange": exchange.redacted_status_json(), + "credential_exposed": false, + })), + } + } +} - "gossip_join" => { - let topic_name = request["topic"].as_str().unwrap_or_default(); - let force_direct = request - .get("mode") - .and_then(|value| value.as_str()) - .map(|mode| mode.eq_ignore_ascii_case("direct")) - .unwrap_or(false); - if topic_name.is_empty() { - return Ok( - serde_json::json!({"status":"error","code":"missing_topic","message":"topic required"}), - ); - } - if state.joined_topics.contains(topic_name) { - return Ok( - serde_json::json!({"status":"error","code":"already_joined","message":"already joined"}), - ); - } - if state.joined_topics.len() >= MAX_TOPICS { - return Ok( - serde_json::json!({"status":"error","code":"too_many_topics","message":"topic limit reached"}), - ); - } +impl std::fmt::Debug for CarrierAvailabilityProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CarrierAvailabilityProvider").finish() + } +} - match join_gossip_topic(&mut state, topic_name, force_direct).await { - Ok(()) => Ok(serde_json::json!({"status":"ok","data":{"topic": topic_name}})), - Err(err) => Ok( - serde_json::json!({"status":"error","code":"join_failed","message": err.to_string()}), - ), +impl CarrierPeerAttestationExchangeClient { + pub fn from_config(config: serde_json::Value) -> Result { + let payload = config + .get("extra") + .filter(|extra| !extra.is_null()) + .unwrap_or(&config); + let default_authorization = payload + .get("authorization") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if let Some(value) = &default_authorization { + validate_carrier_authorization_header_value(value)?; + } + let default_timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(5) + .clamp(1, 60); + let endpoints = match payload.get("endpoints").and_then(|value| value.as_array()) { + Some(values) if !values.is_empty() => values + .iter() + .enumerate() + .map(|(index, endpoint)| { + CarrierPeerAttestationExchangeEndpoint::from_config( + endpoint, + index, + default_authorization.as_deref(), + default_timeout_secs, + ) + }) + .collect::, _>>()?, + _ => vec![CarrierPeerAttestationExchangeEndpoint::from_config( + payload, + 0, + default_authorization.as_deref(), + default_timeout_secs, + )?], + }; + if endpoints.len() > 5 { + return Err( + "carrier peer-attestation exchange supports at most 5 endpoints".to_string(), + ); + } + let quorum = payload + .get("quorum") + .or_else(|| payload.get("required_quorum")) + .and_then(|value| value.as_u64()) + .map(|value| value as usize) + .unwrap_or(endpoints.len()); + if quorum == 0 || quorum > endpoints.len() { + return Err(format!( + "carrier peer-attestation exchange quorum must be between 1 and {}", + endpoints.len() + )); + } + Ok(Self { endpoints, quorum }) + } + + fn redacted_status_json(&self) -> serde_json::Value { + let first = self.endpoints.first(); + let parsed = first.and_then(|endpoint| url::Url::parse(&endpoint.url).ok()); + serde_json::json!({ + "configured": true, + "delivery": "carrier_peer_attestation_exchange", + "endpoint_count": self.endpoints.len(), + "multi_endpoint": self.endpoints.len() > 1, + "quorum_required": self.quorum, + "endpoints": self + .endpoints + .iter() + .map(CarrierPeerAttestationExchangeEndpoint::redacted_status_json) + .collect::>(), + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self + .endpoints + .iter() + .any(|endpoint| endpoint.authorization.is_some()), + "timeout_secs": first.map(|endpoint| endpoint.timeout_secs).unwrap_or(0), + "credential_exposed": false, + }) + } + + async fn exchange( + &self, + request_payload: &serde_json::Value, + ) -> Result { + let mut endpoint_receipts = Vec::new(); + let mut accepted_receipts = 0_usize; + let mut rejected_receipts = 0_usize; + let mut failed_receipts = 0_usize; + let mut verified_receipts = 0_usize; + let mut first_verified_signed_receipt = None; + let mut reasons = Vec::new(); + + for endpoint in &self.endpoints { + let receipt = endpoint + .exchange(request_payload) + .await + .unwrap_or_else(|err| { + failed_receipts = failed_receipts.saturating_add(1); + carrier_peer_attestation_endpoint_unavailable(err, endpoint) + }); + if receipt + .get("accepted") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + accepted_receipts = accepted_receipts.saturating_add(1); + } else if receipt + .get("status") + .and_then(|value| value.as_str()) + .is_some_and(|status| status == "rejected") + { + rejected_receipts = rejected_receipts.saturating_add(1); + } + if receipt + .get("signed_receipt") + .and_then(|value| value.get("verified")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + verified_receipts = verified_receipts.saturating_add(1); + if first_verified_signed_receipt.is_none() { + first_verified_signed_receipt = receipt.get("signed_receipt").cloned(); } } + if let Some(reason) = receipt.get("reason").and_then(|value| value.as_str()) { + reasons.push(reason.to_string()); + } + endpoint_receipts.push(receipt); + } - "gossip_leave" => { - let topic_name = request["topic"].as_str().unwrap_or_default(); - if topic_name.is_empty() { - return Ok( - serde_json::json!({"status":"error","code":"missing_topic","message":"topic required"}), - ); - } + let accepted = accepted_receipts >= self.quorum; + let reason = if accepted { + format!( + "carrier peer-attestation quorum accepted: {accepted_receipts}/{} verified endpoints accepted", + self.endpoints.len() + ) + } else if reasons.is_empty() { + format!( + "carrier peer-attestation quorum rejected: {accepted_receipts}/{} accepted, quorum {}", + self.endpoints.len(), + self.quorum + ) + } else { + format!( + "carrier peer-attestation quorum rejected: {accepted_receipts}/{} accepted, quorum {}; {}", + self.endpoints.len(), + self.quorum, + reasons.join("; ") + ) + }; + let mut signed_receipt = first_verified_signed_receipt.unwrap_or_else(|| { + serde_json::json!({ + "verified": false, + }) + }); + signed_receipt["verified"] = serde_json::Value::Bool(verified_receipts > 0); + signed_receipt["verified_receipts"] = serde_json::Value::from(verified_receipts); + + Ok(serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA, + "provider": "carrier-availability", + "scope": "content-availability", + "configured": true, + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "exchange": self.redacted_status_json(), + "quorum": { + "required": self.quorum, + "endpoint_count": self.endpoints.len(), + "accepted": accepted_receipts, + "rejected": rejected_receipts, + "failed": failed_receipts, + "verified": verified_receipts, + }, + "endpoint_receipts": endpoint_receipts, + "signed_receipt": signed_receipt, + "reason": reason, + "credential_exposed": false, + })) + } +} - let removed_sender = state.senders.remove(topic_name); - let removed_task = state.receiver_tasks.remove(topic_name); - let was_joined = state.joined_topics.remove(topic_name); - if removed_sender.is_none() && removed_task.is_none() && !was_joined { - return Ok( - serde_json::json!({"status":"error","code":"not_joined","message":"not joined"}), - ); - } - if let Some(task) = removed_task { - task.abort(); - } +impl CarrierPeerAttestationExchangeEndpoint { + fn from_config( + payload: &serde_json::Value, + index: usize, + default_authorization: Option<&str>, + default_timeout_secs: u64, + ) -> Result { + let url = payload + .get("url") + .or_else(|| payload.get("exchange_url")) + .or_else(|| payload.get("endpoint_url")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "carrier peer-attestation exchange endpoint requires url".to_string())?; + validate_carrier_external_endpoint_url(url)?; + let authorization = payload + .get("authorization") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or(default_authorization) + .map(str::to_string); + if let Some(value) = &authorization { + validate_carrier_authorization_header_value(value)?; + } + let timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(default_timeout_secs) + .clamp(1, 60); + let id = payload + .get("id") + .or_else(|| payload.get("provider_id")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("peer-attestation-{}", index + 1)); + Ok(Self { + id, + url: url.to_string(), + authorization, + timeout_secs, + }) + } - state - .cursors - .lock() - .await - .retain(|(topic, _), _| topic != topic_name); - state.buffers.lock().await.remove(topic_name); - state.topic_peers.lock().await.remove(topic_name); + fn redacted_status_json(&self) -> serde_json::Value { + let parsed = url::Url::parse(&self.url).ok(); + serde_json::json!({ + "id": self.id, + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self.authorization.is_some(), + "timeout_secs": self.timeout_secs, + "credential_exposed": false, + }) + } - Ok(serde_json::json!({"status":"ok","data":{"topic": topic_name}})) - } + async fn exchange( + &self, + request_payload: &serde_json::Value, + ) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(self.timeout_secs)) + .build() + .map_err(|err| format!("peer-attestation exchange client build failed: {err}"))?; + let mut request = client.post(&self.url).json(request_payload); + if let Some(authorization) = &self.authorization { + request = request.header("Authorization", authorization); + } + let response = request + .send() + .await + .map_err(|err| format!("peer-attestation exchange request failed: {err}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!( + "peer-attestation exchange returned HTTP {}", + status.as_u16() + )); + } + let response_json = response + .json::() + .await + .map_err(|err| format!("peer-attestation exchange response decode failed: {err}"))?; + carrier_peer_attestation_exchange_receipt_from_response( + &response_json, + self.redacted_status_json(), + status.as_u16(), + ) + } +} - "gossip_send" => { - let topic_name = request["topic"].as_str().unwrap_or_default(); - let message = request["message"].as_str().unwrap_or_default(); - let sender_nick = request["sender"].as_str().unwrap_or("unknown"); +fn content_availability_topic_name(cid: &str) -> String { + let digest = Sha256::digest(cid.as_bytes()); + format!("__elastos_content/v1/{}", hex::encode(digest)) +} - let sender = match state.senders.get(topic_name) { - Some(s) => s, - None => { - return Ok( - serde_json::json!({"status":"error","code":"not_joined","message":"not joined"}), - ) - } - }; +fn content_availability_topic_uri(cid: &str) -> String { + let digest = Sha256::digest(cid.as_bytes()); + format!( + "elastos://carrier/content/{}/availability", + hex::encode(digest) + ) +} - let default_id = state - .did - .clone() +fn carrier_availability_error(code: &str, message: impl Into) -> serde_json::Value { + serde_json::json!({ + "status": "error", + "code": code, + "message": message.into(), + }) +} + +fn validate_content_cid(cid: &str) -> Result<(), String> { + let cid = cid.trim(); + if cid.len() < 8 || cid.len() > 128 { + return Err("content availability requires a valid CID".to_string()); + } + if !cid + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err("content availability CID contains unsupported characters".to_string()); + } + Ok(()) +} + +fn validate_carrier_external_endpoint_url(raw: &str) -> Result<(), String> { + let url = url::Url::parse(raw) + .map_err(|err| format!("invalid carrier external endpoint URL: {err}"))?; + if !url.username().is_empty() || url.password().is_some() { + return Err( + "carrier external endpoint URL must not contain inline credentials".to_string(), + ); + } + match url.scheme() { + "https" => Ok(()), + "http" if matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "::1")) => Ok(()), + _ => Err("carrier external endpoint URL must use https or local loopback http".to_string()), + } +} + +fn validate_carrier_authorization_header_value(value: &str) -> Result<(), String> { + if value.bytes().any(|byte| matches!(byte, b'\r' | b'\n')) { + return Err("carrier authorization header contains invalid newline".to_string()); + } + Ok(()) +} + +fn local_replica_count(request: &serde_json::Value) -> u32 { + request + .get("local") + .and_then(|value| value.get("replicas")) + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(0) +} + +fn carrier_peer_attestation_remote_proofs_json( + remote_proofs: &[CarrierReplicationProof], +) -> Vec { + remote_proofs + .iter() + .map(|proof| { + serde_json::json!({ + "node_did": proof.node_did.clone(), + "endpoint_id": proof.endpoint_id.clone(), + "announced_at": proof.announced_at, + "score": proof.score, + "selection_reason": proof.selection_reason.clone(), + "local_reputation": { + "scope": "local_runtime", + "score_delta": proof.reputation_score, + "reason": proof.reputation_reason.clone(), + }, + "admission": proof.admission.clone(), + "ensure_status": proof.ensure_status.clone(), + "status": proof + .status_availability + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + "remote_receipt": proof.remote_receipt.as_ref().map(|receipt| { + serde_json::json!({ + "schema": receipt.get("schema").cloned().unwrap_or(serde_json::Value::Null), + "cid": receipt.get("cid").cloned().unwrap_or(serde_json::Value::Null), + "status": receipt.get("status").cloned().unwrap_or(serde_json::Value::Null), + "signer_did": receipt.get("signer_did").cloned().unwrap_or(serde_json::Value::Null), + "verified": receipt.get("verified").cloned().unwrap_or(serde_json::Value::Bool(false)), + }) + }), + "checked_at": proof.checked_at, + }) + }) + .collect() +} + +fn carrier_peer_attestation_exchange_request( + signing_key: &ed25519_dalek::SigningKey, + cid: &str, + topic_uri: &str, + local_node_did: &str, + remote_proofs: &[CarrierReplicationProof], + live_multi_peer_proof: bool, + requested_at: u64, +) -> Result { + let payload = serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_REQUEST_SCHEMA, + "provider": "carrier-availability", + "scope": "content-availability", + "cid": cid, + "topic": topic_uri, + "local_node_did": local_node_did, + "live_multi_peer_proof": live_multi_peer_proof, + "remote_provider_proofs": remote_proofs.len(), + "remote_proofs": carrier_peer_attestation_remote_proofs_json(remote_proofs), + "requested_at": requested_at, + "authority": { + "runtime_invocation_required": true, + "provider_owned_exchange": true, + "raw_carrier_ticket_exposed": false, + "raw_backend_access": false, + }, + }); + let canonical = serde_json::to_string(&payload).map_err(|err| { + ProviderError::Provider(format!( + "Carrier peer-attestation request serialization failed: {err}" + )) + })?; + let (signature, signer_did) = crate::crypto::domain_separated_sign( + signing_key, + CARRIER_PEER_ATTESTATION_EXCHANGE_REQUEST_DOMAIN, + canonical.as_bytes(), + ); + Ok(serde_json::json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + })) +} + +fn carrier_peer_attestation_exchange_receipt_from_response( + response: &serde_json::Value, + exchange: serde_json::Value, + http_status: u16, +) -> Result { + let accepted = response + .get("accepted") + .and_then(|value| value.as_bool()) + .ok_or_else(|| { + "peer-attestation exchange response requires accepted boolean".to_string() + })?; + let signed_receipt = response.get("receipt").cloned(); + let verified_receipt = match signed_receipt.as_ref() { + Some(receipt) => { + let signer_did = receipt + .get("signer_did") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + "peer-attestation exchange signed receipt requires signer_did".to_string() + })?; + let receipt_bytes = serde_json::to_vec(receipt) + .map_err(|err| format!("peer-attestation exchange receipt encode failed: {err}"))?; + let expected_signers = [signer_did.to_string()]; + crate::crypto::verify_signed_json_envelope_against_dids( + &receipt_bytes, + CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_DOMAIN, + &expected_signers, + ) + .map_err(|err| { + format!("peer-attestation exchange receipt verification failed: {err}") + })?; + let payload = receipt + .get("payload") + .cloned() + .unwrap_or(serde_json::Value::Null); + Some(serde_json::json!({ + "verified": true, + "signer_did": signer_did, + "payload_schema": payload + .get("schema") + .cloned() + .unwrap_or(serde_json::Value::Null), + "exchange_id": payload + .get("exchange_id") + .cloned() + .unwrap_or(serde_json::Value::Null), + "receipt_id": payload + .get("receipt_id") + .cloned() + .unwrap_or(serde_json::Value::Null), + })) + } + None if accepted => { + return Err( + "peer-attestation exchange accepted response requires signed receipt".to_string(), + ) + } + None => None, + }; + Ok(serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA, + "provider": "carrier-availability", + "scope": "content-availability", + "configured": true, + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "http_status": http_status, + "exchange": exchange, + "remote_schema": response.get("schema").cloned().unwrap_or(serde_json::Value::Null), + "remote_exchange_id": response.get("exchange_id").cloned().unwrap_or(serde_json::Value::Null), + "remote_receipt_id": response.get("receipt_id").cloned().unwrap_or(serde_json::Value::Null), + "signed_receipt": verified_receipt.unwrap_or_else(|| { + serde_json::json!({ + "verified": false, + "reason": "no signed receipt returned", + }) + }), + "reason": response.get("reason").cloned().unwrap_or(serde_json::Value::Null), + "credential_exposed": false, + })) +} + +fn carrier_peer_attestation_endpoint_unavailable( + reason: String, + endpoint: &CarrierPeerAttestationExchangeEndpoint, +) -> serde_json::Value { + serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA, + "provider": "carrier-availability", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "failed", + "exchange": endpoint.redacted_status_json(), + "signed_receipt": { + "verified": false, + "reason": reason, + }, + "reason": reason, + "credential_exposed": false, + }) +} + +#[derive(Debug, Clone, Copy)] +struct CarrierAvailabilityRequirements { + min_replicas: u32, + max_replicas: Option, + require_live_multi_peer_proof: bool, + repair_graph_kind: CarrierRepairGraphKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CarrierRepairGraphKind { + Auto, + ObjectManifest, + ExactBytes, + IpldDag, +} + +impl CarrierAvailabilityRequirements { + fn from_request(request: &serde_json::Value) -> Self { + let requirements = request + .get("requirements") + .or_else(|| request.get("availability_requirements")); + let min_replicas = requirements + .and_then(|value| value.get("min_replicas")) + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(1) + .max(1); + let max_replicas = requirements + .and_then(|value| value.get("max_replicas")) + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .filter(|value| *value > 0); + let require_live_multi_peer_proof = requirements + .and_then(|value| value.get("require_live_multi_peer_proof")) + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let repair_graph_kind = CarrierRepairGraphKind::from_requirements(requirements); + Self { + min_replicas, + max_replicas, + require_live_multi_peer_proof, + repair_graph_kind, + } + } + + fn effective_max(self) -> u32 { + self.max_replicas + .unwrap_or(MAX_CARRIER_REPLICATION_CANDIDATES as u32 + 1) + .min(MAX_CARRIER_REPLICATION_CANDIDATES as u32 + 1) + .max(1) + } + + fn to_json(self) -> serde_json::Value { + serde_json::json!({ + "min_replicas": self.min_replicas, + "max_replicas": self.max_replicas, + "require_live_multi_peer_proof": self.require_live_multi_peer_proof, + "repair_graph_kind": self.repair_graph_kind.as_str(), + }) + } +} + +impl CarrierRepairGraphKind { + fn from_requirements(requirements: Option<&serde_json::Value>) -> Self { + let raw = requirements + .and_then(|value| { + value + .get("repair_graph_kind") + .or_else(|| value.get("content_graph_kind")) + .or_else(|| value.get("graph_kind")) + .or_else(|| { + value + .get("repair_graph") + .and_then(|graph| graph.get("kind")) + }) + .or_else(|| { + value + .get("content_graph") + .and_then(|graph| graph.get("kind")) + }) + }) + .and_then(|value| value.as_str()) + .unwrap_or("auto"); + match raw { + "object_manifest" | "manifest" => Self::ObjectManifest, + "exact_bytes" | "exact" | "single_block" | "file" => Self::ExactBytes, + "ipld_dag" | "block_dag" | "dag" | "arbitrary_dag" => Self::IpldDag, + _ => Self::Auto, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::ObjectManifest => "object_manifest", + Self::ExactBytes => "exact_bytes", + Self::IpldDag => "ipld_dag", + } + } + + fn supports_current_import_fallback(self) -> bool { + !matches!(self, Self::IpldDag) + } +} + +#[derive(Debug, Clone)] +struct CarrierAvailabilityReplica { + node_did: String, + endpoint_id: Option, + connect_ticket: String, + announced_at: u64, + score: u32, + selection_reason: String, + reputation_score: i32, + reputation_reason: String, +} + +#[derive(Debug, Clone)] +struct CarrierReplicationProof { + node_did: String, + endpoint_id: Option, + announced_at: u64, + score: u32, + selection_reason: String, + reputation_score: i32, + reputation_reason: String, + ensure_status: String, + admission: Option, + status_availability: serde_json::Value, + remote_receipt: Option, + transfer: Option, + checked_at: u64, +} + +#[derive(Clone, Copy)] +struct CarrierPeerAttestationExchangeView<'a> { + configured: bool, + receipt: Option<&'a serde_json::Value>, +} + +struct CarrierPeerAttestationExchangeRequest<'a> { + signing_key: &'a ed25519_dalek::SigningKey, + cid: &'a str, + topic_uri: &'a str, + local_node_did: &'a str, + remote_proofs: &'a [CarrierReplicationProof], + live_multi_peer_proof: bool, + requested_at: u64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct CarrierPeerReputation { + successes: u32, + failures: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CarrierPeerReputationStore { + schema: String, + peers: BTreeMap, +} + +#[async_trait::async_trait] +impl Provider for CarrierAvailabilityProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "use send_raw for typed content availability operations".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["availability"] + } + + fn name(&self) -> &'static str { + "carrier-availability" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + match request.get("op").and_then(|value| value.as_str()) { + Some("ensure") | Some("repair") => self.announce_availability(request).await, + Some("fetch") => self.fetch_from_announced_carrier_peers(request).await, + Some("status") => { + let state = self.state.lock().await; + let node_did = state + .did + .clone() .unwrap_or_else(|| state.endpoint.id().to_string()); - let msg = GossipMessage { - sender_id: request - .get("sender_id") - .and_then(|v| v.as_str()) - .unwrap_or(&default_id) - .to_string(), - sender_nick: sender_nick.to_string(), - content: message.to_string(), - ts: requested_gossip_ts(request), - nonce: requested_gossip_nonce(request), - signature: request - .get("signature") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - sender_session_id: request - .get("sender_session_id") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - }; + Ok(serde_json::json!({ + "status": "ok", + "data": { + "provider": "carrier-availability", + "node_did": node_did, + "transport": "carrier-gossip", + "joined_topic_count": state.joined_topics.len(), + } + })) + } + _ => Ok(carrier_availability_error( + "unsupported_op", + "unsupported Carrier availability operation", + )), + } + } +} + +impl CarrierAvailabilityProvider { + async fn announce_availability( + &self, + request: &serde_json::Value, + ) -> Result { + let cid = match request.get("cid").and_then(|value| value.as_str()) { + Some(cid) => cid.trim(), + None => { + return Ok(carrier_availability_error( + "invalid_request", + "Carrier availability requires cid", + )) + } + }; + if let Err(err) = validate_content_cid(cid) { + return Ok(carrier_availability_error("invalid_cid", err)); + } + let uri = request + .get("uri") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("elastos://{cid}")); + let policy = request + .get("policy") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("carrier_default"); + let local = request + .get("local") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + let replicas = local_replica_count(request); + let requirements = CarrierAvailabilityRequirements::from_request(request); + let desired_replicas = requirements + .min_replicas + .max(if replicas > 0 { 2 } else { 1 }) + .min(requirements.effective_max()); + let topic_name = content_availability_topic_name(cid); + let topic_uri = content_availability_topic_uri(cid); + let announced_at = now_secs(); + let mut state = self.state.lock().await; + + if !state.joined_topics.contains(&topic_name) { + if state.joined_topics.len() >= MAX_TOPICS { + return Ok(carrier_availability_error( + "too_many_topics", + "Carrier availability topic limit reached", + )); + } + if let Err(err) = join_gossip_topic(&mut state, &topic_name, false).await { + return Ok(carrier_availability_error( + "join_failed", + format!("Carrier availability topic join failed: {err}"), + )); + } + } + + let node_did = state + .did + .clone() + .unwrap_or_else(|| state.endpoint.id().to_string()); + let existing_messages = { + let buffers = state.buffers.lock().await; + buffers + .get(&topic_name) + .map(|buffer| buffer.messages.iter().rev().cloned().collect::>()) + .unwrap_or_default() + }; + let reputation_snapshot = self.peer_reputation.lock().await.clone(); + let remote_candidate_limit = + carrier_remote_candidate_limit(requirements, replicas, desired_replicas); + let remote_candidate_pool = content_availability_replicas_with_reputation( + &existing_messages, + cid, + &reputation_snapshot, + ) + .into_iter() + .filter(|replica| replica.node_did != node_did) + .collect::>(); + let remote_candidate_count = remote_candidate_pool.len(); + let remote_candidate_limit_applied = remote_candidate_count > remote_candidate_limit; + let remote_candidates = remote_candidate_pool + .into_iter() + .take(remote_candidate_limit) + .collect::>(); + let fetch_descriptor = if replicas > 0 { + Some(serde_json::json!({ + "transport": "carrier-file", + "endpoint_id": state.endpoint.id().to_string(), + "connect_ticket": carrier_connect_ticket(&state.endpoint), + })) + } else { + None + }; + let signing_key = match state.signing_key.as_ref() { + Some(signing_key) => signing_key, + None => { + return Ok(carrier_availability_error( + "signer_unavailable", + "Carrier availability signer unavailable", + )) + } + }; + let mut payload = serde_json::json!({ + "schema": CONTENT_AVAILABILITY_ANNOUNCEMENT_SCHEMA, + "cid": cid, + "uri": uri, + "policy": policy, + "provider": "carrier-availability", + "node_did": node_did, + "topic": topic_uri, + "local": local, + "announced_at": announced_at, + }); + if let Some(fetch_descriptor) = fetch_descriptor { + payload["fetch"] = fetch_descriptor; + } + if let Some(object_did) = request.get("object_did").and_then(|value| value.as_str()) { + payload["object_did"] = serde_json::Value::String(object_did.to_string()); + } + if let Some(publisher_did) = request + .get("publisher_did") + .and_then(|value| value.as_str()) + { + payload["publisher_did"] = serde_json::Value::String(publisher_did.to_string()); + } + let canonical = serde_json::to_string(&payload).map_err(|err| { + ProviderError::Provider(format!( + "Carrier availability announcement serialization failed: {err}" + )) + })?; + let (signature, signer_did) = crate::crypto::domain_separated_sign( + signing_key, + CONTENT_AVAILABILITY_ANNOUNCEMENT_DOMAIN, + canonical.as_bytes(), + ); + let peer_attestation_signing_key = signing_key.clone(); + let announcement = serde_json::json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + }); + let content = serde_json::to_string(&announcement).map_err(|err| { + ProviderError::Provider(format!( + "Carrier availability announcement envelope failed: {err}" + )) + })?; + let msg = GossipMessage { + sender_id: signer_did.clone(), + sender_nick: "content-provider".to_string(), + content, + ts: announced_at, + nonce: random_gossip_nonce(), + signature: Some(signature.clone()), + sender_session_id: request + .get("publisher_did") + .and_then(|value| value.as_str()) + .map(str::to_string), + }; + + { + let mut buffers = state.buffers.lock().await; + if let Some(buffer) = buffers.get_mut(&topic_name) { + if buffer.messages.len() >= MAX_BUFFER { + buffer.messages.pop_front(); + buffer.base_index += 1; + } + buffer.messages.push_back(msg.clone()); + } + } + + let delivery = match state.senders.get(&topic_name) { + Some(sender) => { + let bytes = serde_json::to_vec(&msg).unwrap_or_default(); + match sender.broadcast(bytes).await { + Ok(_) => "carrier", + Err(err) => { + tracing::debug!("Carrier availability broadcast failed: {}", err); + "local_only" + } + } + } + None => "local_only", + }; + drop(state); + + let mut remote_proofs = Vec::new(); + let mut replication_errors = Vec::new(); + let mut attempted_remote_invocations = 0_u32; + if !remote_candidates.is_empty() { + match self + .provider_registry + .as_ref() + .and_then(|registry| registry.upgrade()) + { + Some(registry) => { + for candidate in remote_candidates { + attempted_remote_invocations = + attempted_remote_invocations.saturating_add(1); + match ensure_content_via_carrier_provider_invocation( + ®istry, &candidate, cid, request, + ) + .await + { + Ok(proof) => { + self.record_peer_reputation(&candidate.node_did, true).await; + remote_proofs.push(proof) + } + Err(err) => { + self.record_peer_reputation(&candidate.node_did, false) + .await; + replication_errors.push(format!("{}: {err}", candidate.node_did)) + } + } + } + } + None => replication_errors + .push("Carrier replication requires Runtime provider registry".to_string()), + } + } + + let proven_remote_replicas = remote_proofs.len() as u32; + let total_replicas = replicas.saturating_add(proven_remote_replicas); + let live_multi_peer_proof = proven_remote_replicas > 0; + let peer_attestation_exchange_receipt = self + .exchange_peer_attestations(CarrierPeerAttestationExchangeRequest { + signing_key: &peer_attestation_signing_key, + cid, + topic_uri: &topic_uri, + local_node_did: &node_did, + remote_proofs: &remote_proofs, + live_multi_peer_proof, + requested_at: announced_at, + }) + .await; + let meets_replica_requirement = total_replicas >= requirements.min_replicas; + let meets_live_requirement = + !requirements.require_live_multi_peer_proof || live_multi_peer_proof; + let status = if meets_replica_requirement && meets_live_requirement && live_multi_peer_proof + { + "network_available" + } else if meets_replica_requirement && meets_live_requirement { + "carrier_announced" + } else { + "repair_needed" + }; + let repair_scheduled = status == "repair_needed" + || total_replicas < desired_replicas + || (requirements.require_live_multi_peer_proof && !live_multi_peer_proof); + let mut availability = serde_json::json!({ + "status": status, + "provider": "carrier-availability", + "policy": policy, + "replicas": total_replicas, + "transport": "carrier-gossip", + "delivery": delivery, + "topic": topic_uri, + "peer_selection": carrier_peer_selection_json( + &topic_uri, + &node_did, + replicas, + &remote_proofs, + live_multi_peer_proof, + CarrierPeerAttestationExchangeView { + configured: self.peer_attestation_exchange.is_some(), + receipt: peer_attestation_exchange_receipt.as_ref(), + }, + ), + "quota": carrier_quota_json(requirements, total_replicas, desired_replicas), + "repair_worker": carrier_repair_worker_json(repair_scheduled, status), + "repair_graph": carrier_repair_graph_policy_json(requirements), + "storage_market": carrier_storage_market_policy_json( + total_replicas, + live_multi_peer_proof, + ), + "abuse_controls": carrier_abuse_controls_json( + remote_candidate_count, + remote_candidate_limit, + attempted_remote_invocations, + replication_errors.len() as u32, + remote_candidate_limit_applied, + ), + "checked_at": announced_at, + }); + if status == "repair_needed" { + availability["reason"] = serde_json::Value::String(carrier_repair_reason( + requirements, + total_replicas, + live_multi_peer_proof, + &replication_errors, + )); + } else if delivery == "local_only" && remote_proofs.is_empty() { + availability["reason"] = serde_json::Value::String( + "Carrier announcement was recorded locally; no remote peer delivery was observed" + .to_string(), + ); + } + Ok(serde_json::json!({ + "status": "ok", + "data": { + "availability": availability, + } + })) + } + + async fn fetch_from_announced_carrier_peers( + &self, + request: &serde_json::Value, + ) -> Result { + let cid = match request.get("cid").and_then(|value| value.as_str()) { + Some(cid) => cid.trim(), + None => { + return Ok(carrier_availability_error( + "invalid_request", + "Carrier availability fetch requires cid", + )) + } + }; + if let Err(err) = validate_content_cid(cid) { + return Ok(carrier_availability_error("invalid_cid", err)); + } + let path = request + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or(""); + if let Err(err) = validate_carrier_content_path(path) { + return Ok(carrier_availability_error("invalid_path", err)); + } + let topic_name = content_availability_topic_name(cid); + let messages = { + let mut state = self.state.lock().await; + if !state.joined_topics.contains(&topic_name) { + if state.joined_topics.len() >= MAX_TOPICS { + return Ok(carrier_availability_error( + "too_many_topics", + "Carrier availability topic limit reached", + )); + } + if let Err(err) = join_gossip_topic(&mut state, &topic_name, false).await { + return Ok(carrier_availability_error( + "join_failed", + format!("Carrier availability topic join failed: {err}"), + )); + } + } + let buffers = state.buffers.clone(); + drop(state); + let buffers = buffers.lock().await; + buffers + .get(&topic_name) + .map(|buffer| buffer.messages.iter().rev().cloned().collect::>()) + .unwrap_or_default() + }; + let tickets = content_availability_fetch_tickets(&messages, cid); + if tickets.is_empty() { + return Ok(carrier_availability_error( + "carrier_fetch_unavailable", + "no Carrier availability announcement with a fetch ticket is available for this CID", + )); + } + + let Some(registry) = self + .provider_registry + .as_ref() + .and_then(|registry| registry.upgrade()) + else { + return Ok(carrier_availability_error( + "carrier_provider_invocation_unavailable", + "Carrier availability fetch requires Runtime provider registry", + )); + }; + + let mut errors = Vec::new(); + for ticket in tickets { + match fetch_content_via_carrier_provider_invocation(®istry, &ticket, cid, path).await + { + Ok((bytes, remote_transfer)) => { + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + "availability": { + "status": "network_available", + "provider": "carrier-availability", + "policy": "carrier_provider_invoke", + "replicas": 1, + "transport": "carrier-provider-plane", + "remote_transfer": remote_transfer, + "checked_at": now_secs(), + } + } + })) + } + Err(err) => errors.push(err.to_string()), + } + } + + Ok(carrier_availability_error( + "carrier_fetch_failed", + format!("Carrier content fetch failed: {}", errors.join(" | ")), + )) + } +} + +async fn fetch_content_via_carrier_provider_invocation( + registry: &ProviderRegistry, + ticket: &str, + cid: &str, + path: &str, +) -> Result<(Vec, Option)> { + validate_content_cid(cid).map_err(anyhow::Error::msg)?; + validate_carrier_content_path(path).map_err(anyhow::Error::msg)?; + + let mut request = serde_json::json!({ + "op": "fetch", + "cid": cid, + "local_only": true, + "transfer": "stream", + }); + if !path.is_empty() { + request["path"] = serde_json::Value::String(path.to_string()); + } + + let response = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: "content".to_string(), + op: "fetch".to_string(), + request, + transfer: ProviderTransfer::Stream, + range: None, + progress: None, + transport: ProviderInvocationTransport::Carrier(ProviderCarrierRoute { + connect_ticket: ticket.to_string(), + peer_did: None, + timeout_ms: Some(5_000), + }), + }) + .await + .map_err(|err| anyhow::anyhow!("Carrier provider invocation failed: {err}"))?; + + if response.get("status").and_then(|status| status.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|message| message.as_str()) + .unwrap_or("unknown provider error"); + anyhow::bail!("remote content provider fetch failed: {message}"); + } + let remote_transfer = response.get("_runtime_transfer").cloned(); + let bytes = remote_content_provider_response_bytes(&response)?; + Ok((bytes, remote_transfer)) +} + +async fn ensure_content_via_carrier_provider_invocation( + registry: &ProviderRegistry, + replica: &CarrierAvailabilityReplica, + cid: &str, + source_request: &serde_json::Value, +) -> Result { + validate_content_cid(cid).map_err(anyhow::Error::msg)?; + let route = ProviderCarrierRoute { + connect_ticket: replica.connect_ticket.clone(), + peer_did: Some(replica.node_did.clone()), + timeout_ms: Some(5_000), + }; + let admission = + content_admission_via_carrier_provider_invocation(registry, &route, cid, source_request) + .await?; + let mut ensure_request = serde_json::json!({ + "op": "ensure", + "cid": cid, + "availability_policy": "carrier_replica", + "availability_requirements": { + "min_replicas": 1, + "max_replicas": 1, + "require_live_multi_peer_proof": false, + }, + }); + if let Some(object_did) = source_request + .get("object_did") + .and_then(|value| value.as_str()) + { + ensure_request["object_did"] = serde_json::Value::String(object_did.to_string()); + } + if let Some(publisher_did) = source_request + .get("publisher_did") + .and_then(|value| value.as_str()) + { + ensure_request["publisher_did"] = serde_json::Value::String(publisher_did.to_string()); + } + + let mut ensure_response = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: "content".to_string(), + op: "ensure".to_string(), + request: ensure_request, + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Carrier(route.clone()), + }) + .await + .map_err(|err| anyhow::anyhow!("remote content ensure failed: {err}"))?; + if ensure_response + .get("status") + .and_then(|value| value.as_str()) + == Some("error") + { + let message = ensure_response + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("unknown provider error"); + ensure_response = import_content_via_carrier_provider_invocation( + registry, + replica, + cid, + source_request, + Some(message), + ) + .await?; + } + let ensure_status = ensure_response + .get("data") + .and_then(|data| data.get("availability")) + .and_then(|availability| availability.get("status")) + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + .to_string(); + if matches!( + ensure_status.as_str(), + "repair_needed" | "local_unpinned" | "unknown" + ) { + ensure_response = import_content_via_carrier_provider_invocation( + registry, + replica, + cid, + source_request, + Some(&ensure_status), + ) + .await?; + } + let ensure_status = ensure_response + .get("data") + .and_then(|data| data.get("availability")) + .and_then(|availability| availability.get("status")) + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + .to_string(); + if matches!( + ensure_status.as_str(), + "repair_needed" | "local_unpinned" | "unknown" + ) { + anyhow::bail!( + "remote content import/ensure did not prove a pinned replica: {ensure_status}" + ); + } + let remote_receipt = remote_content_receipt_summary(&ensure_response, cid)?; + + let status_response = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: "content".to_string(), + op: "status".to_string(), + request: serde_json::json!({ + "op": "status", + "cid": cid, + }), + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Carrier(route), + }) + .await + .map_err(|err| anyhow::anyhow!("remote content status failed: {err}"))?; + if status_response + .get("status") + .and_then(|value| value.as_str()) + == Some("error") + { + let message = status_response + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("unknown provider error"); + anyhow::bail!("remote content status returned error: {message}"); + } + let status_cid = status_response + .get("data") + .and_then(|data| data.get("cid")) + .and_then(|value| value.as_str()) + .unwrap_or(""); + if status_cid != cid { + anyhow::bail!("remote content status CID mismatch"); + } + let status_availability = status_response + .get("data") + .and_then(|data| data.get("availability")) + .cloned() + .ok_or_else(|| anyhow::anyhow!("remote content status missing availability"))?; + let status = status_availability + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + if matches!(status, "repair_needed" | "local_unpinned" | "unknown") { + anyhow::bail!("remote content status did not prove a live replica: {status}"); + } + + Ok(CarrierReplicationProof { + node_did: replica.node_did.clone(), + endpoint_id: replica.endpoint_id.clone(), + announced_at: replica.announced_at, + score: replica.score, + selection_reason: replica.selection_reason.clone(), + reputation_score: replica.reputation_score, + reputation_reason: replica.reputation_reason.clone(), + ensure_status, + admission: Some(admission), + status_availability, + remote_receipt, + transfer: status_response.get("_runtime_transfer").cloned(), + checked_at: now_secs(), + }) +} + +async fn content_admission_via_carrier_provider_invocation( + registry: &ProviderRegistry, + route: &ProviderCarrierRoute, + cid: &str, + source_request: &serde_json::Value, +) -> Result { + let mut admission_request = serde_json::json!({ + "op": "admission", + "cid": cid, + "availability_policy": "carrier_replica", + "availability_requirements": carrier_source_requirements_json(source_request), + }); + if let Some(estimated_content_bytes) = carrier_admission_estimated_content_bytes(source_request) + { + admission_request["estimated_content_bytes"] = + serde_json::Value::from(estimated_content_bytes); + } + if let Some(accounting) = source_request + .get("accounting") + .filter(|value| value.is_object()) + { + admission_request["accounting"] = accounting.clone(); + } + if let Some(object_did) = source_request + .get("object_did") + .and_then(|value| value.as_str()) + { + admission_request["object_did"] = serde_json::Value::String(object_did.to_string()); + } + if let Some(publisher_did) = source_request + .get("publisher_did") + .and_then(|value| value.as_str()) + { + admission_request["publisher_did"] = serde_json::Value::String(publisher_did.to_string()); + } + + let response = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: "content".to_string(), + op: "admission".to_string(), + request: admission_request, + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Carrier(route.clone()), + }) + .await + .map_err(|err| anyhow::anyhow!("remote content admission failed: {err}"))?; + if response.get("status").and_then(|value| value.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("unknown provider error"); + anyhow::bail!("remote content admission returned error: {message}"); + } + let mut admission = response + .get("data") + .and_then(|data| data.get("admission")) + .filter(|value| value.is_object()) + .cloned() + .ok_or_else(|| anyhow::anyhow!("remote content admission missing admission receipt"))?; + let receipt_summary = remote_content_admission_receipt_summary(&response, &admission, cid)?; + if let Some(admission) = admission.as_object_mut() { + admission.insert("receipt".to_string(), receipt_summary); + } + if admission.get("accepted").and_then(|value| value.as_bool()) != Some(true) { + let status = admission + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("rejected"); + let reason = admission + .get("reason") + .and_then(|value| value.as_str()) + .unwrap_or("remote content provider rejected admission"); + anyhow::bail!("remote content admission rejected: {status}: {reason}"); + } + Ok(admission) +} + +fn remote_content_admission_receipt_summary( + response: &serde_json::Value, + admission: &serde_json::Value, + cid: &str, +) -> Result { + let receipt = response + .get("data") + .and_then(|data| data.get("receipt")) + .filter(|value| value.is_object()) + .ok_or_else(|| anyhow::anyhow!("remote content admission missing signed receipt"))?; + let payload = receipt + .get("payload") + .ok_or_else(|| anyhow::anyhow!("remote content admission receipt missing payload"))?; + if payload != admission { + anyhow::bail!("remote content admission receipt payload mismatch"); + } + let payload_cid = payload + .get("cid") + .and_then(|value| value.as_str()) + .unwrap_or(""); + if payload_cid != cid { + anyhow::bail!("remote content admission receipt CID mismatch"); + } + let signer_did = receipt + .get("signer_did") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("remote content admission receipt missing signer_did"))?; + let receipt_bytes = serde_json::to_vec(receipt) + .map_err(|err| anyhow::anyhow!("remote content admission receipt encode failed: {err}"))?; + let expected_signers = [signer_did.to_string()]; + crate::crypto::verify_signed_json_envelope_against_dids( + &receipt_bytes, + CONTENT_ADMISSION_DOMAIN, + &expected_signers, + ) + .map_err(|err| { + anyhow::anyhow!("remote content admission receipt verification failed: {err}") + })?; + Ok(serde_json::json!({ + "schema": payload + .get("schema") + .cloned() + .unwrap_or(serde_json::Value::Null), + "signer_did": signer_did, + "verified": true, + })) +} + +fn carrier_source_requirements_json(source_request: &serde_json::Value) -> serde_json::Value { + source_request + .get("requirements") + .or_else(|| source_request.get("availability_requirements")) + .or_else(|| source_request.get("replication_requirements")) + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(|| { + serde_json::json!({ + "min_replicas": 1, + "max_replicas": 1, + "require_live_multi_peer_proof": false, + }) + }) +} + +fn carrier_admission_estimated_content_bytes(source_request: &serde_json::Value) -> Option { + ["estimated_content_bytes", "incoming_content_bytes"] + .into_iter() + .find_map(|field| source_request.get(field).and_then(|value| value.as_u64())) + .or_else(|| { + source_request + .get("accounting") + .and_then(|accounting| accounting.get("content_bytes")) + .and_then(|value| value.as_u64()) + }) + .or_else(|| { + source_request + .get("local") + .and_then(|local| local.get("accounting")) + .and_then(|accounting| accounting.get("content_bytes")) + .and_then(|value| value.as_u64()) + }) +} + +fn remote_content_receipt_summary( + response: &serde_json::Value, + cid: &str, +) -> Result> { + let Some(receipt) = response.get("data").and_then(|data| data.get("receipt")) else { + return Ok(None); + }; + let payload = receipt + .get("payload") + .ok_or_else(|| anyhow::anyhow!("remote content receipt missing payload"))?; + let receipt_cid = payload + .get("cid") + .and_then(|value| value.as_str()) + .unwrap_or(""); + if receipt_cid != cid { + anyhow::bail!("remote content receipt CID mismatch"); + } + let signer_did = receipt + .get("signer_did") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("remote content receipt missing signer_did"))?; + let receipt_bytes = serde_json::to_vec(receipt) + .map_err(|err| anyhow::anyhow!("remote content receipt encode failed: {err}"))?; + let expected_signers = [signer_did.to_string()]; + crate::crypto::verify_signed_json_envelope_against_dids( + &receipt_bytes, + "elastos.content.availability.receipt.v1", + &expected_signers, + ) + .map_err(|err| anyhow::anyhow!("remote content receipt verification failed: {err}"))?; + Ok(Some(serde_json::json!({ + "schema": payload.get("schema").cloned().unwrap_or(serde_json::Value::Null), + "cid": receipt_cid, + "status": payload.get("status").cloned().unwrap_or(serde_json::Value::Null), + "provider": payload.get("provider").cloned().unwrap_or(serde_json::Value::Null), + "policy": payload.get("policy").cloned().unwrap_or(serde_json::Value::Null), + "replicas": payload.get("replicas").cloned().unwrap_or(serde_json::Value::Null), + "peer_selection": remote_content_receipt_peer_selection_summary(payload.get("peer_selection")), + "quota": remote_content_receipt_quota_summary(payload.get("quota")), + "repair_worker": remote_content_receipt_repair_worker_summary(payload.get("repair_worker")), + "repair_graph": remote_content_receipt_repair_graph_summary(payload.get("repair_graph")), + "storage_market": remote_content_receipt_storage_market_summary(payload.get("storage_market")), + "accounting": remote_content_receipt_accounting_summary(payload.get("accounting")), + "abuse_controls": remote_content_receipt_abuse_controls_summary(payload.get("abuse_controls")), + "checked_at": payload.get("checked_at").cloned().unwrap_or(serde_json::Value::Null), + "signer_did": signer_did, + "verified": true, + }))) +} + +fn remote_content_receipt_peer_selection_summary( + peer_selection: Option<&serde_json::Value>, +) -> serde_json::Value { + let Some(peer_selection) = peer_selection else { + return serde_json::json!({"mode": "unknown"}); + }; + let replicas = remote_content_receipt_peer_selection_replicas_summary(peer_selection); + let replica_count = peer_selection + .get("replicas") + .and_then(|value| value.as_array()) + .map(|replicas| replicas.len()) + .unwrap_or(0); + let remote_replicas = peer_selection + .get("replicas") + .and_then(|value| value.as_array()) + .map(|replicas| { + replicas + .iter() + .filter(|replica| { + replica.get("role").and_then(|value| value.as_str()) == Some("remote") + }) + .count() + }) + .unwrap_or(0); + serde_json::json!({ + "mode": peer_selection + .get("mode") + .cloned() + .unwrap_or(serde_json::Value::Null), + "strategy": peer_selection + .get("strategy") + .cloned() + .unwrap_or(serde_json::Value::Null), + "live_multi_peer_proof": peer_selection + .get("live_multi_peer_proof") + .cloned() + .unwrap_or(serde_json::Value::Bool(false)), + "peer_reputation_policy": peer_selection + .get("peer_reputation_policy") + .cloned() + .unwrap_or_else(default_carrier_peer_reputation_policy_json), + "peer_attestation_exchange_policy": peer_selection + .get("peer_attestation_exchange_policy") + .cloned() + .unwrap_or_else(default_carrier_peer_attestation_exchange_policy_json), + "replica_count": replica_count, + "remote_replicas": remote_replicas, + "replica_summary_limit": MAX_REMOTE_RECEIPT_REPLICA_SUMMARY_ROWS, + "replicas_truncated": replica_count > replicas.len(), + "replicas": replicas, + }) +} + +fn default_carrier_peer_reputation_policy_json() -> serde_json::Value { + serde_json::json!({ + "schema": CARRIER_PEER_REPUTATION_SCHEMA, + "policy": "not_reported", + "scope": "content-availability", + "status": "not_reported", + "federation": { + "configured": false, + "cross_runtime_reputation": false, + }, + }) +} + +fn default_carrier_peer_attestation_exchange_policy_json() -> serde_json::Value { + serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_POLICY_SCHEMA, + "policy": "not_reported", + "scope": "content-availability", + "status": "not_reported", + "attestation_exchange": { + "configured": false, + "signed_reputation_receipts": false, + "third_party_attestations": false, + "cross_runtime_trust_policy": false, + }, + }) +} + +fn remote_content_receipt_peer_selection_replicas_summary( + peer_selection: &serde_json::Value, +) -> Vec { + peer_selection + .get("replicas") + .and_then(|value| value.as_array()) + .into_iter() + .flatten() + .take(MAX_REMOTE_RECEIPT_REPLICA_SUMMARY_ROWS) + .map(|replica| { + serde_json::json!({ + "role": replica + .get("role") + .cloned() + .unwrap_or(serde_json::Value::Null), + "node_did": replica + .get("node_did") + .cloned() + .unwrap_or(serde_json::Value::Null), + "endpoint_id": replica + .get("endpoint_id") + .cloned() + .unwrap_or(serde_json::Value::Null), + "score": replica + .get("score") + .cloned() + .unwrap_or(serde_json::Value::Null), + "selection_reason": replica + .get("selection_reason") + .cloned() + .unwrap_or(serde_json::Value::Null), + "local_reputation": replica + .get("local_reputation") + .cloned() + .unwrap_or(serde_json::Value::Null), + "status": replica + .get("status") + .cloned() + .unwrap_or(serde_json::Value::Null), + }) + }) + .collect() +} + +fn remote_content_receipt_quota_summary(quota: Option<&serde_json::Value>) -> serde_json::Value { + let Some(quota) = quota else { + return serde_json::json!({"policy": "unknown"}); + }; + serde_json::json!({ + "policy": quota.get("policy").cloned().unwrap_or(serde_json::Value::Null), + "status": quota.get("status").cloned().unwrap_or(serde_json::Value::Null), + "enforced": quota + .get("enforced") + .cloned() + .unwrap_or(serde_json::Value::Bool(false)), + "used_replicas": quota + .get("used_replicas") + .cloned() + .unwrap_or(serde_json::Value::Null), + "effective_max_replicas": quota + .get("effective_max_replicas") + .cloned() + .unwrap_or(serde_json::Value::Null), + "federated_quota_ledger_policy": quota + .get("federated_quota_ledger_policy") + .cloned() + .unwrap_or_else(default_carrier_federated_quota_ledger_policy_json), + }) +} + +fn remote_content_receipt_repair_worker_summary( + repair_worker: Option<&serde_json::Value>, +) -> serde_json::Value { + let Some(repair_worker) = repair_worker else { + return serde_json::json!({"status": "unknown"}); + }; + serde_json::json!({ + "scheduled": repair_worker + .get("scheduled") + .cloned() + .unwrap_or(serde_json::Value::Bool(false)), + "status": repair_worker + .get("status") + .cloned() + .unwrap_or(serde_json::Value::Null), + "worker": repair_worker + .get("worker") + .cloned() + .unwrap_or(serde_json::Value::Null), + }) +} + +fn remote_content_receipt_storage_market_summary( + storage_market: Option<&serde_json::Value>, +) -> serde_json::Value { + let Some(storage_market) = storage_market else { + return serde_json::json!({ + "schema": "elastos.content.storage-market/v1", + "status": "not_reported", + "settlement": "not_configured", + "admission_policy": default_carrier_storage_market_admission_policy_json(), + }); + }; + serde_json::json!({ + "schema": storage_market + .get("schema") + .cloned() + .unwrap_or_else(|| serde_json::Value::String("elastos.content.storage-market/v1".to_string())), + "mode": storage_market + .get("mode") + .cloned() + .unwrap_or(serde_json::Value::Null), + "status": storage_market + .get("status") + .cloned() + .unwrap_or(serde_json::Value::Null), + "settlement": storage_market + .get("settlement") + .cloned() + .unwrap_or_else(|| serde_json::Value::String("not_configured".to_string())), + "escrow": storage_market + .get("escrow") + .cloned() + .unwrap_or_else(|| serde_json::Value::String("not_configured".to_string())), + "quota_enforced": storage_market + .get("quota_enforced") + .cloned() + .unwrap_or(serde_json::Value::Bool(false)), + "admission_policy": storage_market + .get("admission_policy") + .cloned() + .unwrap_or_else(default_carrier_storage_market_admission_policy_json), + "settlement_policy": storage_market + .get("settlement_policy") + .cloned() + .unwrap_or_else(default_carrier_storage_settlement_policy_json), + }) +} + +fn carrier_storage_market_admission_policy_json( + mode: &str, + market_status: &str, + quota_enforced: bool, + live_multi_peer_proof: bool, + remote_admission_preflight: bool, +) -> serde_json::Value { + serde_json::json!({ + "schema": CONTENT_STORAGE_MARKET_ADMISSION_POLICY_SCHEMA, + "policy": "proof_path_admission_no_production_market", + "scope": "content-availability", + "status": if remote_admission_preflight { + "remote_admission_preflight_no_market_admission" + } else if quota_enforced { + "local_quota_admission_no_market_admission" + } else { + "production_storage_market_admission_not_configured" + }, + "market": { + "mode": mode, + "status": market_status, + "quota_enforced": quota_enforced, + "live_multi_peer_proof": live_multi_peer_proof, + }, + "current_admission": { + "local_principal_quota_ledger": quota_enforced, + "remote_content_admission_preflight": remote_admission_preflight, + "signed_admission_receipts": remote_admission_preflight, + "content_admission_schema": "elastos.content.admission/v1", + "content_admission_receipt_domain": CONTENT_ADMISSION_DOMAIN, + "provider_invocation_required": true, + "signed_availability_receipts": true, + }, + "production_market": { + "configured": false, + "provider_admission_network": false, + "provider_offer_receipts": false, + "price_discovery": false, + "sla_admission": false, + "abuse_economic_controls": false, + "reason": "Carrier verifies signed remote content/admission receipts in this branch; production storage-market admission needs provider offers, pricing, SLA, and trust policy receipts", + }, + }) +} + +fn default_carrier_storage_market_admission_policy_json() -> serde_json::Value { + carrier_storage_market_admission_policy_json( + "not_reported", + "not_reported", + false, + false, + false, + ) +} + +fn carrier_storage_settlement_policy_json( + mode: &str, + market_status: &str, + quota_enforced: bool, + live_multi_peer_proof: bool, +) -> serde_json::Value { + serde_json::json!({ + "schema": "elastos.content.storage-settlement-policy/v1", + "policy": "no_settlement_receipt_policy", + "scope": "content-availability", + "status": "settlement_not_configured", + "market": { + "mode": mode, + "status": market_status, + "quota_enforced": quota_enforced, + "live_multi_peer_proof": live_multi_peer_proof, + }, + "settlement": { + "pricing": "not_configured", + "escrow": "not_configured", + "payment_settlement": "not_configured", + "sla_enforcement": "not_configured", + }, + "production_federation": { + "configured": false, + "storage_market_admission": false, + "cross_provider_escrow": false, + "settlement_receipts": false, + "reason": "Carrier can prove provider replicas in this branch; pricing, escrow, settlement, and SLA policy require production storage-market providers", + }, + }) +} + +fn default_carrier_storage_settlement_policy_json() -> serde_json::Value { + carrier_storage_settlement_policy_json("not_reported", "not_reported", false, false) +} + +fn remote_content_receipt_repair_graph_summary( + repair_graph: Option<&serde_json::Value>, +) -> serde_json::Value { + let Some(repair_graph) = repair_graph else { + return serde_json::json!({ + "schema": CONTENT_REPAIR_GRAPH_SCHEMA, + "status": "not_reported", + }); + }; + serde_json::json!({ + "schema": repair_graph + .get("schema") + .cloned() + .unwrap_or_else(|| serde_json::Value::String(CONTENT_REPAIR_GRAPH_SCHEMA.to_string())), + "policy": repair_graph + .get("policy") + .cloned() + .unwrap_or(serde_json::Value::Null), + "requested_kind": repair_graph + .get("requested_kind") + .cloned() + .unwrap_or(serde_json::Value::Null), + "status": repair_graph + .get("status") + .cloned() + .unwrap_or(serde_json::Value::Null), + "refuses_exact_fallback_for_arbitrary_dag": repair_graph + .get("refuses_exact_fallback_for_arbitrary_dag") + .cloned() + .unwrap_or(serde_json::Value::Bool(false)), + }) +} + +fn remote_content_receipt_accounting_summary( + accounting: Option<&serde_json::Value>, +) -> serde_json::Value { + let Some(accounting) = accounting else { + return serde_json::json!({"observed": false}); + }; + serde_json::json!({ + "schema": accounting + .get("schema") + .cloned() + .unwrap_or(serde_json::Value::Null), + "observed": accounting + .get("observed") + .cloned() + .unwrap_or(serde_json::Value::Bool(false)), + "files": accounting + .get("files") + .cloned() + .unwrap_or(serde_json::Value::Null), + "content_bytes": accounting + .get("content_bytes") + .cloned() + .unwrap_or(serde_json::Value::Null), + "replica_bytes_estimate": accounting + .get("replica_bytes_estimate") + .cloned() + .unwrap_or(serde_json::Value::Null), + "storage_quota_status": accounting + .get("storage_quota") + .and_then(|quota| quota.get("status")) + .cloned() + .unwrap_or(serde_json::Value::Null), + }) +} + +fn remote_content_receipt_abuse_controls_summary( + abuse_controls: Option<&serde_json::Value>, +) -> serde_json::Value { + let Some(abuse_controls) = abuse_controls else { + return serde_json::json!({"policy": "unknown", "enforced": false}); + }; + serde_json::json!({ + "schema": abuse_controls + .get("schema") + .cloned() + .unwrap_or(serde_json::Value::Null), + "policy": abuse_controls + .get("policy") + .cloned() + .unwrap_or(serde_json::Value::Null), + "enforced": abuse_controls + .get("enforced") + .cloned() + .unwrap_or(serde_json::Value::Bool(false)), + "candidate_count": abuse_controls + .get("candidate_count") + .cloned() + .unwrap_or(serde_json::Value::Null), + "attempt_limit": abuse_controls + .get("attempt_limit") + .cloned() + .unwrap_or(serde_json::Value::Null), + "attempted_operations": abuse_controls + .get("attempted_operations") + .cloned() + .unwrap_or(serde_json::Value::Null), + "failed_operations": abuse_controls + .get("failed_operations") + .cloned() + .unwrap_or(serde_json::Value::Null), + "throttled": abuse_controls + .get("throttled") + .cloned() + .unwrap_or(serde_json::Value::Bool(false)), + }) +} + +async fn import_content_via_carrier_provider_invocation( + registry: &ProviderRegistry, + replica: &CarrierAvailabilityReplica, + cid: &str, + source_request: &serde_json::Value, + ensure_failure: Option<&str>, +) -> Result { + let requirements = CarrierAvailabilityRequirements::from_request(source_request); + if !requirements + .repair_graph_kind + .supports_current_import_fallback() + { + return import_ipld_dag_content_via_carrier_block_graph_provider( + registry, + replica, + cid, + source_request, + ensure_failure, + requirements, + ) + .await; + } + match import_object_content_via_carrier_provider_invocation( + registry, + replica, + cid, + source_request, + ensure_failure, + ) + .await + { + Ok(response) => Ok(response), + Err(object_err) => import_exact_content_via_carrier_provider_invocation( + registry, + replica, + cid, + source_request, + ensure_failure, + ) + .await + .map_err(|exact_err| { + anyhow::anyhow!( + "remote content object import failed: {object_err}; exact import fallback failed: {exact_err}" + ) + }), + } +} + +async fn import_ipld_dag_content_via_carrier_block_graph_provider( + registry: &ProviderRegistry, + replica: &CarrierAvailabilityReplica, + cid: &str, + source_request: &serde_json::Value, + ensure_failure: Option<&str>, + requirements: CarrierAvailabilityRequirements, +) -> Result { + let mut export_request = serde_json::json!({ + "op": "export_graph", + "cid": cid, + "schema": CONTENT_BLOCK_GRAPH_SCHEMA, + "repair_graph_kind": requirements.repair_graph_kind.as_str(), + "availability_requirements": requirements.to_json(), + "policy": "carrier_block_graph_repair", + }); + copy_optional_content_identity(source_request, &mut export_request); + + let export_response = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: CONTENT_BLOCK_GRAPH_TARGET.to_string(), + op: "export_graph".to_string(), + request: export_request, + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .map_err(|err| { + anyhow::anyhow!( + "local block-graph export failed for arbitrary DAG repair: {err}; Carrier refused object/exact fallback" + ) + })?; + ensure_provider_ok(&export_response, "local block-graph export")?; + let graph = exported_block_graph(&export_response, cid)?; + + let route = ProviderCarrierRoute { + connect_ticket: replica.connect_ticket.clone(), + peer_did: Some(replica.node_did.clone()), + timeout_ms: Some(5_000), + }; + let mut import_request = serde_json::json!({ + "op": "import_graph", + "cid": cid, + "graph": graph, + "availability_policy": "carrier_block_graph_import", + "availability_requirements": requirements.to_json(), + "ensure_failure": ensure_failure, + }); + copy_optional_content_identity(source_request, &mut import_request); + + let import_response = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: CONTENT_BLOCK_GRAPH_TARGET.to_string(), + op: "import_graph".to_string(), + request: import_request, + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Carrier(route), + }) + .await + .map_err(|err| anyhow::anyhow!("remote block-graph import failed: {err}"))?; + ensure_provider_ok(&import_response, "remote block-graph import")?; + + let mut ensure_request = serde_json::json!({ + "op": "ensure", + "cid": cid, + "availability_policy": "carrier_block_graph_import", + "availability_requirements": requirements.to_json(), + }); + copy_optional_content_identity(source_request, &mut ensure_request); + let route = ProviderCarrierRoute { + connect_ticket: replica.connect_ticket.clone(), + peer_did: Some(replica.node_did.clone()), + timeout_ms: Some(5_000), + }; + let ensure_response = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: "content".to_string(), + op: "ensure".to_string(), + request: ensure_request, + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Carrier(route), + }) + .await + .map_err(|err| { + anyhow::anyhow!("remote content ensure after block-graph import failed: {err}") + })?; + ensure_provider_ok( + &ensure_response, + "remote content ensure after block-graph import", + )?; + Ok(ensure_response) +} + +fn ensure_provider_ok(response: &serde_json::Value, label: &str) -> Result<()> { + if response.get("status").and_then(|status| status.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|message| message.as_str()) + .unwrap_or("unknown provider error"); + anyhow::bail!("{label} returned error: {message}"); + } + Ok(()) +} + +fn exported_block_graph(response: &serde_json::Value, cid: &str) -> Result { + let graph = response + .get("data") + .and_then(|data| data.get("graph")) + .cloned() + .ok_or_else(|| anyhow::anyhow!("local block-graph export missing data.graph"))?; + if graph.get("schema").and_then(|value| value.as_str()) != Some(CONTENT_BLOCK_GRAPH_SCHEMA) { + anyhow::bail!("local block-graph export returned unsupported graph schema"); + } + let root_cid = graph + .get("root_cid") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if root_cid != cid { + anyhow::bail!("local block-graph export root CID mismatch"); + } + Ok(graph) +} + +fn copy_optional_content_identity(source: &serde_json::Value, target: &mut serde_json::Value) { + for key in ["object_did", "publisher_did"] { + if let Some(value) = source.get(key).cloned() { + target[key] = value; + } + } +} + +async fn local_content_fetch_bytes_for_import( + registry: &ProviderRegistry, + cid: &str, + path: Option<&str>, +) -> Result> { + let mut request = serde_json::json!({ + "op": "fetch", + "cid": cid, + "local_only": true, + "transfer": "stream", + }); + if let Some(path) = path.filter(|path| !path.is_empty()) { + request["path"] = serde_json::Value::String(path.to_string()); + } + let response = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: "content".to_string(), + op: "fetch".to_string(), + request, + transfer: ProviderTransfer::Stream, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .map_err(|err| anyhow::anyhow!("local content fetch for object import failed: {err}"))?; + if response.get("status").and_then(|value| value.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("unknown provider error"); + anyhow::bail!("local content fetch for object import returned error: {message}"); + } + remote_content_provider_response_bytes(&response) +} + +async fn import_object_content_via_carrier_provider_invocation( + registry: &ProviderRegistry, + replica: &CarrierAvailabilityReplica, + cid: &str, + source_request: &serde_json::Value, + ensure_failure: Option<&str>, +) -> Result { + let manifest_bytes = + local_content_fetch_bytes_for_import(registry, cid, Some(CONTENT_OBJECT_MANIFEST_PATH)) + .await?; + let manifest: ContentObjectManifest = + serde_json::from_slice(&manifest_bytes).map_err(|err| { + anyhow::anyhow!("local content object manifest decode failed for {cid}: {err}") + })?; + if manifest.files.is_empty() { + anyhow::bail!("local content object manifest has no files"); + } + if manifest.files.len() > MAX_CARRIER_OBJECT_IMPORT_FILES { + anyhow::bail!( + "local content object manifest exceeds {} files", + MAX_CARRIER_OBJECT_IMPORT_FILES + ); + } + let mut files = Vec::with_capacity(manifest.files.len()); + let mut total_bytes = 0_usize; + for file in &manifest.files { + let bytes = local_content_fetch_bytes_for_import(registry, cid, Some(&file.path)).await?; + if bytes.len() as u64 != file.size { + anyhow::bail!( + "local content object file {} size mismatch: manifest {}, fetched {}", + file.path, + file.size, + bytes.len() + ); + } + let sha256 = format!("{:x}", Sha256::digest(&bytes)); + if sha256 != file.sha256 { + anyhow::bail!( + "local content object file {} digest mismatch: manifest {}, fetched {}", + file.path, + file.sha256, + sha256 + ); + } + total_bytes = total_bytes.saturating_add(bytes.len()); + if total_bytes > MAX_CARRIER_OBJECT_IMPORT_BYTES { + anyhow::bail!( + "local content object import exceeds {} bytes", + MAX_CARRIER_OBJECT_IMPORT_BYTES + ); + } + files.push(serde_json::json!({ + "path": file.path.clone(), + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + })); + } + let file_count = files.len(); + let mut import_request = serde_json::json!({ + "op": "import_object", + "cid": cid, + "object_kind": manifest.kind.clone(), + "files": files, + }); + if let Some(reason) = ensure_failure { + import_request["ensure_failure"] = serde_json::Value::String(reason.to_string()); + } + if !manifest.links.is_empty() { + import_request["links"] = serde_json::to_value(&manifest.links) + .map_err(|err| anyhow::anyhow!("content object links encode failed: {err}"))?; + } + if let Some(object_did) = manifest.object_did.or_else(|| { + source_request + .get("object_did") + .and_then(|value| value.as_str()) + .map(str::to_string) + }) { + import_request["object_did"] = serde_json::Value::String(object_did); + } + if let Some(publisher_did) = manifest.publisher_did.or_else(|| { + source_request + .get("publisher_did") + .and_then(|value| value.as_str()) + .map(str::to_string) + }) { + import_request["publisher_did"] = serde_json::Value::String(publisher_did); + } + import_request["import_summary"] = serde_json::json!({ + "schema": "elastos.content.import-object.request-summary/v1", + "files": file_count, + "bytes": total_bytes, + "source": "local-object-manifest", + }); + + let response = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: "content".to_string(), + op: "import_object".to_string(), + request: import_request, + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Carrier(ProviderCarrierRoute { + connect_ticket: replica.connect_ticket.clone(), + peer_did: Some(replica.node_did.clone()), + timeout_ms: Some(5_000), + }), + }) + .await + .map_err(|err| anyhow::anyhow!("remote content object import failed: {err}"))?; + if response.get("status").and_then(|value| value.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("unknown provider error"); + anyhow::bail!("remote content object import returned error: {message}"); + } + Ok(response) +} + +async fn import_exact_content_via_carrier_provider_invocation( + registry: &ProviderRegistry, + replica: &CarrierAvailabilityReplica, + cid: &str, + source_request: &serde_json::Value, + ensure_failure: Option<&str>, +) -> Result { + let local_fetch = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: "content".to_string(), + op: "fetch".to_string(), + request: serde_json::json!({ + "op": "fetch", + "cid": cid, + "local_only": true, + "transfer": "stream", + }), + transfer: ProviderTransfer::Stream, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .map_err(|err| anyhow::anyhow!("local content fetch for exact import failed: {err}"))?; + if local_fetch.get("status").and_then(|value| value.as_str()) == Some("error") { + let message = local_fetch + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("unknown provider error"); + anyhow::bail!("local content fetch for exact import returned error: {message}"); + } + let stream = local_fetch + .get("data") + .and_then(|data| data.get("stream")) + .cloned() + .ok_or_else(|| anyhow::anyhow!("local content fetch missing stream payload"))?; + let mut import_request = serde_json::json!({ + "op": "import_exact", + "cid": cid, + "stream": stream, + "filename": "content.bin", + }); + if let Some(reason) = ensure_failure { + import_request["ensure_failure"] = serde_json::Value::String(reason.to_string()); + } + if let Some(object_did) = source_request + .get("object_did") + .and_then(|value| value.as_str()) + { + import_request["object_did"] = serde_json::Value::String(object_did.to_string()); + } + if let Some(publisher_did) = source_request + .get("publisher_did") + .and_then(|value| value.as_str()) + { + import_request["publisher_did"] = serde_json::Value::String(publisher_did.to_string()); + } + + let response = registry + .invoke_provider(ProviderInvocation { + source: "carrier-availability".to_string(), + target: "content".to_string(), + op: "import_exact".to_string(), + request: import_request, + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Carrier(ProviderCarrierRoute { + connect_ticket: replica.connect_ticket.clone(), + peer_did: Some(replica.node_did.clone()), + timeout_ms: Some(5_000), + }), + }) + .await + .map_err(|err| anyhow::anyhow!("remote content exact import failed: {err}"))?; + if response.get("status").and_then(|value| value.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("unknown provider error"); + anyhow::bail!("remote content exact import returned error: {message}"); + } + Ok(response) +} + +fn remote_content_provider_response_bytes(response: &serde_json::Value) -> Result> { + let data = response + .get("data") + .and_then(|data| data.as_object()) + .ok_or_else(|| anyhow::anyhow!("remote content provider response missing data"))?; + if let Some(stream) = data.get("stream") { + return decode_carrier_provider_stream_payload(stream); + } + let data_value = data + .get("data") + .ok_or_else(|| anyhow::anyhow!("remote content provider response missing data"))?; + let encoded = data_value + .as_str() + .or_else(|| data_value.get("data").and_then(|value| value.as_str())) + .ok_or_else(|| anyhow::anyhow!("remote content provider response missing base64 data"))?; + base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|err| anyhow::anyhow!("remote content provider returned invalid base64: {err}")) +} + +fn decode_carrier_provider_stream_payload(stream: &serde_json::Value) -> Result> { + let object = stream + .as_object() + .ok_or_else(|| anyhow::anyhow!("remote content provider stream must be an object"))?; + let schema = object + .get("schema") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if schema != "elastos.provider.stream/v1" { + anyhow::bail!( + "remote content provider stream schema mismatch: expected elastos.provider.stream/v1, got {schema}" + ); + } + let encoding = object + .get("encoding") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if encoding != "base64-chunks" { + anyhow::bail!( + "remote content provider stream encoding mismatch: expected base64-chunks, got {encoding}" + ); + } + let chunks = object + .get("chunks") + .and_then(|value| value.as_array()) + .ok_or_else(|| anyhow::anyhow!("remote content provider stream missing chunks"))?; + let mut bytes = Vec::new(); + for (expected_index, chunk) in chunks.iter().enumerate() { + let chunk = chunk.as_object().ok_or_else(|| { + anyhow::anyhow!("remote content provider stream chunk must be an object") + })?; + let index = chunk + .get("index") + .and_then(|value| value.as_u64()) + .ok_or_else(|| anyhow::anyhow!("remote content provider stream chunk missing index"))?; + if index != expected_index as u64 { + anyhow::bail!( + "remote content provider stream chunk index mismatch: expected {expected_index}, got {index}" + ); + } + let offset = chunk + .get("offset") + .and_then(|value| value.as_u64()) + .ok_or_else(|| { + anyhow::anyhow!("remote content provider stream chunk missing offset") + })?; + if offset != bytes.len() as u64 { + anyhow::bail!( + "remote content provider stream chunk {index} offset mismatch: expected {}, got {offset}", + bytes.len() + ); + } + let encoded = chunk + .get("data") + .and_then(|value| value.as_str()) + .ok_or_else(|| anyhow::anyhow!("remote content provider stream chunk missing data"))?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|err| { + anyhow::anyhow!("remote content provider stream chunk has invalid base64: {err}") + })?; + if let Some(length) = chunk.get("length").and_then(|value| value.as_u64()) { + if length != decoded.len() as u64 { + anyhow::bail!( + "remote content provider stream chunk {index} length {length} does not match decoded length {}", + decoded.len() + ); + } + } + bytes.extend_from_slice(&decoded); + } + if let Some(total_bytes) = object.get("total_bytes").and_then(|value| value.as_u64()) { + if total_bytes != bytes.len() as u64 { + anyhow::bail!( + "remote content provider stream total_bytes {total_bytes} does not match decoded length {}", + bytes.len() + ); + } + } + Ok(bytes) +} + +fn carrier_connect_ticket(endpoint: &Endpoint) -> String { + let mut watcher = endpoint.watch_addr(); + let addr = watcher.get(); + let ticket_json = serde_json::json!({ + "topic": null, + "endpoints": [addr], + }); + let ticket_bytes = serde_json::to_vec(&ticket_json).unwrap_or_default(); + let mut ticket_str = data_encoding::BASE32_NOPAD.encode(&ticket_bytes); + ticket_str.make_ascii_lowercase(); + ticket_str +} + +fn carrier_peer_selection_json( + topic_uri: &str, + local_node_did: &str, + local_replicas: u32, + remote_proofs: &[CarrierReplicationProof], + live_multi_peer_proof: bool, + peer_attestation_exchange: CarrierPeerAttestationExchangeView<'_>, +) -> serde_json::Value { + let mut replicas = Vec::new(); + if local_replicas > 0 { + replicas.push(serde_json::json!({ + "role": "local", + "node_did": local_node_did, + "status": "local_pinned", + })); + } + replicas.extend(remote_proofs.iter().map(|proof| { + serde_json::json!({ + "role": "remote", + "node_did": proof.node_did.clone(), + "endpoint_id": proof.endpoint_id.clone(), + "announced_at": proof.announced_at, + "score": proof.score, + "selection_reason": proof.selection_reason.clone(), + "local_reputation": { + "scope": "local_runtime", + "score_delta": proof.reputation_score, + "reason": proof.reputation_reason.clone(), + }, + "admission": proof.admission.clone(), + "ensure_status": proof.ensure_status.clone(), + "status": proof + .status_availability + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + "remote_receipt": proof.remote_receipt.clone(), + "transfer": proof.transfer.clone(), + "checked_at": proof.checked_at, + }) + })); + serde_json::json!({ + "mode": if live_multi_peer_proof { + "carrier_provider_replication" + } else { + "carrier_topic" + }, + "strategy": "signed_announcement_then_provider_invoke", + "topic": topic_uri, + "live_multi_peer_proof": live_multi_peer_proof, + "peer_reputation_policy": carrier_peer_reputation_policy_json( + remote_proofs, + live_multi_peer_proof, + ), + "peer_attestation_exchange_policy": carrier_peer_attestation_exchange_policy_json( + remote_proofs, + live_multi_peer_proof, + peer_attestation_exchange, + ), + "replicas": replicas, + }) +} + +fn carrier_peer_reputation_policy_json( + remote_proofs: &[CarrierReplicationProof], + live_multi_peer_proof: bool, +) -> serde_json::Value { + let scored_remote_peers = remote_proofs + .iter() + .filter(|proof| proof.reputation_reason != "no_local_history") + .count(); + serde_json::json!({ + "schema": CARRIER_PEER_REPUTATION_SCHEMA, + "policy": "local_runtime_reputation", + "scope": "content-availability", + "status": if scored_remote_peers > 0 { + "local_history_applied" + } else if live_multi_peer_proof { + "live_peer_proof_without_local_history" + } else { + "no_remote_peer_proof" + }, + "local_runtime": { + "used_for_candidate_score": true, + "history_store": "carrier-peer-reputation.json", + "scored_remote_peers": scored_remote_peers, + "max_positive_score_delta": 20, + "max_negative_score_delta": -30, + }, + "federation": { + "configured": false, + "cross_runtime_reputation": false, + "signed_reputation_receipts": false, + "third_party_attestations": false, + "reason": "this branch uses local Runtime success/failure history only; federated peer reputation needs signed cross-provider reputation receipts and trust policy", + }, + }) +} + +fn carrier_peer_attestation_exchange_policy_json( + remote_proofs: &[CarrierReplicationProof], + live_multi_peer_proof: bool, + exchange: CarrierPeerAttestationExchangeView<'_>, +) -> serde_json::Value { + let remote_provider_proofs = remote_proofs.len(); + let verified_remote_content_receipts = remote_proofs + .iter() + .filter(|proof| { + proof + .remote_receipt + .as_ref() + .and_then(|receipt| receipt.get("verified")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + }) + .count(); + let exchange_status = exchange + .receipt + .and_then(|receipt| receipt.get("status")) + .and_then(|value| value.as_str()); + let exchange_accepted = exchange + .receipt + .and_then(|receipt| receipt.get("accepted")) + .and_then(|value| value.as_bool()) + .unwrap_or(false); + serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_POLICY_SCHEMA, + "policy": if exchange.configured { + "configured_peer_attestation_exchange" + } else { + "no_cross_runtime_attestation_exchange" + }, + "scope": "content-availability", + "status": if exchange_accepted { + "attestation_exchange_accepted" + } else if exchange.configured && exchange.receipt.is_some() { + exchange_status.unwrap_or("attestation_exchange_failed") + } else if exchange.configured && live_multi_peer_proof { + "attestation_exchange_configured_without_receipt" + } else if exchange.configured { + "attestation_exchange_configured_no_remote_peer_proof" + } else if live_multi_peer_proof { + "live_peer_proof_without_attestation_exchange" + } else { + "no_remote_peer_proof" + }, + "local_proof": { + "signed_availability_announcements": true, + "verified_remote_content_receipts": verified_remote_content_receipts, + "remote_provider_proofs": remote_provider_proofs, + "local_runtime_reputation": true, + "peer_reputation_schema": CARRIER_PEER_REPUTATION_SCHEMA, + }, + "attestation_exchange": { + "configured": exchange.configured, + "signed_reputation_receipts": exchange_accepted, + "third_party_attestations": false, + "cross_runtime_trust_policy": if exchange.configured { + "configured_endpoint" + } else { + "not_configured" + }, + "revocation": "not_configured", + "receipt": exchange.receipt.cloned().unwrap_or(serde_json::Value::Null), + "reason": if exchange_accepted { + "configured peer-attestation exchange accepted a signed Carrier proof receipt" + } else if exchange.configured { + "a peer-attestation exchange endpoint is configured, but this proof has no accepted signed exchange receipt" + } else { + "this branch verifies signed availability announcements and remote content receipts only; signed cross-runtime reputation attestations need a federated trust policy and receipt exchange" + }, + }, + }) +} + +fn carrier_quota_json( + requirements: CarrierAvailabilityRequirements, + replicas: u32, + desired_replicas: u32, +) -> serde_json::Value { + let effective_max_replicas = requirements.effective_max(); + let requirements_exceed_quota = requirements.min_replicas > effective_max_replicas; + let quota_status = if requirements_exceed_quota { + "requirements_exceed_quota" + } else if replicas >= effective_max_replicas { + "at_quota" + } else { + "within_quota" + }; + serde_json::json!({ + "policy": "carrier_provider_quota", + "scope": "content-availability", + "enforced": true, + "status": quota_status, + "min_replicas": requirements.min_replicas, + "desired_replicas": desired_replicas, + "max_replicas": requirements.max_replicas.unwrap_or(MAX_CARRIER_REPLICATION_CANDIDATES as u32 + 1), + "effective_max_replicas": effective_max_replicas, + "used_replicas": replicas, + "candidate_limit": MAX_CARRIER_REPLICATION_CANDIDATES, + "requirements_exceed_quota": requirements_exceed_quota, + "requirements": requirements.to_json(), + "federated_quota_ledger_policy": carrier_federated_quota_ledger_policy_json( + "carrier_provider_quota", + quota_status, + true, + true, + ), + }) +} + +fn carrier_federated_quota_ledger_policy_json( + mode: &str, + quota_status: &str, + local_principal_ledger: bool, + remote_admission_preflight: bool, +) -> serde_json::Value { + serde_json::json!({ + "schema": CONTENT_FEDERATED_QUOTA_LEDGER_POLICY_SCHEMA, + "policy": "local_principal_ledger_plus_remote_admission_preflight", + "scope": "content-availability", + "status": "federated_quota_ledger_not_configured", + "quota": { + "mode": mode, + "status": quota_status, + "enforced": true, + }, + "local": { + "principal_storage_ledger": local_principal_ledger, + "ledger_schema": "elastos.content.storage-accounting.ledger/v1", + }, + "remote": { + "admission_preflight": remote_admission_preflight, + "signed_admission_receipts": remote_admission_preflight, + "admission_schema": "elastos.content.admission/v1", + "admission_receipt_domain": CONTENT_ADMISSION_DOMAIN, + }, + "federation": { + "configured": false, + "cross_provider_quota_ledger": false, + "storage_admission_network": false, + "signed_admission_receipt_exchange": remote_admission_preflight, + "quota_receipt_exchange": false, + "production_quota_receipt_exchange": false, + "reason": if remote_admission_preflight { + "Carrier verifies signed remote content/admission receipts for this proof path; federated quota ledgers and production storage-admission networks remain unconfigured" + } else { + "Carrier local quota exists, but remote signed admission and federated quota ledgers are not configured for this path" + }, + }, + }) +} + +fn default_carrier_federated_quota_ledger_policy_json() -> serde_json::Value { + carrier_federated_quota_ledger_policy_json("not_reported", "not_reported", false, false) +} + +fn carrier_repair_worker_json(scheduled: bool, availability_status: &str) -> serde_json::Value { + serde_json::json!({ + "scheduled": scheduled, + "status": if scheduled { "queued" } else { "healthy" }, + "worker": "carrier-availability", + "reason": if scheduled { + format!("availability status is {availability_status}") + } else { + "replica requirements satisfied".to_string() + }, + }) +} + +fn carrier_repair_graph_policy_json( + requirements: CarrierAvailabilityRequirements, +) -> serde_json::Value { + let current_modes = ["object_manifest", "exact_bytes"]; + let requested_kind = requirements.repair_graph_kind.as_str(); + let supported = requirements + .repair_graph_kind + .supports_current_import_fallback(); + serde_json::json!({ + "schema": CONTENT_REPAIR_GRAPH_SCHEMA, + "policy": "carrier_provider_bounded_graph_repair", + "requested_kind": requested_kind, + "status": if supported { + "bounded_import_supported" + } else { + "unsupported_without_block_graph_provider" + }, + "supported_import_fallbacks": current_modes, + "refuses_exact_fallback_for_arbitrary_dag": true, + "block_graph_contract": { + "provider": CONTENT_BLOCK_GRAPH_PROVIDER, + "target": CONTENT_BLOCK_GRAPH_TARGET, + "schema": CONTENT_BLOCK_GRAPH_SCHEMA, + "operations": ["export_graph", "import_graph", "status"] + }, + "requires_provider": if supported { + serde_json::Value::Null + } else { + serde_json::Value::String(CONTENT_BLOCK_GRAPH_PROVIDER.to_string()) + }, + }) +} + +fn carrier_storage_market_policy_json( + replicas: u32, + live_multi_peer_proof: bool, +) -> serde_json::Value { + let status = if live_multi_peer_proof { + "receipt_proven_no_market_settlement" + } else { + "local_or_announced_no_market_settlement" + }; + serde_json::json!({ + "schema": "elastos.content.storage-market/v1", + "mode": "carrier_provider_receipts", + "status": status, + "settlement": "not_configured", + "escrow": "not_configured", + "quota_enforced": true, + "replicas": replicas, + "live_multi_peer_proof": live_multi_peer_proof, + "remote_admission_preflight": live_multi_peer_proof, + "admission_policy": carrier_storage_market_admission_policy_json( + "carrier_provider_receipts", + status, + true, + live_multi_peer_proof, + live_multi_peer_proof, + ), + "settlement_policy": carrier_storage_settlement_policy_json( + "carrier_provider_receipts", + status, + true, + live_multi_peer_proof, + ), + "next": "Production storage markets need pricing, escrow/settlement, storage-market admission, and cross-peer SLA policy before enabling." + }) +} + +fn carrier_abuse_controls_json( + candidate_count: usize, + attempt_limit: usize, + attempted_operations: u32, + failed_operations: u32, + candidate_limit_applied: bool, +) -> serde_json::Value { + serde_json::json!({ + "schema": "elastos.content.abuse-controls/v1", + "policy": "carrier_provider_invocation_guardrail", + "scope": "content-availability", + "enforced": true, + "candidate_limit": MAX_CARRIER_REPLICATION_CANDIDATES, + "candidate_count": candidate_count, + "attempt_limit": attempt_limit, + "attempted_operations": attempted_operations, + "failed_operations": failed_operations, + "throttled": candidate_limit_applied, + "reason": if candidate_limit_applied { + "candidate attempt limit applied" + } else { + "candidate attempts within bounded provider-invocation budget" + }, + }) +} + +fn carrier_remote_candidate_limit( + requirements: CarrierAvailabilityRequirements, + local_replicas: u32, + desired_replicas: u32, +) -> usize { + let replica_shortfall = desired_replicas.saturating_sub(local_replicas); + let remaining_quota = requirements.effective_max().saturating_sub(local_replicas); + let live_remote_required = + u32::from(requirements.require_live_multi_peer_proof && remaining_quota > 0); + replica_shortfall + .max(live_remote_required) + .min(remaining_quota) + .min(MAX_CARRIER_REPLICATION_CANDIDATES as u32) as usize +} + +fn carrier_repair_reason( + requirements: CarrierAvailabilityRequirements, + replicas: u32, + live_multi_peer_proof: bool, + errors: &[String], +) -> String { + let mut reasons = Vec::new(); + if replicas < requirements.min_replicas { + reasons.push(format!( + "only {replicas} replica(s) proven; {} required", + requirements.min_replicas + )); + } + if requirements.require_live_multi_peer_proof && !live_multi_peer_proof { + reasons.push( + "live multi-peer proof is required but no independent remote replica was proven" + .to_string(), + ); + } + if !errors.is_empty() { + reasons.push(format!("replication errors: {}", errors.join(" | "))); + } + if reasons.is_empty() { + "Carrier availability repair is required".to_string() + } else { + reasons.join("; ") + } +} + +fn content_availability_replicas( + messages: &[GossipMessage], + cid: &str, +) -> Vec { + content_availability_replicas_with_reputation(messages, cid, &HashMap::new()) +} + +fn content_availability_replicas_with_reputation( + messages: &[GossipMessage], + cid: &str, + reputation: &HashMap, +) -> Vec { + let mut replicas = Vec::new(); + for message in messages { + let Ok((envelope, signer_did)) = crate::crypto::verify_signed_json_envelope_against_dids( + message.content.as_bytes(), + CONTENT_AVAILABILITY_ANNOUNCEMENT_DOMAIN, + &[], + ) else { + continue; + }; + let Some(payload) = envelope.get("payload") else { + continue; + }; + if payload.get("schema").and_then(|value| value.as_str()) + != Some(CONTENT_AVAILABILITY_ANNOUNCEMENT_SCHEMA) + { + continue; + } + if payload.get("cid").and_then(|value| value.as_str()) != Some(cid) { + continue; + } + if payload.get("node_did").and_then(|value| value.as_str()) != Some(signer_did.as_str()) { + continue; + } + let Some(ticket) = payload + .get("fetch") + .and_then(|value| value.get("connect_ticket")) + .and_then(|value| value.as_str()) + .filter(|value| { + let value = value.trim(); + !value.is_empty() && value.len() <= MAX_CARRIER_AVAILABILITY_TICKET_LEN + }) + else { + continue; + }; + let raw_endpoint_id = payload + .get("fetch") + .and_then(|value| value.get("endpoint_id")) + .and_then(|value| value.as_str()); + if raw_endpoint_id + .map(|value| value.len() > MAX_CARRIER_AVAILABILITY_ENDPOINT_ID_LEN) + .unwrap_or(false) + { + continue; + } + let endpoint_id = raw_endpoint_id + .filter(|value| !value.trim().is_empty()) + .map(str::to_string); + let node_did = signer_did.to_string(); + if replicas + .iter() + .any(|replica: &CarrierAvailabilityReplica| replica.node_did == node_did) + { + continue; + } + let announced_at = payload + .get("announced_at") + .and_then(|value| value.as_u64()) + .unwrap_or(message.ts); + let (score, selection_reason, reputation_score, reputation_reason) = + carrier_replica_candidate_score( + endpoint_id.as_deref(), + announced_at, + message.ts, + reputation.get(&node_did), + ); + replicas.push(CarrierAvailabilityReplica { + node_did, + endpoint_id, + connect_ticket: ticket.to_string(), + announced_at, + score, + selection_reason, + reputation_score, + reputation_reason, + }); + } + replicas.sort_by(|a, b| { + b.score + .cmp(&a.score) + .then_with(|| b.announced_at.cmp(&a.announced_at)) + .then_with(|| a.node_did.cmp(&b.node_did)) + }); + replicas +} + +fn carrier_replica_candidate_score( + endpoint_id: Option<&str>, + announced_at: u64, + message_ts: u64, + reputation: Option<&CarrierPeerReputation>, +) -> (u32, String, i32, String) { + let mut score = 50_u32; + let mut reasons = vec!["signed_announcement"]; + if endpoint_id + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + { + score = score.saturating_add(20); + reasons.push("endpoint_advertised"); + } + if announced_at >= message_ts.saturating_sub(60 * 60) { + score = score.saturating_add(20); + reasons.push("fresh"); + } else { + reasons.push("stale"); + } + let (reputation_score, reputation_reason) = carrier_reputation_score(reputation); + if reputation_score > 0 { + score = score.saturating_add(reputation_score as u32); + reasons.push("local_reputation_positive"); + } else if reputation_score < 0 { + score = score.saturating_sub(reputation_score.unsigned_abs()); + reasons.push("local_reputation_negative"); + } else { + reasons.push("local_reputation_neutral"); + } + ( + score.min(100), + reasons.join("+"), + reputation_score, + reputation_reason, + ) +} + +fn carrier_reputation_score(reputation: Option<&CarrierPeerReputation>) -> (i32, String) { + let Some(reputation) = reputation else { + return (0, "no_local_history".to_string()); + }; + let successes = reputation.successes.min(5) as i32; + let failures = reputation.failures.min(5) as i32; + let score = (successes * 4 - failures * 8).clamp(-30, 20); + ( + score, + format!( + "local_runtime_successes:{};failures:{}", + reputation.successes, reputation.failures + ), + ) +} + +fn carrier_peer_reputation_path(data_dir: &std::path::Path) -> PathBuf { + data_dir + .join("ElastOS") + .join("SystemServices") + .join("Content") + .join("carrier-peer-reputation.json") +} + +fn load_carrier_peer_reputation( + data_dir: &std::path::Path, +) -> HashMap { + let path = carrier_peer_reputation_path(data_dir); + let Ok(bytes) = std::fs::read(&path) else { + return HashMap::new(); + }; + let Ok(store) = serde_json::from_slice::(&bytes) else { + tracing::debug!("carrier peer reputation decode failed: {}", path.display()); + return HashMap::new(); + }; + if store.schema != CARRIER_PEER_REPUTATION_SCHEMA { + tracing::debug!( + "carrier peer reputation schema mismatch at {}: {}", + path.display(), + store.schema + ); + return HashMap::new(); + } + store.peers.into_iter().collect() +} + +fn save_carrier_peer_reputation( + data_dir: &std::path::Path, + reputation: &HashMap, +) -> Result<()> { + let path = carrier_peer_reputation_path(data_dir); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let store = CarrierPeerReputationStore { + schema: CARRIER_PEER_REPUTATION_SCHEMA.to_string(), + peers: reputation + .iter() + .map(|(node_did, reputation)| (node_did.clone(), reputation.clone())) + .collect(), + }; + let bytes = serde_json::to_vec_pretty(&store)?; + std::fs::write(path, bytes)?; + Ok(()) +} + +fn content_availability_fetch_tickets(messages: &[GossipMessage], cid: &str) -> Vec { + content_availability_replicas(messages, cid) + .into_iter() + .map(|replica| replica.connect_ticket) + .collect() +} + +// ── Gossip Provider (implements Provider trait) ────────────────── + +/// In-process gossip provider for `elastos://peer/*`. +/// Replaces the separate peer-provider subprocess. +pub struct CarrierGossipProvider { + state: Arc>, +} + +impl CarrierGossipProvider { + pub fn new(state: Arc>) -> Self { + Self { state } + } +} + +impl std::fmt::Debug for CarrierGossipProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CarrierGossipProvider").finish() + } +} + +#[async_trait::async_trait] +impl Provider for CarrierGossipProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "use send_raw for peer operations".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["peer"] + } + fn name(&self) -> &'static str { + "carrier-gossip" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + let op = request.get("op").and_then(|v| v.as_str()).unwrap_or(""); + let mut state = self.state.lock().await; + + match op { + "init" => { + let id = state + .did + .clone() + .unwrap_or_else(|| state.endpoint.id().to_string()); + Ok(serde_json::json!({"status": "ok", "data": {"node_id": id}})) + } + + "gossip_join" => { + let topic_name = request["topic"].as_str().unwrap_or_default(); + let force_direct = request + .get("mode") + .and_then(|value| value.as_str()) + .map(|mode| mode.eq_ignore_ascii_case("direct")) + .unwrap_or(false); + if topic_name.is_empty() { + return Ok( + serde_json::json!({"status":"error","code":"missing_topic","message":"topic required"}), + ); + } + if state.joined_topics.contains(topic_name) { + return Ok( + serde_json::json!({"status":"error","code":"already_joined","message":"already joined"}), + ); + } + if state.joined_topics.len() >= MAX_TOPICS { + return Ok( + serde_json::json!({"status":"error","code":"too_many_topics","message":"topic limit reached"}), + ); + } + + match join_gossip_topic(&mut state, topic_name, force_direct).await { + Ok(()) => Ok(serde_json::json!({"status":"ok","data":{"topic": topic_name}})), + Err(err) => Ok( + serde_json::json!({"status":"error","code":"join_failed","message": err.to_string()}), + ), + } + } + + "gossip_leave" => { + let topic_name = request["topic"].as_str().unwrap_or_default(); + if topic_name.is_empty() { + return Ok( + serde_json::json!({"status":"error","code":"missing_topic","message":"topic required"}), + ); + } + + let removed_sender = state.senders.remove(topic_name); + let removed_task = state.receiver_tasks.remove(topic_name); + let was_joined = state.joined_topics.remove(topic_name); + if removed_sender.is_none() && removed_task.is_none() && !was_joined { + return Ok( + serde_json::json!({"status":"error","code":"not_joined","message":"not joined"}), + ); + } + if let Some(task) = removed_task { + task.abort(); + } + + state + .cursors + .lock() + .await + .retain(|(topic, _), _| topic != topic_name); + state.buffers.lock().await.remove(topic_name); + state.topic_peers.lock().await.remove(topic_name); + + Ok(serde_json::json!({"status":"ok","data":{"topic": topic_name}})) + } + + "gossip_send" => { + let topic_name = request["topic"].as_str().unwrap_or_default(); + let message = request["message"].as_str().unwrap_or_default(); + let sender_nick = request["sender"].as_str().unwrap_or("unknown"); + + let sender = match state.senders.get(topic_name) { + Some(s) => s, + None => { + return Ok( + serde_json::json!({"status":"error","code":"not_joined","message":"not joined"}), + ) + } + }; + + let default_id = state + .did + .clone() + .unwrap_or_else(|| state.endpoint.id().to_string()); + let msg = GossipMessage { + sender_id: request + .get("sender_id") + .and_then(|v| v.as_str()) + .unwrap_or(&default_id) + .to_string(), + sender_nick: sender_nick.to_string(), + content: message.to_string(), + ts: requested_gossip_ts(request), + nonce: requested_gossip_nonce(request), + signature: request + .get("signature") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + sender_session_id: request + .get("sender_session_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + }; + + // Insert into local buffer so other local clients (native chat, + // WASM bridge, microVM bridge) on the same runtime see the message. + { + let mut bufs = state.buffers.lock().await; + if let Some(buf) = bufs.get_mut(topic_name) { + if buf.messages.len() >= MAX_BUFFER { + buf.messages.pop_front(); + buf.base_index += 1; + } + buf.messages.push_back(msg.clone()); + } + } + + let bytes = serde_json::to_vec(&msg).unwrap_or_default(); + match sender.broadcast(bytes).await { + Ok(_) => Ok(serde_json::json!({"status":"ok"})), + Err(e) => { + // Broadcast may fail with 0 peers — message is still in + // the local buffer for same-runtime clients, but remote + // peers did NOT receive it. Report honestly. + tracing::debug!("gossip broadcast to external peers failed: {}", e); + Ok(serde_json::json!({"status":"ok","broadcast":"local_only"})) + } + } + } + + "gossip_recv" => { + let topic_name = request["topic"].as_str().unwrap_or_default(); + let limit = request["limit"].as_u64().unwrap_or(50) as usize; + let consumer_id = request + .get("consumer_id") + .and_then(|v| v.as_str()) + .unwrap_or("default") + .to_string(); + // Skip messages from this sender (prevents local loopback echo) + let skip_sender_id = request + .get("skip_sender_id") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + + let buffers = state.buffers.lock().await; + let buf = match buffers.get(topic_name) { + Some(b) => b, + None => return Ok(serde_json::json!({"status":"ok","data":{"messages":[]}})), + }; + + let mut cursors = state.cursors.lock().await; + // Evict cursors under memory pressure. HashMap iteration order + // is arbitrary, so this is not LRU — it just prevents unbounded + // growth. Active consumers will recreate their cursor on the + // next gossip_recv call. + if cursors.len() >= MAX_CURSORS { + let to_remove: Vec<_> = + cursors.keys().take(MAX_CURSORS / 10).cloned().collect(); + for k in to_remove { + cursors.remove(&k); + } + } + let cursor_key = (topic_name.to_string(), consumer_id); + let cursor = cursors.entry(cursor_key).or_insert(buf.base_index); + + let start = if *cursor >= buf.base_index { + (*cursor - buf.base_index) as usize + } else { + 0 + }; + + let all: Vec<&GossipMessage> = + buf.messages.iter().skip(start).take(limit).collect(); + let count = all.len(); + let messages: Vec<&GossipMessage> = if skip_sender_id.is_empty() { + all + } else { + all.into_iter() + .filter(|m| m.sender_id != skip_sender_id) + .collect() + }; + + *cursor = buf.base_index + start as u64 + count as u64; + + Ok(serde_json::json!({"status":"ok","data":{"messages": messages}})) + } + + "get_ticket" => { + // Use watch_addr() to include relay URLs (NAT traversal) + let mut watcher = state.endpoint.watch_addr(); + let addr = watcher.get(); + let ticket_json = serde_json::json!({ + "topic": null, + "endpoints": [addr], + }); + let ticket_bytes = serde_json::to_vec(&ticket_json).unwrap_or_default(); + let mut ticket_str = data_encoding::BASE32_NOPAD.encode(&ticket_bytes); + ticket_str.make_ascii_lowercase(); + + Ok(serde_json::json!({"status":"ok","data":{ + "ticket": ticket_str, + "node_id": state.endpoint.id().to_string(), + }})) + } + + "connect" => { + let memory_lookup = state.memory_lookup.clone(); + let endpoints = match parse_ticket_endpoints_or_error( + request["ticket"].as_str().unwrap_or_default(), + ) { + Ok(endpoints) => endpoints, + Err(err) => return Ok(err), + }; + let added = add_ticket_endpoints( + &memory_lookup, + &mut state.bootstrap_peers, + &endpoints, + true, + ); + let connected = connect_ticket_endpoints( + &state.endpoint, + &state.gossip, + state.peers.clone(), + &endpoints, + ) + .await; + Ok( + serde_json::json!({"status":"ok","data":{"added": added, "connected": connected}}), + ) + } + + "remember_peer" => { + let memory_lookup = state.memory_lookup.clone(); + let endpoints = match parse_ticket_endpoints_or_error( + request["ticket"].as_str().unwrap_or_default(), + ) { + Ok(endpoints) => endpoints, + Err(err) => return Ok(err), + }; + let added = add_ticket_endpoints( + &memory_lookup, + &mut state.bootstrap_peers, + &endpoints, + false, + ); + Ok(serde_json::json!({"status":"ok","data":{"added": added}})) + } + + "get_node_id" => { + let id = state + .did + .clone() + .unwrap_or_else(|| state.endpoint.id().to_string()); + Ok(serde_json::json!({"status":"ok","data":{"node_id": id}})) + } + + "list_peers" => { + let peers = state.peers.lock().await.clone(); + Ok(serde_json::json!({"status":"ok","data":{"peers": peers}})) + } + + "list_topics" => { + let topics: Vec<&String> = state + .joined_topics + .iter() + .filter(|topic| !topic.starts_with("__elastos_internal/")) + .collect(); + Ok(serde_json::json!({"status":"ok","data":{"topics": topics}})) + } + + "list_topic_peers" => { + let topic_name = request["topic"].as_str().unwrap_or_default(); + if topic_name.is_empty() { + return Ok( + serde_json::json!({"status":"error","code":"missing_topic","message":"topic required"}), + ); + } + let mut peers: Vec = state + .topic_peers + .lock() + .await + .get(topic_name) + .cloned() + .unwrap_or_default() + .into_iter() + .collect(); + peers.sort(); + Ok(serde_json::json!({"status":"ok","data":{"topic": topic_name, "peers": peers}})) + } + + "gossip_join_peers" => { + let topic_name = request["topic"].as_str().unwrap_or_default(); + if topic_name.is_empty() { + return Ok( + serde_json::json!({"status":"error","code":"missing_topic","message":"topic required"}), + ); + } + let sender = match state.senders.get(topic_name) { + Some(s) => s, + None => { + return Ok( + serde_json::json!({"status":"error","code":"not_joined","message":"not joined"}), + ) + } + }; + let peer_ids: Vec = request + .get("peers") + .and_then(|v| v.as_array()) + .into_iter() + .flatten() + .filter_map(|v| v.as_str()) + .filter_map(|peer| peer.parse::().ok()) + .collect(); + if peer_ids.is_empty() { + return Ok( + serde_json::json!({"status":"error","code":"missing_peers","message":"peers required"}), + ); + } + match sender.join_peers(peer_ids, None).await { + Ok(_) => Ok(serde_json::json!({"status":"ok","data":{"topic": topic_name}})), + Err(err) => Ok( + serde_json::json!({"status":"error","code":"join_failed","message": err.to_string()}), + ), + } + } + + _ => Ok( + serde_json::json!({"status":"error","code":"unknown_op","message": format!("unknown: {}", op)}), + ), + } + } +} + +/// Background task: receive gossip messages and buffer them. +async fn handle_gossip_event( + event: iroh_gossip::api::Event, + buffers: &Arc>>, + peers: &Arc>>, + topic_peers: &Arc>>>, + topic: &str, +) { + match event { + iroh_gossip::api::Event::Received(msg) => { + if let Ok(gossip_msg) = serde_json::from_slice::(&msg.content) { + let mut bufs = buffers.lock().await; + if let Some(buf) = bufs.get_mut(topic) { + if buf.messages.len() >= MAX_BUFFER { + buf.messages.pop_front(); + buf.base_index += 1; + } + buf.messages.push_back(gossip_msg); + } + } + } + iroh_gossip::api::Event::NeighborUp(peer) => { + let mut p = peers.lock().await; + let peer_str = peer.to_string(); + if !p.contains(&peer_str) { + p.push(peer_str.clone()); + } + drop(p); + topic_peers + .lock() + .await + .entry(topic.to_string()) + .or_default() + .insert(peer_str); + } + iroh_gossip::api::Event::NeighborDown(peer) => { + let mut p = peers.lock().await; + p.retain(|x| x != &peer.to_string()); + drop(p); + if let Some(topic_set) = topic_peers.lock().await.get_mut(topic) { + topic_set.remove(&peer.to_string()); + } + } + _ => {} + } +} + +async fn recv_loop( + receiver: distributed_topic_tracker::GossipReceiver, + buffers: Arc>>, + peers: Arc>>, + topic_peers: Arc>>>, + topic: String, +) { + loop { + match receiver.next().await { + Some(Ok(event)) => { + handle_gossip_event(event, &buffers, &peers, &topic_peers, &topic).await; + } + Some(Err(e)) => { + tracing::warn!("carrier recv_loop error on '{}': {}", topic, e); + // Continue — transient errors should not kill the receiver + } + None => { + tracing::info!("carrier recv_loop ended for '{}' (stream closed)", topic); + break; + } + } + } +} + +// ── Client and provider-plane invocation ───────────────────────── + +pub struct CarrierProviderInvoker; + +impl CarrierProviderInvoker { + pub fn new() -> Self { + Self + } +} + +impl Default for CarrierProviderInvoker { + fn default() -> Self { + Self::new() + } +} + +#[async_trait::async_trait] +impl ProviderCarrierInvoker for CarrierProviderInvoker { + async fn invoke_carrier_provider( + &self, + route: &ProviderCarrierRoute, + invocation: &ProviderInvocation, + request: serde_json::Value, + ) -> std::result::Result { + let timeout_secs = carrier_route_timeout_secs(route); + let mut endpoints = decode_ticket_endpoints(&route.connect_ticket); + if let Some(peer_did) = route.peer_did.as_deref() { + endpoints.retain(|endpoint| carrier_endpoint_matches_peer(endpoint, peer_did)); + if endpoints.is_empty() { + return Err(ProviderError::Provider( + "Carrier provider invocation peer_did does not match connect_ticket" + .to_string(), + )); + } + } + if endpoints.is_empty() { + return Err(ProviderError::Provider( + "Carrier provider invocation connect_ticket has no endpoints".to_string(), + )); + } + + let mut errors = Vec::new(); + for (index, endpoint) in endpoints.into_iter().enumerate() { + match CarrierClient::connect_endpoint_addr(endpoint, timeout_secs).await { + Ok(client) => match client.invoke_provider(invocation, request.clone()).await { + Ok(response) => return Ok(response), + Err(err) => errors.push(format!("ticket[{index}] invoke failed: {err}")), + }, + Err(err) => errors.push(format!("ticket[{index}] connect failed: {err}")), + } + } + + Err(ProviderError::Provider(format!( + "Carrier provider invocation failed: {}", + errors.join(" | ") + ))) + } +} + +fn carrier_route_timeout_secs(route: &ProviderCarrierRoute) -> u64 { + let timeout_ms = route.timeout_ms.unwrap_or(5_000).clamp(1, 60_000); + timeout_ms.div_ceil(1_000) +} + +fn carrier_endpoint_matches_peer(endpoint: &iroh::EndpointAddr, peer_did: &str) -> bool { + if let Some(public_key) = did_to_public_key(peer_did) { + return endpoint.id == public_key; + } + endpoint.id.to_string() == peer_did +} + +pub struct CarrierClient { + conn: iroh::endpoint::Connection, + _endpoint: Endpoint, +} + +impl CarrierClient { + async fn connect_endpoint_addr(addr: iroh::EndpointAddr, timeout_secs: u64) -> Result { + let mut rng_bytes = [0u8; 32]; + getrandom::getrandom(&mut rng_bytes).map_err(|e| anyhow::anyhow!("rng: {}", e))?; + let secret_key = SecretKey::from_bytes(&rng_bytes); + let endpoint = Endpoint::builder() + .secret_key(secret_key) + .bind() + .await + .context("Failed to bind")?; + + let conn = tokio::time::timeout( + Duration::from_secs(timeout_secs), + endpoint.connect(addr, CARRIER_ALPN), + ) + .await + .map_err(|_| anyhow::anyhow!("connect timed out"))? + .context("connect failed")?; + + Ok(Self { + conn, + _endpoint: endpoint, + }) + } + + pub async fn connect( + publisher_node_id: &str, + publisher_addrs: &[String], + timeout_secs: u64, + ) -> Result { + let public_key: iroh::PublicKey = publisher_node_id.parse().context("Invalid node ID")?; + let mut addr = iroh::EndpointAddr::from(public_key); + for addr_str in publisher_addrs { + if let Ok(sa) = addr_str.parse::() { + addr = addr.with_addrs([iroh::TransportAddr::Ip(sa)]); + break; + } + if let Some((host, port_str)) = addr_str.rsplit_once(':') { + if let Ok(port) = port_str.parse::() { + if let Ok(mut resolved) = + tokio::net::lookup_host(format!("{}:{}", host, port)).await + { + if let Some(sa) = resolved.next() { + addr = addr.with_addrs([iroh::TransportAddr::Ip(sa)]); + break; + } + } + } + } + } + + Self::connect_endpoint_addr(addr, timeout_secs).await + } + + pub async fn connect_trusted_source(source: &TrustedSource, timeout_secs: u64) -> Result { + let ticket_endpoints = decode_ticket_endpoints(&source.connect_ticket); + let mut ticket_errors = Vec::new(); + for endpoint in ticket_endpoints { + match Self::connect_endpoint_addr(endpoint.clone(), timeout_secs).await { + Ok(client) => return Ok(client), + Err(err) => ticket_errors.push(err.to_string()), + } + } + + let node_id = source_node_id(source) + .ok_or_else(|| anyhow::anyhow!("trusted source has no usable Carrier node id"))?; + let addrs = source_carrier_addrs(source); + match Self::connect(&node_id, &addrs, timeout_secs).await { + Ok(client) => Ok(client), + Err(err) if !ticket_errors.is_empty() => Err(anyhow::anyhow!( + "trusted source Carrier connection failed (ticket errors: {}; fallback error: {})", + ticket_errors.join(" | "), + err + )), + Err(err) => Err(err), + } + } + + pub async fn release_head(&self) -> Result> { + let (mut send, recv) = self.conn.open_bi().await?; + let msg = serde_json::json!({"op":"release_head","path":""}); + let mut bytes = serde_json::to_vec(&msg)?; + bytes.push(b'\n'); + send.write_all(&bytes).await?; + send.finish()?; + let mut reader = BufReader::new(recv); + let mut line = String::new(); + reader.read_line(&mut line).await?; + let resp: serde_json::Value = serde_json::from_str(line.trim())?; + if resp["ok"].as_bool() == Some(true) { + Ok(Some(resp["release"].clone())) + } else { + Ok(None) + } + } + + pub async fn fetch_file(&self, path: &str) -> Result> { + let (mut send, mut recv) = self.conn.open_bi().await?; + let msg = serde_json::json!({"op":"file","path":path}); + let mut bytes = serde_json::to_vec(&msg)?; + bytes.push(b'\n'); + send.write_all(&bytes).await?; + send.finish()?; + read_carrier_len_prefixed_bytes(&mut recv, &format!("trusted source file fetch for {path}")) + .await + } + + pub async fn fetch_content(&self, cid: &str, path: Option<&str>) -> Result> { + let (mut send, mut recv) = self.conn.open_bi().await?; + let mut msg = serde_json::json!({ + "op": "content_fetch", + "cid": cid, + }); + if let Some(path) = path.filter(|path| !path.is_empty()) { + msg["path"] = serde_json::Value::String(path.to_string()); + } + let mut bytes = serde_json::to_vec(&msg)?; + bytes.push(b'\n'); + send.write_all(&bytes).await?; + send.finish()?; + read_carrier_len_prefixed_bytes(&mut recv, "content fetch").await + } + + pub async fn invoke_provider( + &self, + invocation: &ProviderInvocation, + request: serde_json::Value, + ) -> Result { + let (mut send, recv) = self.conn.open_bi().await?; + let msg = serde_json::json!({ + "op": "provider_invoke", + "source": invocation.source.as_str(), + "target": invocation.target.as_str(), + "operation": invocation.op.as_str(), + "transfer": invocation.transfer.as_str(), + "range": invocation.range.map(|range| serde_json::json!({ + "start": range.start, + "end": range.end, + })), + "progress": invocation.progress.as_ref().map(|progress| serde_json::json!({ + "request_id": progress.request_id.as_str(), + "expected_bytes": progress.expected_bytes, + })), + "request": request, + }); + let mut bytes = serde_json::to_vec(&msg)?; + bytes.push(b'\n'); + send.write_all(&bytes).await?; + send.finish()?; + + let mut reader = BufReader::new(recv); + let mut line = String::new(); + reader.read_line(&mut line).await?; + let response: serde_json::Value = serde_json::from_str(line.trim())?; + if response.get("ok").and_then(|value| value.as_bool()) == Some(true) { + return Ok(response + .get("result") + .cloned() + .unwrap_or(serde_json::Value::Null)); + } + let message = response + .get("error") + .and_then(|value| value.as_str()) + .unwrap_or("Carrier provider invocation failed"); + anyhow::bail!("{message}"); + } +} + +async fn read_carrier_len_prefixed_bytes( + recv: &mut iroh::endpoint::RecvStream, + operation: &str, +) -> Result> { + let mut len_buf = [0u8; 8]; + recv.read_exact(&mut len_buf).await?; + let len = u64::from_be_bytes(len_buf) as usize; + if len > 200 * 1024 * 1024 { + let mut error_bytes = len_buf.to_vec(); + let tail = recv.read_to_end(16 * 1024).await?; + error_bytes.extend_from_slice(&tail); + if let Ok(text) = String::from_utf8(error_bytes) { + if let Ok(json) = serde_json::from_str::(text.trim()) { + if json["ok"].as_bool() == Some(false) { + let msg = json["error"] + .as_str() + .unwrap_or("Carrier returned an unknown error"); + anyhow::bail!("{operation} failed: {msg}"); + } + } + } + anyhow::bail!("{operation} returned invalid byte reply ({len} bytes declared)"); + } + let mut content = vec![0u8; len]; + recv.read_exact(&mut content).await?; + Ok(content) +} + +async fn fetch_file_with_timeout( + client: &CarrierClient, + path: &str, + timeout_secs: u64, +) -> Result> { + tokio::time::timeout(Duration::from_secs(timeout_secs), client.fetch_file(path)) + .await + .map_err(|_| anyhow::anyhow!("file fetch timed out after {}s", timeout_secs))? +} + +pub async fn fetch_file_from_trusted_source( + source: &TrustedSource, + path: &str, + connect_timeout_secs: u64, + fetch_timeout_secs: u64, +) -> Result> { + let mut errors = Vec::new(); + let ticket_endpoints = decode_ticket_endpoints(&source.connect_ticket); + for (index, endpoint) in ticket_endpoints.into_iter().enumerate() { + match CarrierClient::connect_endpoint_addr(endpoint, connect_timeout_secs).await { + Ok(client) => match fetch_file_with_timeout(&client, path, fetch_timeout_secs).await { + Ok(bytes) => return Ok(bytes), + Err(err) => errors.push(format!("ticket[{index}] fetch failed: {err}")), + }, + Err(err) => errors.push(format!("ticket[{index}] connect failed: {err}")), + } + } + + let relay_endpoints = relay_only_ticket_endpoints(source); + for (index, endpoint) in relay_endpoints.into_iter().enumerate() { + match CarrierClient::connect_endpoint_addr(endpoint, connect_timeout_secs).await { + Ok(client) => match fetch_file_with_timeout(&client, path, fetch_timeout_secs).await { + Ok(bytes) => return Ok(bytes), + Err(err) => errors.push(format!("relay[{index}] fetch failed: {err}")), + }, + Err(err) => errors.push(format!("relay[{index}] connect failed: {err}")), + } + } + + let node_id = source_node_id(source) + .ok_or_else(|| anyhow::anyhow!("trusted source has no usable Carrier node id"))?; + let addrs = source_carrier_addrs(source); + match CarrierClient::connect(&node_id, &addrs, connect_timeout_secs).await { + Ok(client) => match fetch_file_with_timeout(&client, path, fetch_timeout_secs).await { + Ok(bytes) => Ok(bytes), + Err(err) => { + errors.push(format!("direct fetch failed: {err}")); + Err(anyhow::anyhow!( + "trusted source Carrier fetch failed: {}", + errors.join(" | ") + )) + } + }, + Err(err) => { + errors.push(format!("direct connect failed: {err}")); + Err(anyhow::anyhow!( + "trusted source Carrier fetch failed: {}", + errors.join(" | ") + )) + } + } +} + +pub async fn try_p2p_discovery( + publisher_node_id: &str, + publisher_addrs: &[String], + timeout_secs: u64, +) -> Option { + let client = CarrierClient::connect(publisher_node_id, publisher_addrs, timeout_secs) + .await + .ok()?; + let release = client.release_head().await.ok()??; + release["head_cid"].as_str().map(|s| s.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct MockCarrierIpfsProvider; + + #[async_trait::async_trait] + impl Provider for MockCarrierIpfsProvider { + async fn handle( + &self, + _request: ResourceRequest, + ) -> Result { + Err(ProviderError::Provider( + "mock ipfs provider only supports raw operations".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + Vec::new() + } + + fn name(&self) -> &'static str { + "mock-carrier-ipfs-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + if request.get("op").and_then(|value| value.as_str()) != Some("cat") { + return Ok(serde_json::json!({ + "status": "error", + "code": "unsupported", + "message": "unsupported mock ipfs operation" + })); + } + Ok(serde_json::json!({ + "status": "ok", + "data": { + "data": base64::engine::general_purpose::STANDARD.encode(b"carrier content") + } + })) + } + } + + struct MockCarrierContentProvider; + + #[async_trait::async_trait] + impl Provider for MockCarrierContentProvider { + async fn handle( + &self, + _request: ResourceRequest, + ) -> Result { + Err(ProviderError::Provider( + "mock content provider only supports raw operations".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + Vec::new() + } + + fn name(&self) -> &'static str { + "mock-carrier-content-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> Result { + if request.get("op").and_then(|op| op.as_str()) == Some("fetch") + && request + .get("local_only") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "cid": request + .get("cid") + .cloned() + .unwrap_or(serde_json::Value::Null), + "stream": { + "schema": "elastos.provider.stream/v1", + "encoding": "base64-chunks", + "total_bytes": 22, + "completed": true, + "chunks": [ + { + "index": 0, + "offset": 0, + "length": 22, + "data": base64::engine::general_purpose::STANDARD.encode( + b"carrier provider bytes", + ), + } + ], + } + } + })); + } + Ok(serde_json::json!({ + "status": "ok", + "data": { + "op": request.get("op").cloned().unwrap_or(serde_json::Value::Null), + "runtime_invocation": request + .get("_runtime_invocation") + .cloned() + .unwrap_or(serde_json::Value::Null), + } + })) + } + } + + struct MockCarrierObjectContentProvider; + struct MockCarrierBlockGraphProvider; + + #[async_trait::async_trait] + impl Provider for MockCarrierObjectContentProvider { + async fn handle( + &self, + _request: ResourceRequest, + ) -> Result { + Err(ProviderError::Provider( + "mock content provider only supports raw operations".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + Vec::new() + } + + fn name(&self) -> &'static str { + "mock-carrier-object-content-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> std::result::Result { + if request.get("op").and_then(|op| op.as_str()) != Some("fetch") + || !request + .get("local_only") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + return Ok(serde_json::json!({ + "status": "error", + "code": "unsupported", + "message": "mock object content provider only supports local fetch" + })); + } + let path = request + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let bytes = match path { + CONTENT_OBJECT_MANIFEST_PATH => carrier_test_object_manifest_bytes(), + "index.md" => carrier_test_object_file_bytes(), + _ => { + return Ok(serde_json::json!({ + "status": "error", + "code": "not_found", + "message": "mock object path not found" + })) + } + }; + Ok(serde_json::json!({ + "status": "ok", + "data": { + "cid": request + .get("cid") + .cloned() + .unwrap_or(serde_json::Value::Null), + "stream": carrier_test_stream_payload(&bytes) + } + })) + } + } + + #[async_trait::async_trait] + impl Provider for MockCarrierBlockGraphProvider { + async fn handle( + &self, + _request: ResourceRequest, + ) -> Result { + Err(ProviderError::Provider( + "mock block-graph provider only supports raw operations".into(), + )) + } - // Insert into local buffer so other local clients (native chat, - // WASM bridge, microVM bridge) on the same runtime see the message. - { - let mut bufs = state.buffers.lock().await; - if let Some(buf) = bufs.get_mut(topic_name) { - if buf.messages.len() >= MAX_BUFFER { - buf.messages.pop_front(); - buf.base_index += 1; - } - buf.messages.push_back(msg.clone()); + fn schemes(&self) -> Vec<&'static str> { + Vec::new() + } + + fn name(&self) -> &'static str { + "mock-block-graph-provider" + } + + async fn send_raw( + &self, + request: &serde_json::Value, + ) -> std::result::Result { + if request.get("op").and_then(|op| op.as_str()) != Some("export_graph") { + return Ok(serde_json::json!({ + "status": "error", + "code": "unsupported", + "message": "mock block-graph provider only supports export_graph" + })); + } + let cid = request + .get("cid") + .and_then(|value| value.as_str()) + .unwrap_or("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"); + Ok(serde_json::json!({ + "status": "ok", + "data": { + "graph": { + "schema": CONTENT_BLOCK_GRAPH_SCHEMA, + "root_cid": cid, + "kind": "ipld_dag", + "blocks": [ + { + "cid": cid, + "codec": "dag-pb", + "size": 22, + "data": base64::engine::general_purpose::STANDARD.encode( + b"carrier provider bytes", + ) + } + ], + "links": [], + "bytes": 22 } } + })) + } + } - let bytes = serde_json::to_vec(&msg).unwrap_or_default(); - match sender.broadcast(bytes).await { - Ok(_) => Ok(serde_json::json!({"status":"ok"})), - Err(e) => { - // Broadcast may fail with 0 peers — message is still in - // the local buffer for same-runtime clients, but remote - // peers did NOT receive it. Report honestly. - tracing::debug!("gossip broadcast to external peers failed: {}", e); - Ok(serde_json::json!({"status":"ok","broadcast":"local_only"})) + #[derive(Default)] + struct MockCarrierProviderPlaneInvoker { + requests: Mutex>, + fail_ensure: bool, + reject_admission: bool, + omit_admission_receipt: bool, + } + + #[async_trait::async_trait] + impl ProviderCarrierInvoker for MockCarrierProviderPlaneInvoker { + async fn invoke_carrier_provider( + &self, + route: &ProviderCarrierRoute, + invocation: &ProviderInvocation, + request: serde_json::Value, + ) -> std::result::Result { + self.requests.lock().await.push(serde_json::json!({ + "ticket": route.connect_ticket.as_str(), + "source": invocation.source.as_str(), + "target": invocation.target.as_str(), + "op": invocation.op.as_str(), + "request": request, + })); + if invocation.transfer == ProviderTransfer::Stream { + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "cid": "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + "stream": { + "schema": "elastos.provider.stream/v1", + "encoding": "base64-chunks", + "total_bytes": 22, + "completed": true, + "chunks": [ + { + "index": 0, + "offset": 0, + "length": 22, + "data": base64::engine::general_purpose::STANDARD.encode( + b"carrier provider bytes", + ), + } + ], + } + } + })); + } + if invocation.transfer == ProviderTransfer::Json && invocation.op == "admission" { + let admission = serde_json::json!({ + "schema": "elastos.content.admission/v1", + "policy": "content_provider_principal_quota_preflight", + "scope": "content-availability", + "accepted": !self.reject_admission, + "status": if self.reject_admission { "rejected" } else { "accepted" }, + "reason": if self.reject_admission { + Some("mock remote quota exceeded") + } else { + None + }, + "cid": request + .get("cid") + .cloned() + .unwrap_or(serde_json::Value::Null), + "publisher_did": request + .get("publisher_did") + .cloned() + .unwrap_or(serde_json::Value::Null), + "estimated_content_bytes": request + .get("estimated_content_bytes") + .cloned() + .unwrap_or(serde_json::Value::Null), + "quota": { + "policy": "principal_storage_quota", + "status": if self.reject_admission { + "quota_exceeded" + } else { + "within_quota" + }, + "enforced": true + }, + "checked_at": 1_700_000_000, + "app_visible": false + }); + let receipt = if self.omit_admission_receipt { + serde_json::Value::Null + } else { + signed_remote_admission_receipt(&admission) + }; + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "cid": request + .get("cid") + .cloned() + .unwrap_or(serde_json::Value::Null), + "admission": admission, + "receipt": receipt } + })); + } + if invocation.transfer == ProviderTransfer::Json && invocation.op == "ensure" { + let ensure_attempts = self + .requests + .lock() + .await + .iter() + .filter(|request| request["op"] == "ensure") + .count(); + if self.fail_ensure && ensure_attempts == 1 { + return Ok(serde_json::json!({ + "status": "error", + "code": "pin_failed", + "message": "mock remote pin failed" + })); } + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "cid": request + .get("cid") + .cloned() + .unwrap_or(serde_json::Value::Null), + "receipt": signed_remote_content_receipt( + request + .get("cid") + .and_then(|value| value.as_str()) + .unwrap_or("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi") + ), + "availability": { + "status": "local_pinned", + "provider": "content-provider", + "policy": "carrier_replica", + "replicas": 1, + "peer_selection": { + "mode": "single_local", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "not_enforced" + }, + "repair_worker": { + "scheduled": false, + "status": "not_scheduled" + } + } + } + })); + } + if invocation.transfer == ProviderTransfer::Json && invocation.op == "import_exact" { + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "cid": request + .get("cid") + .cloned() + .unwrap_or(serde_json::Value::Null), + "receipt": signed_remote_content_receipt( + request + .get("cid") + .and_then(|value| value.as_str()) + .unwrap_or("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi") + ), + "availability": { + "status": "local_pinned", + "provider": "content-provider", + "policy": "carrier_exact_import", + "replicas": 1, + "peer_selection": { + "mode": "single_local", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "not_enforced" + }, + "repair_worker": { + "scheduled": false, + "status": "not_scheduled" + } + }, + "import": { + "schema": "elastos.content.import-exact/v1", + "verified_cid": true, + "bytes": 22 + } + } + })); + } + if invocation.transfer == ProviderTransfer::Json && invocation.op == "import_object" { + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "cid": request + .get("cid") + .cloned() + .unwrap_or(serde_json::Value::Null), + "receipt": signed_remote_content_receipt( + request + .get("cid") + .and_then(|value| value.as_str()) + .unwrap_or("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi") + ), + "availability": { + "status": "local_pinned", + "provider": "content-provider", + "policy": "carrier_object_import", + "replicas": 1, + "peer_selection": { + "mode": "single_local", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "not_enforced" + }, + "repair_worker": { + "scheduled": false, + "status": "not_scheduled" + } + }, + "import": { + "schema": "elastos.content.import-object/v1", + "verified_cid": true, + "files": request + .get("files") + .and_then(|value| value.as_array()) + .map(|files| files.len()) + .unwrap_or(0) + } + } + })); } + if invocation.transfer == ProviderTransfer::Json && invocation.op == "import_graph" { + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "cid": request + .get("cid") + .cloned() + .unwrap_or(serde_json::Value::Null), + "receipt": signed_remote_content_receipt( + request + .get("cid") + .and_then(|value| value.as_str()) + .unwrap_or("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi") + ), + "availability": { + "status": "local_pinned", + "provider": CONTENT_BLOCK_GRAPH_PROVIDER, + "policy": "carrier_block_graph_import", + "replicas": 1, + "peer_selection": { + "mode": "single_local", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "not_enforced" + }, + "repair_worker": { + "scheduled": false, + "status": "not_scheduled" + }, + "repair_graph": { + "schema": CONTENT_REPAIR_GRAPH_SCHEMA, + "policy": "carrier_provider_bounded_graph_repair", + "requested_kind": "ipld_dag", + "status": "block_graph_provider_imported" + } + }, + "import": { + "schema": CONTENT_BLOCK_GRAPH_SCHEMA, + "verified_cid": true, + "blocks": request + .get("graph") + .and_then(|graph| graph.get("blocks")) + .and_then(|value| value.as_array()) + .map(|blocks| blocks.len()) + .unwrap_or(0) + } + } + })); + } + if invocation.transfer == ProviderTransfer::Json && invocation.op == "status" { + return Ok(serde_json::json!({ + "status": "ok", + "data": { + "cid": request + .get("cid") + .cloned() + .unwrap_or(serde_json::Value::Null), + "availability": { + "status": "local_pinned", + "provider": "content-provider", + "policy": "local_repair_pin", + "replicas": 1, + "peer_selection": { + "mode": "single_local", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "not_enforced" + }, + "repair_worker": { + "scheduled": false, + "status": "not_scheduled" + } + } + } + })); + } + Ok(serde_json::json!({ + "status": "ok", + "data": { + "data": { + "data": base64::engine::general_purpose::STANDARD.encode( + b"carrier provider bytes", + ) + } + } + })) + } + } - "gossip_recv" => { - let topic_name = request["topic"].as_str().unwrap_or_default(); - let limit = request["limit"].as_u64().unwrap_or(50) as usize; - let consumer_id = request - .get("consumer_id") - .and_then(|v| v.as_str()) - .unwrap_or("default") - .to_string(); - // Skip messages from this sender (prevents local loopback echo) - let skip_sender_id = request - .get("skip_sender_id") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(); + #[test] + fn test_topic_hash_deterministic() { + let h1 = topic_hash("#general"); + let h2 = topic_hash("#general"); + assert_eq!(h1, h2, "same topic name must produce same hash"); - let buffers = state.buffers.lock().await; - let buf = match buffers.get(topic_name) { - Some(b) => b, - None => return Ok(serde_json::json!({"status":"ok","data":{"messages":[]}})), - }; + let h3 = topic_hash("#other"); + assert_ne!(h1, h3, "different topics must produce different hashes"); + } - let mut cursors = state.cursors.lock().await; - // Evict cursors under memory pressure. HashMap iteration order - // is arbitrary, so this is not LRU — it just prevents unbounded - // growth. Active consumers will recreate their cursor on the - // next gossip_recv call. - if cursors.len() >= MAX_CURSORS { - let to_remove: Vec<_> = - cursors.keys().take(MAX_CURSORS / 10).cloned().collect(); - for k in to_remove { - cursors.remove(&k); - } + #[test] + fn test_gossip_message_serialization() { + let msg = GossipMessage { + sender_id: "did:key:z6MkTest".to_string(), + sender_nick: "alice".to_string(), + content: "hello world".to_string(), + ts: 1700000000, + nonce: 42, + signature: None, + sender_session_id: None, + }; + let bytes = serde_json::to_vec(&msg).unwrap(); + let decoded: GossipMessage = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(decoded.sender_id, "did:key:z6MkTest"); + assert_eq!(decoded.sender_nick, "alice"); + assert_eq!(decoded.content, "hello world"); + assert_eq!(decoded.ts, 1700000000); + assert_eq!(decoded.nonce, 42); + assert!(decoded.signature.is_none()); + } + + #[test] + fn test_gossip_message_with_signature() { + let msg = GossipMessage { + sender_id: "did:key:z6MkTest".to_string(), + sender_nick: "bob".to_string(), + content: "signed msg".to_string(), + ts: 1700000000, + nonce: 1, + signature: Some("deadbeef".to_string()), + sender_session_id: None, + }; + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("\"signature\":\"deadbeef\"")); + + let decoded: GossipMessage = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.signature, Some("deadbeef".to_string())); + } + + #[test] + fn test_content_availability_topic_is_deterministic_and_does_not_embed_raw_cid() { + let cid = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + let topic = content_availability_topic_name(cid); + let topic_again = content_availability_topic_name(cid); + let uri = content_availability_topic_uri(cid); + + assert_eq!(topic, topic_again); + assert!(topic.starts_with("__elastos_content/v1/")); + assert!(uri.starts_with("elastos://carrier/content/")); + assert!(uri.ends_with("/availability")); + assert!(!topic.contains(cid)); + assert!(!uri.contains(cid)); + } + + #[test] + fn test_content_availability_cid_validation_is_fail_closed() { + assert!(validate_content_cid( + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi" + ) + .is_ok()); + assert!(validate_content_cid("QmRSEtAyq7Xgr5YCFVWuYsBdqbR5X9fJDsdpNQuvm9yaic").is_ok()); + assert!(validate_content_cid("short").is_err()); + assert!(validate_content_cid("cid/with/slashes").is_err()); + assert!(validate_content_cid("cid with spaces").is_err()); + } + + #[test] + fn test_content_availability_fetch_tickets_require_signed_matching_announcement() { + let cid = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + let (signed_message, _) = signed_content_availability_message( + cid, + [19u8; 32], + "ticket:test", + "endpoint:test", + 1_700_000_000, + ); + let unsigned_message = GossipMessage { + content: serde_json::json!({ + "payload": { + "schema": CONTENT_AVAILABILITY_ANNOUNCEMENT_SCHEMA, + "cid": cid, + "node_did": "did:key:z6Mkuntrusted", + "fetch": {"connect_ticket": "ticket:unsigned"} + }, + "signature": "00", + "signer_did": "did:key:z6Mkuntrusted" + }) + .to_string(), + ..signed_message.clone() + }; + + let tickets = content_availability_fetch_tickets(&[unsigned_message, signed_message], cid); + + assert_eq!(tickets, vec!["ticket:test".to_string()]); + } + + #[test] + fn test_content_availability_replicas_ignore_signed_repair_only_announcements() { + let cid = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + let (sk, did) = elastos_identity::derive_did(&[23u8; 32]); + let payload = serde_json::json!({ + "schema": CONTENT_AVAILABILITY_ANNOUNCEMENT_SCHEMA, + "cid": cid, + "uri": format!("elastos://{cid}"), + "policy": "network_default", + "provider": "carrier-availability", + "node_did": did, + "topic": content_availability_topic_uri(cid), + "local": { + "status": "local_unpinned", + "provider": "ipfs-provider", + "replicas": 0 + }, + "announced_at": 1_700_000_000u64 + }); + let canonical = serde_json::to_string(&payload).unwrap(); + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &sk, + CONTENT_AVAILABILITY_ANNOUNCEMENT_DOMAIN, + canonical.as_bytes(), + ); + let message = GossipMessage { + sender_id: signer_did.clone(), + sender_nick: "content-provider".to_string(), + content: serde_json::json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + }) + .to_string(), + ts: 1_700_000_000, + nonce: 1, + signature: None, + sender_session_id: None, + }; + + assert!( + content_availability_replicas(&[message], cid).is_empty(), + "repair-only announcements must not become fetch/replication candidates" + ); + } + + #[test] + fn test_content_availability_replicas_ignore_oversized_candidate_metadata() { + let cid = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + let (oversized_ticket, _) = signed_content_availability_message( + cid, + [24u8; 32], + &"x".repeat(MAX_CARRIER_AVAILABILITY_TICKET_LEN + 1), + "remote-endpoint", + 1_700_000_000, + ); + let (oversized_endpoint, _) = signed_content_availability_message( + cid, + [25u8; 32], + "ticket:test", + &"e".repeat(MAX_CARRIER_AVAILABILITY_ENDPOINT_ID_LEN + 1), + 1_700_000_000, + ); + + let replicas = content_availability_replicas(&[oversized_ticket, oversized_endpoint], cid); + + assert!( + replicas.is_empty(), + "oversized candidate metadata must not be used for Carrier provider invocation" + ); + } + + #[test] + fn test_content_availability_replicas_are_scored_and_sorted() { + let cid = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + let (mut stale, stale_did) = + signed_content_availability_message(cid, [26u8; 32], "ticket:stale", "", 10); + stale.ts = 1_700_000_000; + let (fresh, fresh_did) = signed_content_availability_message( + cid, + [27u8; 32], + "ticket:fresh", + "remote-endpoint", + 1_700_000_000, + ); + + let replicas = content_availability_replicas(&[stale, fresh], cid); + + assert_eq!(replicas.len(), 2); + assert_eq!(replicas[0].node_did, fresh_did); + assert_eq!(replicas[0].connect_ticket, "ticket:fresh"); + assert_eq!(replicas[0].score, 90); + assert_eq!( + replicas[0].selection_reason, + "signed_announcement+endpoint_advertised+fresh+local_reputation_neutral" + ); + assert_eq!(replicas[0].reputation_score, 0); + assert_eq!(replicas[0].reputation_reason, "no_local_history"); + assert_eq!(replicas[1].node_did, stale_did); + assert_eq!(replicas[1].score, 50); + assert_eq!( + replicas[1].selection_reason, + "signed_announcement+stale+local_reputation_neutral" + ); + } + + #[test] + fn test_content_availability_replicas_apply_local_runtime_reputation() { + let cid = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + let (preferred, preferred_did) = signed_content_availability_message( + cid, + [28u8; 32], + "ticket:preferred", + "remote-endpoint", + 1_700_000_000, + ); + let (penalized, penalized_did) = signed_content_availability_message( + cid, + [29u8; 32], + "ticket:penalized", + "remote-endpoint", + 1_700_000_000, + ); + let mut reputation = HashMap::new(); + reputation.insert( + preferred_did.clone(), + CarrierPeerReputation { + successes: 2, + failures: 0, + }, + ); + reputation.insert( + penalized_did.clone(), + CarrierPeerReputation { + successes: 0, + failures: 2, + }, + ); + + let replicas = content_availability_replicas_with_reputation( + &[penalized, preferred], + cid, + &reputation, + ); + + assert_eq!(replicas.len(), 2); + assert_eq!(replicas[0].node_did, preferred_did); + assert_eq!(replicas[0].score, 98); + assert_eq!(replicas[0].reputation_score, 8); + assert_eq!( + replicas[0].reputation_reason, + "local_runtime_successes:2;failures:0" + ); + assert_eq!(replicas[1].node_did, penalized_did); + assert_eq!(replicas[1].score, 74); + assert_eq!(replicas[1].reputation_score, -16); + } + + #[test] + fn test_carrier_peer_reputation_persists_local_history() { + let data_dir = tempfile::tempdir().unwrap(); + let mut reputation = HashMap::new(); + reputation.insert( + "did:key:zDurablePeer".to_string(), + CarrierPeerReputation { + successes: 3, + failures: 1, + }, + ); + + save_carrier_peer_reputation(data_dir.path(), &reputation).unwrap(); + let loaded = load_carrier_peer_reputation(data_dir.path()); + + let peer = loaded.get("did:key:zDurablePeer").unwrap(); + assert_eq!(peer.successes, 3); + assert_eq!(peer.failures, 1); + assert!(carrier_peer_reputation_path(data_dir.path()).is_file()); + } + + fn signed_content_availability_message( + cid: &str, + key_seed: [u8; 32], + connect_ticket: &str, + endpoint_id: &str, + announced_at: u64, + ) -> (GossipMessage, String) { + let (sk, did) = elastos_identity::derive_did(&key_seed); + let payload = serde_json::json!({ + "schema": CONTENT_AVAILABILITY_ANNOUNCEMENT_SCHEMA, + "cid": cid, + "uri": format!("elastos://{cid}"), + "policy": "network_default", + "provider": "carrier-availability", + "node_did": did, + "topic": content_availability_topic_uri(cid), + "fetch": { + "transport": "carrier-file", + "endpoint_id": endpoint_id, + "connect_ticket": connect_ticket + }, + "local": { + "status": "local_pinned", + "provider": "ipfs-provider", + "replicas": 1 + }, + "announced_at": announced_at + }); + let canonical = serde_json::to_string(&payload).unwrap(); + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &sk, + CONTENT_AVAILABILITY_ANNOUNCEMENT_DOMAIN, + canonical.as_bytes(), + ); + ( + GossipMessage { + sender_id: signer_did.clone(), + sender_nick: "content-provider".to_string(), + content: serde_json::json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + }) + .to_string(), + ts: announced_at, + nonce: 1, + signature: None, + sender_session_id: None, + }, + did, + ) + } + + fn signed_remote_content_receipt(cid: &str) -> serde_json::Value { + let (sk, _) = elastos_identity::derive_did(&[44u8; 32]); + let checked_at = 1_700_000_123u64; + let payload = serde_json::json!({ + "schema": "elastos.content.availability.receipt/v1", + "cid": cid, + "uri": format!("elastos://{cid}"), + "provider": "content-provider", + "policy": "carrier_exact_import", + "status": "local_pinned", + "replicas": 1, + "peer_selection": { + "mode": "single_local", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "carrier_provider_quota", + "status": "within_quota", + "enforced": true, + "used_replicas": 1, + "effective_max_replicas": 3 + }, + "repair_worker": { + "scheduled": false, + "status": "not_scheduled", + "worker": "content-provider" + }, + "repair_graph": { + "schema": CONTENT_REPAIR_GRAPH_SCHEMA, + "policy": "carrier_provider_bounded_graph_repair", + "requested_kind": "auto", + "status": "bounded_import_supported", + "refuses_exact_fallback_for_arbitrary_dag": true + }, + "storage_market": { + "schema": "elastos.content.storage-market/v1", + "mode": "carrier_provider_receipts", + "status": "receipt_proven_no_market_settlement", + "settlement": "not_configured", + "quota_enforced": true + }, + "accounting": { + "schema": "elastos.content.accounting/v1", + "observed": true, + "files": 1, + "content_bytes": 22, + "replica_bytes_estimate": 22, + "storage_quota": { + "status": "observed_not_enforced" } - let cursor_key = (topic_name.to_string(), consumer_id); - let cursor = cursors.entry(cursor_key).or_insert(buf.base_index); + }, + "abuse_controls": { + "schema": "elastos.content.abuse-controls/v1", + "policy": "carrier_provider_invocation_guardrail", + "enforced": true, + "candidate_count": 1, + "attempt_limit": 1, + "attempted_operations": 1, + "failed_operations": 0, + "throttled": false + }, + "checked_at": checked_at, + }); + let canonical = serde_json::to_string(&payload).unwrap(); + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &sk, + "elastos.content.availability.receipt.v1", + canonical.as_bytes(), + ); + serde_json::json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + }) + } - let start = if *cursor >= buf.base_index { - (*cursor - buf.base_index) as usize - } else { - 0 - }; + fn signed_remote_admission_receipt(payload: &serde_json::Value) -> serde_json::Value { + let (sk, _) = elastos_identity::derive_did(&[45u8; 32]); + let canonical = serde_json::to_string(payload).unwrap(); + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &sk, + CONTENT_ADMISSION_DOMAIN, + canonical.as_bytes(), + ); + serde_json::json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + }) + } - let all: Vec<&GossipMessage> = - buf.messages.iter().skip(start).take(limit).collect(); - let count = all.len(); - let messages: Vec<&GossipMessage> = if skip_sender_id.is_empty() { - all - } else { - all.into_iter() - .filter(|m| m.sender_id != skip_sender_id) - .collect() - }; + fn signed_peer_attestation_exchange_receipt(payload: serde_json::Value) -> serde_json::Value { + let (sk, _) = elastos_identity::derive_did(&[46u8; 32]); + let canonical = serde_json::to_string(&payload).unwrap(); + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &sk, + CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_DOMAIN, + canonical.as_bytes(), + ); + serde_json::json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + }) + } - *cursor = buf.base_index + start as u64 + count as u64; + fn carrier_peer_attestation_test_proof() -> CarrierReplicationProof { + CarrierReplicationProof { + node_did: "did:key:zRemote".to_string(), + endpoint_id: Some("remote-endpoint".to_string()), + announced_at: 1_700_000_000, + score: 90, + selection_reason: "signed_announcement+endpoint_advertised+fresh".to_string(), + reputation_score: 4, + reputation_reason: "local_runtime_successes:1;failures:0".to_string(), + ensure_status: "ok".to_string(), + admission: Some(serde_json::json!({ + "accepted": true, + "quota": {"status": "within_quota"} + })), + status_availability: serde_json::json!({ + "status": "local_pinned", + "replicas": 1, + }), + remote_receipt: Some(serde_json::json!({ + "schema": "elastos.content.availability.receipt/v1", + "cid": "bafyattest", + "status": "network_available", + "signer_did": "did:key:zRemoteContentProvider", + "verified": true, + })), + transfer: Some(serde_json::json!({ + "transport": "carrier-provider-plane", + "carrier": { + "route": "connect_ticket", + "connect_ticket": "ticket:internal-secret", + } + })), + checked_at: 1_700_000_001, + } + } - Ok(serde_json::json!({"status":"ok","data":{"messages": messages}})) + fn spawn_peer_attestation_exchange_endpoint( + response: serde_json::Value, + ) -> (String, std::thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body = response.to_string(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = std::io::Read::read(&mut stream, &mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if http_request_complete(&request) { + break; + } } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + std::io::Write::write_all(&mut stream, response.as_bytes()).unwrap(); + String::from_utf8_lossy(&request).into_owned() + }); + (format!("http://{addr}/peer-attestation/exchange"), handle) + } - "get_ticket" => { - // Use watch_addr() to include relay URLs (NAT traversal) - let mut watcher = state.endpoint.watch_addr(); - let addr = watcher.get(); - let ticket_json = serde_json::json!({ - "topic": null, - "endpoints": [addr], - }); - let ticket_bytes = serde_json::to_vec(&ticket_json).unwrap_or_default(); - let mut ticket_str = data_encoding::BASE32_NOPAD.encode(&ticket_bytes); - ticket_str.make_ascii_lowercase(); + fn http_request_complete(request: &[u8]) -> bool { + let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") else { + return false; + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.eq_ignore_ascii_case("content-length") { + value.trim().parse::().ok() + } else { + None + } + }) + .unwrap_or(0); + request.len() >= header_end + 4 + content_length + } - Ok(serde_json::json!({"status":"ok","data":{ - "ticket": ticket_str, - "node_id": state.endpoint.id().to_string(), - }})) - } + fn carrier_test_stream_payload(bytes: &[u8]) -> serde_json::Value { + serde_json::json!({ + "schema": "elastos.provider.stream/v1", + "encoding": "base64-chunks", + "total_bytes": bytes.len(), + "completed": true, + "chunks": [{ + "index": 0, + "offset": 0, + "length": bytes.len(), + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + }], + }) + } - "connect" => { - let memory_lookup = state.memory_lookup.clone(); - let endpoints = match parse_ticket_endpoints_or_error( - request["ticket"].as_str().unwrap_or_default(), - ) { - Ok(endpoints) => endpoints, - Err(err) => return Ok(err), - }; - let added = add_ticket_endpoints( - &memory_lookup, - &mut state.bootstrap_peers, - &endpoints, - true, - ); - let connected = connect_ticket_endpoints( - &state.endpoint, - &state.gossip, - state.peers.clone(), - &endpoints, - ) - .await; - Ok( - serde_json::json!({"status":"ok","data":{"added": added, "connected": connected}}), - ) - } + fn carrier_test_object_file_bytes() -> Vec { + b"# Carrier Object\n".to_vec() + } - "remember_peer" => { - let memory_lookup = state.memory_lookup.clone(); - let endpoints = match parse_ticket_endpoints_or_error( - request["ticket"].as_str().unwrap_or_default(), - ) { - Ok(endpoints) => endpoints, - Err(err) => return Ok(err), - }; - let added = add_ticket_endpoints( - &memory_lookup, - &mut state.bootstrap_peers, - &endpoints, - false, - ); - Ok(serde_json::json!({"status":"ok","data":{"added": added}})) - } + fn carrier_test_object_manifest_bytes() -> Vec { + let bytes = carrier_test_object_file_bytes(); + let file_sha = format!("{:x}", Sha256::digest(&bytes)); + let mut hasher = Sha256::new(); + hasher.update(b"index.md"); + hasher.update(b"\0"); + hasher.update(file_sha.as_bytes()); + hasher.update(b"\0"); + hasher.update(bytes.len().to_string().as_bytes()); + hasher.update(b"\0"); + let manifest = serde_json::json!({ + "schema": "elastos.content.object.manifest/v1", + "kind": "document", + "content_digest": format!("sha256:{:x}", hasher.finalize()), + "files": [{ + "path": "index.md", + "sha256": file_sha, + "size": bytes.len() + }], + "links": [], + "object_did": "did:key:zObject", + "publisher_did": "did:key:zPublisher" + }); + serde_json::to_vec(&manifest).unwrap() + } - "get_node_id" => { - let id = state - .did - .clone() - .unwrap_or_else(|| state.endpoint.id().to_string()); - Ok(serde_json::json!({"status":"ok","data":{"node_id": id}})) + #[tokio::test] + async fn test_carrier_provider_invoke_dispatches_runtime_enveloped_request() { + let registry = ProviderRegistry::new(); + registry + .register_sub_provider("content", Arc::new(MockCarrierContentProvider)) + .await + .unwrap(); + let request = serde_json::json!({ + "source": "carrier-availability", + "target": "content", + "operation": "fetch", + "transfer": "bytes", + "request": { + "op": "fetch", + "_runtime_invocation": { + "schema": "elastos.provider.invocation/v1", + "source": "carrier-availability", + "target": "content", + "op": "fetch", + "capability": "provider:carrier-availability->content:fetch", + "transport": "carrier-provider-plane", + "carrier": { + "route": "connect_ticket", + "peer_did": "did:key:zRemote", + "timeout_ms": 5000 + }, + "transfer": "bytes", + "range": null, + "progress": null + } } + }); - "list_peers" => { - let peers = state.peers.lock().await.clone(); - Ok(serde_json::json!({"status":"ok","data":{"peers": peers}})) - } + let response = carrier_provider_invoke_registry(®istry, &request) + .await + .unwrap(); - "list_topics" => { - let topics: Vec<&String> = state - .joined_topics - .iter() - .filter(|topic| !topic.starts_with("__elastos_internal/")) - .collect(); - Ok(serde_json::json!({"status":"ok","data":{"topics": topics}})) - } + assert_eq!(response["ok"], true); + assert_eq!(response["result"]["status"], "ok"); + assert_eq!(response["result"]["data"]["op"], "fetch"); + assert_eq!( + response["result"]["data"]["runtime_invocation"]["transport"], + "carrier-provider-plane" + ); + assert!(!response.to_string().contains("\"connect_ticket\":")); + } - "list_topic_peers" => { - let topic_name = request["topic"].as_str().unwrap_or_default(); - if topic_name.is_empty() { - return Ok( - serde_json::json!({"status":"error","code":"missing_topic","message":"topic required"}), - ); + #[tokio::test] + async fn test_carrier_provider_invoke_accepts_stream_contract_metadata() { + let registry = ProviderRegistry::new(); + registry + .register_sub_provider("content", Arc::new(MockCarrierContentProvider)) + .await + .unwrap(); + let request = serde_json::json!({ + "source": "carrier-availability", + "target": "content", + "operation": "fetch", + "transfer": "stream", + "request": { + "op": "fetch", + "_runtime_invocation": { + "schema": "elastos.provider.invocation/v1", + "source": "carrier-availability", + "target": "content", + "op": "fetch", + "capability": "provider:carrier-availability->content:fetch", + "transport": "carrier-provider-plane", + "carrier": { + "route": "connect_ticket", + "peer_did": "did:key:zRemote", + "timeout_ms": 5000 + }, + "transfer": "stream", + "stream": { + "schema": "elastos.provider.stream/v1", + "encoding": "base64-chunks", + "chunk_size": 65536 + }, + "range": null, + "progress": null } - let mut peers: Vec = state - .topic_peers - .lock() - .await - .get(topic_name) - .cloned() - .unwrap_or_default() - .into_iter() - .collect(); - peers.sort(); - Ok(serde_json::json!({"status":"ok","data":{"topic": topic_name, "peers": peers}})) } + }); - "gossip_join_peers" => { - let topic_name = request["topic"].as_str().unwrap_or_default(); - if topic_name.is_empty() { - return Ok( - serde_json::json!({"status":"error","code":"missing_topic","message":"topic required"}), - ); - } - let sender = match state.senders.get(topic_name) { - Some(s) => s, - None => { - return Ok( - serde_json::json!({"status":"error","code":"not_joined","message":"not joined"}), - ) - } - }; - let peer_ids: Vec = request - .get("peers") - .and_then(|v| v.as_array()) - .into_iter() - .flatten() - .filter_map(|v| v.as_str()) - .filter_map(|peer| peer.parse::().ok()) - .collect(); - if peer_ids.is_empty() { - return Ok( - serde_json::json!({"status":"error","code":"missing_peers","message":"peers required"}), - ); - } - match sender.join_peers(peer_ids, None).await { - Ok(_) => Ok(serde_json::json!({"status":"ok","data":{"topic": topic_name}})), - Err(err) => Ok( - serde_json::json!({"status":"error","code":"join_failed","message": err.to_string()}), - ), + let response = carrier_provider_invoke_registry(®istry, &request) + .await + .unwrap(); + + assert_eq!(response["ok"], true); + assert_eq!( + response["result"]["data"]["runtime_invocation"]["stream"]["schema"], + "elastos.provider.stream/v1" + ); + assert_eq!( + response["result"]["data"]["runtime_invocation"]["stream"]["encoding"], + "base64-chunks" + ); + assert!(!response.to_string().contains("\"connect_ticket\":")); + } + + #[tokio::test] + async fn test_carrier_provider_invoke_rejects_stream_without_contract_metadata() { + let registry = ProviderRegistry::new(); + registry + .register_sub_provider("content", Arc::new(MockCarrierContentProvider)) + .await + .unwrap(); + let request = serde_json::json!({ + "source": "carrier-availability", + "target": "content", + "operation": "fetch", + "transfer": "stream", + "request": { + "op": "fetch", + "_runtime_invocation": { + "schema": "elastos.provider.invocation/v1", + "source": "carrier-availability", + "target": "content", + "op": "fetch", + "capability": "provider:carrier-availability->content:fetch", + "transport": "carrier-provider-plane", + "carrier": { + "route": "connect_ticket" + }, + "transfer": "stream" } } + }); - _ => Ok( - serde_json::json!({"status":"error","code":"unknown_op","message": format!("unknown: {}", op)}), - ), - } + let response = carrier_provider_invoke_registry(®istry, &request) + .await + .unwrap(); + + assert_eq!(response["ok"], false); + assert_eq!(response["code"], "invalid_provider_invocation"); + assert!(response["error"] + .as_str() + .unwrap() + .contains("stream transfer requires stream metadata")); } -} -/// Background task: receive gossip messages and buffer them. -async fn handle_gossip_event( - event: iroh_gossip::api::Event, - buffers: &Arc>>, - peers: &Arc>>, - topic_peers: &Arc>>>, - topic: &str, -) { - match event { - iroh_gossip::api::Event::Received(msg) => { - if let Ok(gossip_msg) = serde_json::from_slice::(&msg.content) { - let mut bufs = buffers.lock().await; - if let Some(buf) = bufs.get_mut(topic) { - if buf.messages.len() >= MAX_BUFFER { - buf.messages.pop_front(); - buf.base_index += 1; - } - buf.messages.push_back(gossip_msg); + #[tokio::test] + async fn test_carrier_provider_invoke_rejects_raw_backend_target() { + let registry = ProviderRegistry::new(); + registry + .register_sub_provider("ipfs", Arc::new(MockCarrierIpfsProvider)) + .await + .unwrap(); + let request = serde_json::json!({ + "source": "content-provider", + "target": "ipfs", + "operation": "cat", + "transfer": "bytes", + "request": { + "op": "cat", + "_runtime_invocation": { + "schema": "elastos.provider.invocation/v1", + "source": "content-provider", + "target": "ipfs", + "op": "cat", + "capability": "provider:content-provider->ipfs:cat", + "transport": "carrier-provider-plane", + "carrier": { + "route": "connect_ticket" + }, + "transfer": "bytes" } } - } - iroh_gossip::api::Event::NeighborUp(peer) => { - let mut p = peers.lock().await; - let peer_str = peer.to_string(); - if !p.contains(&peer_str) { - p.push(peer_str.clone()); - } - drop(p); - topic_peers - .lock() - .await - .entry(topic.to_string()) - .or_default() - .insert(peer_str); - } - iroh_gossip::api::Event::NeighborDown(peer) => { - let mut p = peers.lock().await; - p.retain(|x| x != &peer.to_string()); - drop(p); - if let Some(topic_set) = topic_peers.lock().await.get_mut(topic) { - topic_set.remove(&peer.to_string()); - } - } - _ => {} + }); + + let response = carrier_provider_invoke_registry(®istry, &request) + .await + .unwrap(); + + assert_eq!(response["ok"], false); + assert_eq!(response["code"], "unauthorized_provider_target"); } -} -async fn recv_loop( - receiver: distributed_topic_tracker::GossipReceiver, - buffers: Arc>>, - peers: Arc>>, - topic_peers: Arc>>>, - topic: String, -) { - loop { - match receiver.next().await { - Some(Ok(event)) => { - handle_gossip_event(event, &buffers, &peers, &topic_peers, &topic).await; - } - Some(Err(e)) => { - tracing::warn!("carrier recv_loop error on '{}': {}", topic, e); - // Continue — transient errors should not kill the receiver - } - None => { - tracing::info!("carrier recv_loop ended for '{}' (stream closed)", topic); - break; - } - } + #[tokio::test] + async fn test_carrier_availability_fetch_uses_provider_invocation_transport() { + let registry = ProviderRegistry::new(); + let invoker = Arc::new(MockCarrierProviderPlaneInvoker::default()); + registry.set_carrier_invoker(invoker.clone()).await; + + let (bytes, remote_transfer) = fetch_content_via_carrier_provider_invocation( + ®istry, + "ticket:internal-secret", + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + "docs/readme.md", + ) + .await + .unwrap(); + + assert_eq!(bytes, b"carrier provider bytes"); + let remote_transfer = remote_transfer.expect("Carrier invocation must emit transfer"); + assert_eq!(remote_transfer["transport"], "carrier-provider-plane"); + assert_eq!(remote_transfer["source"], "carrier-availability"); + assert_eq!(remote_transfer["target"], "content"); + assert_eq!(remote_transfer["op"], "fetch"); + assert_eq!(remote_transfer["transfer"], "stream"); + assert_eq!( + remote_transfer["stream"]["schema"], + "elastos.provider.stream/v1" + ); + assert!(!remote_transfer + .to_string() + .contains("ticket:internal-secret")); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0]["ticket"], "ticket:internal-secret"); + assert_eq!(requests[0]["target"], "content"); + assert_eq!(requests[0]["request"]["local_only"], true); + assert_eq!(requests[0]["request"]["transfer"], "stream"); + assert_eq!(requests[0]["request"]["path"], "docs/readme.md"); + assert_eq!( + requests[0]["request"]["_runtime_invocation"]["transport"], + "carrier-provider-plane" + ); + assert_eq!( + requests[0]["request"]["_runtime_invocation"]["transfer"], + "stream" + ); } -} -// ── Client (for updates) ───────────────────────────────────────── + #[tokio::test] + async fn test_carrier_replication_proof_uses_remote_content_provider_invocation() { + let registry = ProviderRegistry::new(); + let invoker = Arc::new(MockCarrierProviderPlaneInvoker::default()); + registry.set_carrier_invoker(invoker.clone()).await; + let replica = CarrierAvailabilityReplica { + node_did: "did:key:zRemote".to_string(), + endpoint_id: Some("remote-endpoint".to_string()), + connect_ticket: "ticket:internal-secret".to_string(), + announced_at: 1_700_000_000, + score: 90, + selection_reason: "signed_announcement+endpoint_advertised+fresh".to_string(), + reputation_score: 0, + reputation_reason: "no_local_history".to_string(), + }; -pub struct CarrierClient { - conn: iroh::endpoint::Connection, - _endpoint: Endpoint, -} + let proof = ensure_content_via_carrier_provider_invocation( + ®istry, + &replica, + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + &serde_json::json!({ + "object_did": "did:key:zObject", + "publisher_did": "did:key:zPublisher", + "accounting": { + "schema": "elastos.content.accounting/v1", + "content_bytes": 22 + }, + "requirements": { + "max_storage_bytes_per_principal": 1024 + } + }), + ) + .await + .unwrap(); -impl CarrierClient { - async fn connect_endpoint_addr(addr: iroh::EndpointAddr, timeout_secs: u64) -> Result { - let mut rng_bytes = [0u8; 32]; - getrandom::getrandom(&mut rng_bytes).map_err(|e| anyhow::anyhow!("rng: {}", e))?; - let secret_key = SecretKey::from_bytes(&rng_bytes); - let endpoint = Endpoint::builder() - .secret_key(secret_key) - .bind() + assert_eq!(proof.node_did, "did:key:zRemote"); + assert_eq!(proof.endpoint_id.as_deref(), Some("remote-endpoint")); + assert_eq!(proof.ensure_status, "local_pinned"); + assert_eq!(proof.status_availability["status"], "local_pinned"); + assert_eq!(proof.announced_at, 1_700_000_000); + assert_eq!(proof.admission.as_ref().unwrap()["accepted"], true); + assert_eq!( + proof.admission.as_ref().unwrap()["estimated_content_bytes"], + 22 + ); + assert_eq!(proof.remote_receipt.as_ref().unwrap()["verified"], true); + assert_eq!( + proof.remote_receipt.as_ref().unwrap()["status"], + "local_pinned" + ); + assert_eq!( + proof.remote_receipt.as_ref().unwrap()["policy"], + "carrier_exact_import" + ); + assert_eq!( + proof.remote_receipt.as_ref().unwrap()["quota"]["status"], + "within_quota" + ); + assert_eq!( + proof.remote_receipt.as_ref().unwrap()["quota"]["enforced"], + true + ); + assert_eq!( + proof.remote_receipt.as_ref().unwrap()["repair_worker"]["worker"], + "content-provider" + ); + assert_eq!( + proof.remote_receipt.as_ref().unwrap()["accounting"]["content_bytes"], + 22 + ); + assert_eq!( + proof.remote_receipt.as_ref().unwrap()["accounting"]["storage_quota_status"], + "observed_not_enforced" + ); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 3); + assert_eq!(requests[0]["target"], "content"); + assert_eq!(requests[0]["op"], "admission"); + assert_eq!( + requests[0]["request"]["availability_requirements"]["max_storage_bytes_per_principal"], + 1024 + ); + assert_eq!(requests[0]["request"]["estimated_content_bytes"], 22); + assert_eq!(requests[1]["op"], "ensure"); + assert_eq!( + requests[1]["request"]["availability_policy"], + "carrier_replica" + ); + assert_eq!(requests[1]["request"]["object_did"], "did:key:zObject"); + assert_eq!(requests[2]["op"], "status"); + assert_eq!( + requests[0]["request"]["_runtime_invocation"]["transport"], + "carrier-provider-plane" + ); + } + + #[tokio::test] + async fn test_carrier_replication_falls_back_to_exact_import_when_remote_pin_fails() { + let registry = ProviderRegistry::new(); + let invoker = Arc::new(MockCarrierProviderPlaneInvoker { + requests: Mutex::new(Vec::new()), + fail_ensure: true, + reject_admission: false, + omit_admission_receipt: false, + }); + registry.set_carrier_invoker(invoker.clone()).await; + registry + .register_sub_provider("content", Arc::new(MockCarrierContentProvider)) .await - .context("Failed to bind")?; + .unwrap(); + let replica = CarrierAvailabilityReplica { + node_did: "did:key:zRemote".to_string(), + endpoint_id: Some("remote-endpoint".to_string()), + connect_ticket: "ticket:internal-secret".to_string(), + announced_at: 1_700_000_000, + score: 90, + selection_reason: "signed_announcement+endpoint_advertised+fresh".to_string(), + reputation_score: 0, + reputation_reason: "no_local_history".to_string(), + }; - let conn = tokio::time::timeout( - Duration::from_secs(timeout_secs), - endpoint.connect(addr, CARRIER_ALPN), + let proof = ensure_content_via_carrier_provider_invocation( + ®istry, + &replica, + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + &serde_json::json!({ + "object_did": "did:key:zObject", + "publisher_did": "did:key:zPublisher" + }), ) .await - .map_err(|_| anyhow::anyhow!("connect timed out"))? - .context("connect failed")?; + .unwrap(); - Ok(Self { - conn, - _endpoint: endpoint, - }) + assert_eq!(proof.ensure_status, "local_pinned"); + assert_eq!(proof.status_availability["status"], "local_pinned"); + assert_eq!(proof.remote_receipt.as_ref().unwrap()["verified"], true); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 4); + assert_eq!(requests[0]["op"], "admission"); + assert_eq!(requests[1]["op"], "ensure"); + assert_eq!(requests[2]["op"], "import_exact"); + assert_eq!(requests[2]["request"]["object_did"], "did:key:zObject"); + assert_eq!( + requests[2]["request"]["stream"]["schema"], + "elastos.provider.stream/v1" + ); + assert_eq!(requests[3]["op"], "status"); + assert!(!requests[2]["request"] + .to_string() + .contains("ticket:internal-secret")); } - pub async fn connect( - publisher_node_id: &str, - publisher_addrs: &[String], - timeout_secs: u64, - ) -> Result { - let public_key: iroh::PublicKey = publisher_node_id.parse().context("Invalid node ID")?; - let mut addr = iroh::EndpointAddr::from(public_key); - for addr_str in publisher_addrs { - if let Ok(sa) = addr_str.parse::() { - addr = addr.with_addrs([iroh::TransportAddr::Ip(sa)]); - break; - } - if let Some((host, port_str)) = addr_str.rsplit_once(':') { - if let Ok(port) = port_str.parse::() { - if let Ok(mut resolved) = - tokio::net::lookup_host(format!("{}:{}", host, port)).await - { - if let Some(sa) = resolved.next() { - addr = addr.with_addrs([iroh::TransportAddr::Ip(sa)]); - break; - } - } - } - } - } + #[tokio::test] + async fn test_carrier_replication_refuses_object_exact_fallback_without_block_graph_provider() { + let registry = ProviderRegistry::new(); + let invoker = Arc::new(MockCarrierProviderPlaneInvoker { + requests: Mutex::new(Vec::new()), + fail_ensure: true, + reject_admission: false, + omit_admission_receipt: false, + }); + registry.set_carrier_invoker(invoker.clone()).await; + registry + .register_sub_provider("content", Arc::new(MockCarrierContentProvider)) + .await + .unwrap(); + let replica = CarrierAvailabilityReplica { + node_did: "did:key:zRemote".to_string(), + endpoint_id: Some("remote-endpoint".to_string()), + connect_ticket: "ticket:internal-secret".to_string(), + announced_at: 1_700_000_000, + score: 90, + selection_reason: "signed_announcement+endpoint_advertised+fresh".to_string(), + reputation_score: 0, + reputation_reason: "no_local_history".to_string(), + }; - Self::connect_endpoint_addr(addr, timeout_secs).await + let err = ensure_content_via_carrier_provider_invocation( + ®istry, + &replica, + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + &serde_json::json!({ + "availability_requirements": { + "repair_graph_kind": "ipld_dag" + } + }), + ) + .await + .unwrap_err(); + + assert!(err + .to_string() + .contains("local block-graph export failed for arbitrary DAG repair")); + assert!(err.to_string().contains("refused object/exact fallback")); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 2); + assert_eq!(requests[0]["op"], "admission"); + assert_eq!(requests[1]["op"], "ensure"); + assert!(!requests + .iter() + .any(|request| request["op"] == "import_exact")); + assert!(!requests + .iter() + .any(|request| request["op"] == "import_object")); } - pub async fn connect_trusted_source(source: &TrustedSource, timeout_secs: u64) -> Result { - let ticket_endpoints = decode_ticket_endpoints(&source.connect_ticket); - let mut ticket_errors = Vec::new(); - for endpoint in ticket_endpoints { - match Self::connect_endpoint_addr(endpoint.clone(), timeout_secs).await { - Ok(client) => return Ok(client), - Err(err) => ticket_errors.push(err.to_string()), - } - } + #[tokio::test] + async fn test_carrier_replication_uses_block_graph_provider_for_arbitrary_dag() { + let registry = ProviderRegistry::new(); + let invoker = Arc::new(MockCarrierProviderPlaneInvoker { + requests: Mutex::new(Vec::new()), + fail_ensure: true, + reject_admission: false, + omit_admission_receipt: false, + }); + registry.set_carrier_invoker(invoker.clone()).await; + registry + .register_sub_provider("content", Arc::new(MockCarrierContentProvider)) + .await + .unwrap(); + registry + .register_sub_provider("block-graph", Arc::new(MockCarrierBlockGraphProvider)) + .await + .unwrap(); + let replica = CarrierAvailabilityReplica { + node_did: "did:key:zRemote".to_string(), + endpoint_id: Some("remote-endpoint".to_string()), + connect_ticket: "ticket:internal-secret".to_string(), + announced_at: 1_700_000_000, + score: 90, + selection_reason: "signed_announcement+endpoint_advertised+fresh".to_string(), + reputation_score: 0, + reputation_reason: "no_local_history".to_string(), + }; - let node_id = source_node_id(source) - .ok_or_else(|| anyhow::anyhow!("trusted source has no usable Carrier node id"))?; - let addrs = source_carrier_addrs(source); - match Self::connect(&node_id, &addrs, timeout_secs).await { - Ok(client) => Ok(client), - Err(err) if !ticket_errors.is_empty() => Err(anyhow::anyhow!( - "trusted source Carrier connection failed (ticket errors: {}; fallback error: {})", - ticket_errors.join(" | "), - err - )), - Err(err) => Err(err), - } + let proof = ensure_content_via_carrier_provider_invocation( + ®istry, + &replica, + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + &serde_json::json!({ + "object_did": "did:key:zObject", + "publisher_did": "did:key:zPublisher", + "availability_requirements": { + "repair_graph_kind": "ipld_dag" + } + }), + ) + .await + .unwrap(); + + assert_eq!(proof.ensure_status, "local_pinned"); + assert_eq!( + proof.remote_receipt.as_ref().unwrap()["verified"], + serde_json::Value::Bool(true) + ); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 5); + assert_eq!(requests[0]["op"], "admission"); + assert_eq!(requests[1]["op"], "ensure"); + assert_eq!(requests[2]["target"], CONTENT_BLOCK_GRAPH_TARGET); + assert_eq!(requests[2]["op"], "import_graph"); + assert_eq!( + requests[2]["request"]["graph"]["schema"], + CONTENT_BLOCK_GRAPH_SCHEMA + ); + assert_eq!(requests[2]["request"]["object_did"], "did:key:zObject"); + assert_eq!( + requests[2]["request"]["publisher_did"], + "did:key:zPublisher" + ); + assert_eq!(requests[3]["target"], "content"); + assert_eq!(requests[3]["op"], "ensure"); + assert_eq!( + requests[3]["request"]["availability_policy"], + "carrier_block_graph_import" + ); + assert_eq!(requests[4]["op"], "status"); + assert!(!requests + .iter() + .any(|request| request["op"] == "import_exact")); + assert!(!requests + .iter() + .any(|request| request["op"] == "import_object")); } - pub async fn release_head(&self) -> Result> { - let (mut send, recv) = self.conn.open_bi().await?; - let msg = serde_json::json!({"op":"release_head","path":""}); - let mut bytes = serde_json::to_vec(&msg)?; - bytes.push(b'\n'); - send.write_all(&bytes).await?; - send.finish()?; - let mut reader = BufReader::new(recv); - let mut line = String::new(); - reader.read_line(&mut line).await?; - let resp: serde_json::Value = serde_json::from_str(line.trim())?; - if resp["ok"].as_bool() == Some(true) { - Ok(Some(resp["release"].clone())) - } else { - Ok(None) - } + #[tokio::test] + async fn test_carrier_replication_prefers_object_import_when_manifest_exists() { + let registry = ProviderRegistry::new(); + let invoker = Arc::new(MockCarrierProviderPlaneInvoker { + requests: Mutex::new(Vec::new()), + fail_ensure: true, + reject_admission: false, + omit_admission_receipt: false, + }); + registry.set_carrier_invoker(invoker.clone()).await; + registry + .register_sub_provider("content", Arc::new(MockCarrierObjectContentProvider)) + .await + .unwrap(); + let replica = CarrierAvailabilityReplica { + node_did: "did:key:zRemote".to_string(), + endpoint_id: Some("remote-endpoint".to_string()), + connect_ticket: "ticket:internal-secret".to_string(), + announced_at: 1_700_000_000, + score: 90, + selection_reason: "signed_announcement+endpoint_advertised+fresh".to_string(), + reputation_score: 0, + reputation_reason: "no_local_history".to_string(), + }; + + let proof = ensure_content_via_carrier_provider_invocation( + ®istry, + &replica, + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + &serde_json::json!({ + "object_did": "did:key:zIgnoredSourceObject", + "publisher_did": "did:key:zIgnoredSourcePublisher" + }), + ) + .await + .unwrap(); + + assert_eq!(proof.ensure_status, "local_pinned"); + assert_eq!(proof.status_availability["status"], "local_pinned"); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 4); + assert_eq!(requests[0]["op"], "admission"); + assert_eq!(requests[1]["op"], "ensure"); + assert_eq!(requests[2]["op"], "import_object"); + assert_eq!(requests[2]["request"]["object_kind"], "document"); + assert_eq!(requests[2]["request"]["object_did"], "did:key:zObject"); + assert_eq!( + requests[2]["request"]["publisher_did"], + "did:key:zPublisher" + ); + assert_eq!(requests[2]["request"]["files"].as_array().unwrap().len(), 1); + assert_eq!( + requests[2]["request"]["files"][0]["path"], + serde_json::Value::String("index.md".to_string()) + ); + assert!(requests[2]["request"].get("stream").is_none()); + assert_eq!(requests[3]["op"], "status"); + assert!(!requests[2]["request"] + .to_string() + .contains("ticket:internal-secret")); } - pub async fn fetch_file(&self, path: &str) -> Result> { - let (mut send, mut recv) = self.conn.open_bi().await?; - let msg = serde_json::json!({"op":"file","path":path}); - let mut bytes = serde_json::to_vec(&msg)?; - bytes.push(b'\n'); - send.write_all(&bytes).await?; - send.finish()?; - let mut len_buf = [0u8; 8]; - recv.read_exact(&mut len_buf).await?; - let len = u64::from_be_bytes(len_buf) as usize; - if len > 200 * 1024 * 1024 { - let mut error_bytes = len_buf.to_vec(); - let tail = recv.read_to_end(16 * 1024).await?; - error_bytes.extend_from_slice(&tail); - if let Ok(text) = String::from_utf8(error_bytes) { - if let Ok(json) = serde_json::from_str::(text.trim()) { - if json["ok"].as_bool() == Some(false) { - let msg = json["error"] - .as_str() - .unwrap_or("trusted source returned an unknown file error"); - anyhow::bail!("trusted source file fetch failed for {}: {}", path, msg); - } + #[tokio::test] + async fn test_carrier_replication_stops_when_remote_admission_rejects() { + let registry = ProviderRegistry::new(); + let invoker = Arc::new(MockCarrierProviderPlaneInvoker { + requests: Mutex::new(Vec::new()), + fail_ensure: false, + reject_admission: true, + omit_admission_receipt: false, + }); + registry.set_carrier_invoker(invoker.clone()).await; + let replica = CarrierAvailabilityReplica { + node_did: "did:key:zRemote".to_string(), + endpoint_id: Some("remote-endpoint".to_string()), + connect_ticket: "ticket:internal-secret".to_string(), + announced_at: 1_700_000_000, + score: 90, + selection_reason: "signed_announcement+endpoint_advertised+fresh".to_string(), + reputation_score: 0, + reputation_reason: "no_local_history".to_string(), + }; + + let err = ensure_content_via_carrier_provider_invocation( + ®istry, + &replica, + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + &serde_json::json!({ + "publisher_did": "did:key:zPublisher", + "estimated_content_bytes": 22, + "requirements": { + "max_storage_bytes_per_principal": 1 } - } - anyhow::bail!( - "invalid trusted source file reply for {} (declared {} bytes)", - path, - len - ); - } - let mut content = vec![0u8; len]; - recv.read_exact(&mut content).await?; - Ok(content) + }), + ) + .await + .unwrap_err(); + + assert!(err + .to_string() + .contains("remote content admission rejected")); + assert!(err.to_string().contains("mock remote quota exceeded")); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0]["op"], "admission"); + assert_eq!(requests[0]["request"]["estimated_content_bytes"], 22); + assert!(!requests.iter().any(|request| request["op"] == "ensure")); + assert!(!requests + .iter() + .any(|request| request["op"] == "import_exact")); + assert!(!requests + .iter() + .any(|request| request["op"] == "import_object")); + assert!(!requests + .iter() + .any(|request| request["op"] == "import_graph")); } -} -async fn fetch_file_with_timeout( - client: &CarrierClient, - path: &str, - timeout_secs: u64, -) -> Result> { - tokio::time::timeout(Duration::from_secs(timeout_secs), client.fetch_file(path)) + #[tokio::test] + async fn test_carrier_replication_rejects_unsigned_remote_admission() { + let registry = ProviderRegistry::new(); + let invoker = Arc::new(MockCarrierProviderPlaneInvoker { + requests: Mutex::new(Vec::new()), + fail_ensure: false, + reject_admission: false, + omit_admission_receipt: true, + }); + registry.set_carrier_invoker(invoker.clone()).await; + let replica = CarrierAvailabilityReplica { + node_did: "did:key:zRemote".to_string(), + endpoint_id: Some("remote-endpoint".to_string()), + connect_ticket: "ticket:internal-secret".to_string(), + announced_at: 1_700_000_000, + score: 90, + selection_reason: "signed_announcement+endpoint_advertised+fresh".to_string(), + reputation_score: 0, + reputation_reason: "no_local_history".to_string(), + }; + + let err = ensure_content_via_carrier_provider_invocation( + ®istry, + &replica, + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + &serde_json::json!({ + "publisher_did": "did:key:zPublisher", + "estimated_content_bytes": 22, + "requirements": { + "max_storage_bytes_per_principal": 1024 + } + }), + ) .await - .map_err(|_| anyhow::anyhow!("file fetch timed out after {}s", timeout_secs))? -} + .unwrap_err(); + + assert!(err + .to_string() + .contains("remote content admission missing signed receipt")); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0]["op"], "admission"); + assert!(!requests.iter().any(|request| request["op"] == "ensure")); + assert!(!requests + .iter() + .any(|request| request["op"] == "import_exact")); + assert!(!requests + .iter() + .any(|request| request["op"] == "import_object")); + assert!(!requests + .iter() + .any(|request| request["op"] == "import_graph")); + } -pub async fn fetch_file_from_trusted_source( - source: &TrustedSource, - path: &str, - connect_timeout_secs: u64, - fetch_timeout_secs: u64, -) -> Result> { - let mut errors = Vec::new(); - let ticket_endpoints = decode_ticket_endpoints(&source.connect_ticket); - for (index, endpoint) in ticket_endpoints.into_iter().enumerate() { - match CarrierClient::connect_endpoint_addr(endpoint, connect_timeout_secs).await { - Ok(client) => match fetch_file_with_timeout(&client, path, fetch_timeout_secs).await { - Ok(bytes) => return Ok(bytes), - Err(err) => errors.push(format!("ticket[{index}] fetch failed: {err}")), + #[test] + fn test_carrier_peer_selection_proof_redacts_connect_tickets() { + let proof = CarrierReplicationProof { + node_did: "did:key:zRemote".to_string(), + endpoint_id: Some("remote-endpoint".to_string()), + announced_at: 1_700_000_000, + score: 90, + selection_reason: "signed_announcement+endpoint_advertised+fresh".to_string(), + reputation_score: 4, + reputation_reason: "local_runtime_successes:1;failures:0".to_string(), + ensure_status: "local_pinned".to_string(), + admission: Some(serde_json::json!({ + "schema": "elastos.content.admission/v1", + "accepted": true, + "status": "accepted", + "quota": { + "status": "within_quota", + "enforced": true + } + })), + status_availability: serde_json::json!({"status": "local_pinned"}), + remote_receipt: Some(serde_json::json!({ + "schema": "elastos.content.availability.receipt/v1", + "cid": "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + "status": "local_pinned", + "replicas": 1, + "quota": { + "status": "within_quota", + "enforced": true + }, + "accounting": { + "observed": true, + "content_bytes": 22, + "storage_quota_status": "observed_not_enforced" + }, + "signer_did": "did:key:zRemoteContentProvider", + "verified": true + })), + transfer: Some(serde_json::json!({ + "transport": "carrier-provider-plane", + "carrier": { + "route": "connect_ticket", + "peer_did": "did:key:zRemote" + } + })), + checked_at: 1_700_000_001, + }; + + let peer_selection = carrier_peer_selection_json( + "elastos://carrier/content/test/availability", + "did:key:zLocal", + 1, + &[proof], + true, + CarrierPeerAttestationExchangeView { + configured: false, + receipt: None, }, - Err(err) => errors.push(format!("ticket[{index}] connect failed: {err}")), - } + ); + + assert_eq!(peer_selection["mode"], "carrier_provider_replication"); + assert_eq!(peer_selection["live_multi_peer_proof"], true); + assert_eq!( + peer_selection["peer_reputation_policy"]["schema"], + CARRIER_PEER_REPUTATION_SCHEMA + ); + assert_eq!( + peer_selection["peer_reputation_policy"]["status"], + "local_history_applied" + ); + assert_eq!( + peer_selection["peer_reputation_policy"]["federation"]["configured"], + false + ); + assert_eq!( + peer_selection["peer_attestation_exchange_policy"]["schema"], + CARRIER_PEER_ATTESTATION_EXCHANGE_POLICY_SCHEMA + ); + assert_eq!( + peer_selection["peer_attestation_exchange_policy"]["status"], + "live_peer_proof_without_attestation_exchange" + ); + assert_eq!( + peer_selection["peer_attestation_exchange_policy"]["local_proof"] + ["verified_remote_content_receipts"], + 1 + ); + assert_eq!( + peer_selection["peer_attestation_exchange_policy"]["attestation_exchange"] + ["configured"], + false + ); + assert_eq!(peer_selection["replicas"].as_array().unwrap().len(), 2); + assert_eq!(peer_selection["replicas"][1]["score"], 90); + assert_eq!( + peer_selection["replicas"][1]["selection_reason"], + "signed_announcement+endpoint_advertised+fresh" + ); + assert_eq!( + peer_selection["replicas"][1]["local_reputation"]["scope"], + "local_runtime" + ); + assert_eq!( + peer_selection["replicas"][1]["local_reputation"]["score_delta"], + 4 + ); + assert_eq!( + peer_selection["replicas"][1]["local_reputation"]["reason"], + "local_runtime_successes:1;failures:0" + ); + assert_eq!( + peer_selection["replicas"][1]["remote_receipt"]["quota"]["status"], + "within_quota" + ); + assert_eq!( + peer_selection["replicas"][1]["remote_receipt"]["accounting"]["content_bytes"], + 22 + ); + assert_eq!( + peer_selection["replicas"][1]["remote_receipt"]["accounting"]["storage_quota_status"], + "observed_not_enforced" + ); + assert_eq!(peer_selection["replicas"][1]["admission"]["accepted"], true); + assert_eq!( + peer_selection["replicas"][1]["admission"]["quota"]["status"], + "within_quota" + ); + assert!(!peer_selection + .to_string() + .contains("ticket:internal-secret")); + assert!(!peer_selection + .to_string() + .contains("connect_ticket\": \"ticket")); } - let relay_endpoints = relay_only_ticket_endpoints(source); - for (index, endpoint) in relay_endpoints.into_iter().enumerate() { - match CarrierClient::connect_endpoint_addr(endpoint, connect_timeout_secs).await { - Ok(client) => match fetch_file_with_timeout(&client, path, fetch_timeout_secs).await { - Ok(bytes) => return Ok(bytes), - Err(err) => errors.push(format!("relay[{index}] fetch failed: {err}")), + #[tokio::test] + async fn test_carrier_peer_attestation_exchange_posts_signed_request_and_verifies_receipt() { + let signed_receipt = signed_peer_attestation_exchange_receipt(serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA, + "exchange_id": "peer-attestation:test", + "receipt_id": "peer-attestation-receipt:123", + "accepted": true, + })); + let (url, handle) = spawn_peer_attestation_exchange_endpoint(serde_json::json!({ + "accepted": true, + "status": "accepted", + "exchange_id": "peer-attestation:test", + "receipt_id": "peer-attestation-receipt:123", + "receipt": signed_receipt, + })); + let client = CarrierPeerAttestationExchangeClient::from_config(serde_json::json!({ + "url": url, + "authorization": "Bearer peer-attestation-test", + "timeout_secs": 5, + })) + .unwrap(); + let (signing_key, _) = elastos_identity::derive_did(&[47u8; 32]); + let proof = carrier_peer_attestation_test_proof(); + let request = carrier_peer_attestation_exchange_request( + &signing_key, + "bafyattest", + "elastos://carrier/content/test/availability", + "did:key:zLocal", + std::slice::from_ref(&proof), + true, + 1_700_000_002, + ) + .unwrap(); + + let receipt = client.exchange(&request).await.unwrap(); + + assert_eq!( + receipt["schema"], + CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA + ); + assert_eq!(receipt["status"], "accepted"); + assert_eq!(receipt["accepted"], true); + assert_eq!(receipt["signed_receipt"]["verified"], true); + assert_eq!( + receipt["signed_receipt"]["payload_schema"], + CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA + ); + assert_eq!(receipt["exchange"]["credential_exposed"], false); + let peer_selection = carrier_peer_selection_json( + "elastos://carrier/content/test/availability", + "did:key:zLocal", + 1, + &[proof], + true, + CarrierPeerAttestationExchangeView { + configured: true, + receipt: Some(&receipt), }, - Err(err) => errors.push(format!("relay[{index}] connect failed: {err}")), - } + ); + assert_eq!( + peer_selection["peer_attestation_exchange_policy"]["status"], + "attestation_exchange_accepted" + ); + assert_eq!( + peer_selection["peer_attestation_exchange_policy"]["attestation_exchange"] + ["configured"], + true + ); + assert_eq!( + peer_selection["peer_attestation_exchange_policy"]["attestation_exchange"] + ["signed_reputation_receipts"], + true + ); + + let request_text = handle.join().unwrap(); + assert!(request_text.starts_with("POST /peer-attestation/exchange HTTP/1.1")); + assert!(request_text + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer peer-attestation-test"))); + assert!(request_text.contains(CARRIER_PEER_ATTESTATION_EXCHANGE_REQUEST_SCHEMA)); + assert!(request_text.contains("\"signature\"")); + assert!(request_text.contains("\"signer_did\"")); + assert!(!request_text + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("peer-attestation-test")); + assert!(!request_text.contains("ticket:internal-secret")); } - let node_id = source_node_id(source) - .ok_or_else(|| anyhow::anyhow!("trusted source has no usable Carrier node id"))?; - let addrs = source_carrier_addrs(source); - match CarrierClient::connect(&node_id, &addrs, connect_timeout_secs).await { - Ok(client) => match fetch_file_with_timeout(&client, path, fetch_timeout_secs).await { - Ok(bytes) => Ok(bytes), - Err(err) => { - errors.push(format!("direct fetch failed: {err}")); - Err(anyhow::anyhow!( - "trusted source Carrier fetch failed: {}", - errors.join(" | ") - )) - } - }, - Err(err) => { - errors.push(format!("direct connect failed: {err}")); - Err(anyhow::anyhow!( - "trusted source Carrier fetch failed: {}", - errors.join(" | ") - )) - } + #[tokio::test] + async fn test_carrier_peer_attestation_exchange_accepts_endpoint_quorum() { + let signed_receipt_a = signed_peer_attestation_exchange_receipt(serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA, + "exchange_id": "peer-attestation:a", + "receipt_id": "peer-attestation-receipt:a", + "accepted": true, + })); + let (url_a, handle_a) = spawn_peer_attestation_exchange_endpoint(serde_json::json!({ + "accepted": true, + "status": "accepted", + "exchange_id": "peer-attestation:a", + "receipt_id": "peer-attestation-receipt:a", + "receipt": signed_receipt_a, + })); + let signed_receipt_b = signed_peer_attestation_exchange_receipt(serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA, + "exchange_id": "peer-attestation:b", + "receipt_id": "peer-attestation-receipt:b", + "accepted": true, + })); + let (url_b, handle_b) = spawn_peer_attestation_exchange_endpoint(serde_json::json!({ + "accepted": true, + "status": "accepted", + "exchange_id": "peer-attestation:b", + "receipt_id": "peer-attestation-receipt:b", + "receipt": signed_receipt_b, + })); + let client = CarrierPeerAttestationExchangeClient::from_config(serde_json::json!({ + "quorum": 2, + "endpoints": [ + { + "id": "peer-attestation-a", + "url": url_a, + "authorization": "Bearer peer-attestation-secret-a", + "timeout_secs": 5 + }, + { + "id": "peer-attestation-b", + "url": url_b, + "authorization": "Bearer peer-attestation-secret-b", + "timeout_secs": 5 + } + ] + })) + .unwrap(); + let (signing_key, _) = elastos_identity::derive_did(&[47u8; 32]); + let proof = carrier_peer_attestation_test_proof(); + let request = carrier_peer_attestation_exchange_request( + &signing_key, + "bafyattest", + "elastos://carrier/content/test/availability", + "did:key:zLocal", + std::slice::from_ref(&proof), + true, + 1_700_000_002, + ) + .unwrap(); + + let receipt = client.exchange(&request).await.unwrap(); + + assert_eq!(receipt["status"], "accepted"); + assert_eq!(receipt["accepted"], true); + assert_eq!(receipt["quorum"]["required"], 2); + assert_eq!(receipt["quorum"]["endpoint_count"], 2); + assert_eq!(receipt["quorum"]["accepted"], 2); + assert_eq!(receipt["signed_receipt"]["verified"], true); + assert_eq!(receipt["exchange"]["multi_endpoint"], true); + assert_eq!(receipt["exchange"]["endpoint_count"], 2); + assert!(!receipt.to_string().contains("peer-attestation-secret-a")); + assert!(!receipt.to_string().contains("peer-attestation-secret-b")); + + let request_a = handle_a.join().unwrap(); + let request_b = handle_b.join().unwrap(); + assert!(request_a.lines().any( + |line| line.eq_ignore_ascii_case("authorization: Bearer peer-attestation-secret-a") + )); + assert!(request_b.lines().any( + |line| line.eq_ignore_ascii_case("authorization: Bearer peer-attestation-secret-b") + )); + assert!(!request_a + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("peer-attestation-secret-a")); + assert!(!request_b + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("peer-attestation-secret-b")); + assert!(!request_a.contains("ticket:internal-secret")); + assert!(!request_b.contains("ticket:internal-secret")); } -} -pub async fn try_p2p_discovery( - publisher_node_id: &str, - publisher_addrs: &[String], - timeout_secs: u64, -) -> Option { - let client = CarrierClient::connect(publisher_node_id, publisher_addrs, timeout_secs) - .await - .ok()?; - let release = client.release_head().await.ok()??; - release["head_cid"].as_str().map(|s| s.to_string()) -} + #[tokio::test] + async fn test_carrier_peer_attestation_exchange_rejects_endpoint_quorum_failure() { + let signed_receipt = signed_peer_attestation_exchange_receipt(serde_json::json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_RECEIPT_SCHEMA, + "exchange_id": "peer-attestation:a", + "receipt_id": "peer-attestation-receipt:a", + "accepted": true, + })); + let (accepted_url, accepted_handle) = + spawn_peer_attestation_exchange_endpoint(serde_json::json!({ + "accepted": true, + "status": "accepted", + "receipt": signed_receipt, + })); + let (rejected_url, rejected_handle) = + spawn_peer_attestation_exchange_endpoint(serde_json::json!({ + "accepted": false, + "status": "rejected", + "reason": "reputation trust policy rejected peer", + })); + let client = CarrierPeerAttestationExchangeClient::from_config(serde_json::json!({ + "quorum": 2, + "endpoints": [ + {"id": "peer-attestation-a", "url": accepted_url, "timeout_secs": 5}, + {"id": "peer-attestation-b", "url": rejected_url, "timeout_secs": 5} + ] + })) + .unwrap(); + let (signing_key, _) = elastos_identity::derive_did(&[47u8; 32]); + let proof = carrier_peer_attestation_test_proof(); + let request = carrier_peer_attestation_exchange_request( + &signing_key, + "bafyattest", + "elastos://carrier/content/test/availability", + "did:key:zLocal", + std::slice::from_ref(&proof), + true, + 1_700_000_002, + ) + .unwrap(); -#[cfg(test)] -mod tests { - use super::*; + let receipt = client.exchange(&request).await.unwrap(); + + assert_eq!(receipt["status"], "rejected"); + assert_eq!(receipt["accepted"], false); + assert_eq!(receipt["quorum"]["required"], 2); + assert_eq!(receipt["quorum"]["accepted"], 1); + assert_eq!(receipt["quorum"]["rejected"], 1); + assert_eq!(receipt["signed_receipt"]["verified"], true); + assert!(receipt["reason"] + .as_str() + .unwrap() + .contains("reputation trust policy rejected peer")); + + let accepted_request = accepted_handle.join().unwrap(); + let rejected_request = rejected_handle.join().unwrap(); + assert!(accepted_request.contains(CARRIER_PEER_ATTESTATION_EXCHANGE_REQUEST_SCHEMA)); + assert!(rejected_request.contains(CARRIER_PEER_ATTESTATION_EXCHANGE_REQUEST_SCHEMA)); + } #[test] - fn test_topic_hash_deterministic() { - let h1 = topic_hash("#general"); - let h2 = topic_hash("#general"); - assert_eq!(h1, h2, "same topic name must produce same hash"); + fn test_remote_content_receipt_peer_selection_summary_redacts_replica_rows() { + let summary = remote_content_receipt_peer_selection_summary(Some(&serde_json::json!({ + "mode": "carrier_provider_replication", + "strategy": "signed_announcement_then_provider_invoke", + "live_multi_peer_proof": true, + "peer_reputation_policy": { + "schema": CARRIER_PEER_REPUTATION_SCHEMA, + "policy": "local_runtime_reputation", + "status": "local_history_applied", + "federation": { + "configured": false, + "cross_runtime_reputation": false + } + }, + "peer_attestation_exchange_policy": { + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_POLICY_SCHEMA, + "policy": "no_cross_runtime_attestation_exchange", + "status": "live_peer_proof_without_attestation_exchange", + "attestation_exchange": { + "configured": false, + "signed_reputation_receipts": false + } + }, + "replicas": [ + { + "role": "local", + "node_did": "did:key:zLocal", + "status": "local_pinned" + }, + { + "role": "remote", + "node_did": "did:key:zRemote", + "endpoint_id": "remote-endpoint", + "score": 94, + "selection_reason": "signed_announcement+endpoint_advertised+fresh+local_reputation_positive", + "local_reputation": { + "scope": "local_runtime", + "score_delta": 4, + "reason": "local_runtime_successes:1;failures:0" + }, + "status": "local_pinned", + "transfer": { + "transport": "carrier-provider-plane", + "carrier": { + "route": "connect_ticket", + "connect_ticket": "ticket:internal-secret" + } + }, + "remote_receipt": { + "signer_did": "did:key:zRemoteContentProvider" + } + } + ] + }))); - let h3 = topic_hash("#other"); - assert_ne!(h1, h3, "different topics must produce different hashes"); + assert_eq!(summary["mode"], "carrier_provider_replication"); + assert_eq!( + summary["peer_reputation_policy"]["status"], + "local_history_applied" + ); + assert_eq!( + summary["peer_reputation_policy"]["federation"]["configured"], + false + ); + assert_eq!( + summary["peer_attestation_exchange_policy"]["schema"], + CARRIER_PEER_ATTESTATION_EXCHANGE_POLICY_SCHEMA + ); + assert_eq!( + summary["peer_attestation_exchange_policy"]["attestation_exchange"]["configured"], + false + ); + assert_eq!(summary["replica_count"], 2); + assert_eq!(summary["remote_replicas"], 1); + assert_eq!(summary["replica_summary_limit"], 5); + assert_eq!(summary["replicas_truncated"], false); + assert_eq!(summary["replicas"].as_array().unwrap().len(), 2); + assert_eq!(summary["replicas"][1]["node_did"], "did:key:zRemote"); + assert_eq!(summary["replicas"][1]["score"], 94); + assert_eq!( + summary["replicas"][1]["local_reputation"]["scope"], + "local_runtime" + ); + assert!(!summary.to_string().contains("ticket:internal-secret")); + assert!(!summary.to_string().contains("connect_ticket")); + assert!(!summary.to_string().contains("remote_receipt")); } #[test] - fn test_gossip_message_serialization() { - let msg = GossipMessage { - sender_id: "did:key:z6MkTest".to_string(), - sender_nick: "alice".to_string(), - content: "hello world".to_string(), - ts: 1700000000, - nonce: 42, - signature: None, - sender_session_id: None, + fn test_remote_content_receipt_peer_selection_summary_marks_truncated_rows() { + let replicas = (0..6) + .map(|index| { + serde_json::json!({ + "role": "remote", + "node_did": format!("did:key:zRemote{index}"), + "score": 80 + index, + "transfer": { + "carrier": { + "route": "connect_ticket", + "connect_ticket": format!("ticket:secret-{index}") + } + } + }) + }) + .collect::>(); + let summary = remote_content_receipt_peer_selection_summary(Some(&serde_json::json!({ + "mode": "carrier_provider_replication", + "live_multi_peer_proof": true, + "replicas": replicas + }))); + + assert_eq!(summary["replica_count"], 6); + assert_eq!(summary["remote_replicas"], 6); + assert_eq!(summary["replica_summary_limit"], 5); + assert_eq!(summary["replicas_truncated"], true); + assert_eq!(summary["replicas"].as_array().unwrap().len(), 5); + assert!(!summary.to_string().contains("ticket:secret")); + assert!(!summary.to_string().contains("connect_ticket")); + } + + #[test] + fn test_carrier_quota_marks_impossible_replica_requirements() { + let requirements = CarrierAvailabilityRequirements { + min_replicas: 4, + max_replicas: Some(2), + require_live_multi_peer_proof: true, + repair_graph_kind: CarrierRepairGraphKind::Auto, }; - let bytes = serde_json::to_vec(&msg).unwrap(); - let decoded: GossipMessage = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(decoded.sender_id, "did:key:z6MkTest"); - assert_eq!(decoded.sender_nick, "alice"); - assert_eq!(decoded.content, "hello world"); - assert_eq!(decoded.ts, 1700000000); - assert_eq!(decoded.nonce, 42); - assert!(decoded.signature.is_none()); + + let quota = carrier_quota_json(requirements, 2, 2); + + assert_eq!(quota["policy"], "carrier_provider_quota"); + assert_eq!(quota["enforced"], true); + assert_eq!(quota["status"], "requirements_exceed_quota"); + assert_eq!(quota["effective_max_replicas"], 2); + assert_eq!(quota["requirements_exceed_quota"], true); } #[test] - fn test_gossip_message_with_signature() { - let msg = GossipMessage { - sender_id: "did:key:z6MkTest".to_string(), - sender_nick: "bob".to_string(), - content: "signed msg".to_string(), - ts: 1700000000, - nonce: 1, - signature: Some("deadbeef".to_string()), - sender_session_id: None, + fn test_carrier_remote_candidate_limit_keeps_live_multi_peer_requirement() { + let requirements = CarrierAvailabilityRequirements { + min_replicas: 2, + max_replicas: None, + require_live_multi_peer_proof: true, + repair_graph_kind: CarrierRepairGraphKind::Auto, }; - let json = serde_json::to_string(&msg).unwrap(); - assert!(json.contains("\"signature\":\"deadbeef\"")); - let decoded: GossipMessage = serde_json::from_str(&json).unwrap(); - assert_eq!(decoded.signature, Some("deadbeef".to_string())); + assert_eq!(carrier_remote_candidate_limit(requirements, 2, 2), 1); + + let quota_blocked = CarrierAvailabilityRequirements { + min_replicas: 2, + max_replicas: Some(2), + require_live_multi_peer_proof: true, + repair_graph_kind: CarrierRepairGraphKind::Auto, + }; + assert_eq!(carrier_remote_candidate_limit(quota_blocked, 2, 2), 0); + } + + #[tokio::test] + async fn test_carrier_availability_ensure_proves_remote_replica_via_provider_plane() { + let cid = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + let topic_name = content_availability_topic_name(cid); + let (remote_message, remote_did) = signed_content_availability_message( + cid, + [22u8; 32], + "ticket:remote-secret", + "remote-endpoint", + 1_700_000_000, + ); + let (local_sk, local_did) = elastos_identity::derive_did(&[21u8; 32]); + let endpoint = Endpoint::builder() + .secret_key(iroh::SecretKey::from_bytes(&local_sk.to_bytes())) + .bind() + .await + .unwrap(); + let memory_lookup = MemoryLookup::new(); + endpoint.address_lookup().add(memory_lookup.clone()); + let gossip = Gossip::builder().spawn(endpoint.clone()); + let state = Arc::new(Mutex::new(GossipState::new( + endpoint.clone(), + gossip, + memory_lookup, + Some(local_sk), + Some(local_did), + ))); + { + let mut guard = state.lock().await; + guard.joined_topics.insert(topic_name.clone()); + guard.buffers.lock().await.insert( + topic_name, + TopicBuffer { + messages: VecDeque::from([remote_message]), + base_index: 0, + }, + ); + } + + let registry = Arc::new(ProviderRegistry::new()); + let invoker = Arc::new(MockCarrierProviderPlaneInvoker::default()); + registry.set_carrier_invoker(invoker.clone()).await; + let provider = + CarrierAvailabilityProvider::with_provider_registry(state, Arc::downgrade(®istry)); + let response = provider + .send_raw(&serde_json::json!({ + "op": "ensure", + "cid": cid, + "uri": format!("elastos://{cid}"), + "policy": "network_default", + "local": { + "status": "local_pinned", + "provider": "ipfs-provider", + "replicas": 1 + }, + "requirements": { + "min_replicas": 2, + "max_replicas": 2, + "require_live_multi_peer_proof": true + }, + "object_did": "did:key:zObject", + "publisher_did": "did:key:zPublisher" + })) + .await + .unwrap(); + + let availability = &response["data"]["availability"]; + assert_eq!(response["status"], "ok"); + assert_eq!(availability["status"], "network_available"); + assert_eq!(availability["replicas"], 2); + assert_eq!( + availability["peer_selection"]["mode"], + "carrier_provider_replication" + ); + assert_eq!( + availability["peer_selection"]["live_multi_peer_proof"], + true + ); + assert_eq!(availability["quota"]["policy"], "carrier_provider_quota"); + assert_eq!(availability["quota"]["enforced"], true); + assert_eq!(availability["quota"]["status"], "at_quota"); + assert_eq!(availability["quota"]["effective_max_replicas"], 2); + assert_eq!(availability["quota"]["requirements_exceed_quota"], false); + assert_eq!(availability["quota"]["used_replicas"], 2); + assert_eq!( + availability["abuse_controls"]["policy"], + "carrier_provider_invocation_guardrail" + ); + assert_eq!(availability["abuse_controls"]["enforced"], true); + assert_eq!(availability["abuse_controls"]["candidate_count"], 1); + assert_eq!(availability["abuse_controls"]["attempt_limit"], 1); + assert_eq!(availability["abuse_controls"]["attempted_operations"], 1); + assert_eq!(availability["abuse_controls"]["failed_operations"], 0); + assert_eq!(availability["abuse_controls"]["throttled"], false); + assert_eq!( + availability["peer_selection"]["replicas"][1]["remote_receipt"]["abuse_controls"] + ["policy"], + "carrier_provider_invocation_guardrail" + ); + assert_eq!( + availability["peer_selection"]["replicas"][1]["remote_receipt"]["abuse_controls"] + ["attempted_operations"], + 1 + ); + assert_eq!(availability["repair_worker"]["status"], "healthy"); + assert_eq!( + availability["repair_graph"]["schema"], + CONTENT_REPAIR_GRAPH_SCHEMA + ); + assert_eq!( + availability["repair_graph"]["status"], + "bounded_import_supported" + ); + assert_eq!( + availability["repair_graph"]["refuses_exact_fallback_for_arbitrary_dag"], + true + ); + assert_eq!( + availability["peer_selection"]["replicas"][1]["remote_receipt"]["repair_graph"] + ["status"], + "bounded_import_supported" + ); + assert_eq!( + availability["storage_market"]["mode"], + "carrier_provider_receipts" + ); + assert_eq!( + availability["quota"]["federated_quota_ledger_policy"]["schema"], + CONTENT_FEDERATED_QUOTA_LEDGER_POLICY_SCHEMA + ); + assert_eq!( + availability["quota"]["federated_quota_ledger_policy"]["remote"]["admission_preflight"], + true + ); + assert_eq!( + availability["quota"]["federated_quota_ledger_policy"]["remote"] + ["signed_admission_receipts"], + true + ); + assert_eq!( + availability["quota"]["federated_quota_ledger_policy"]["federation"]["configured"], + false + ); + assert_eq!( + availability["quota"]["federated_quota_ledger_policy"]["federation"] + ["signed_admission_receipt_exchange"], + true + ); + assert_eq!( + availability["peer_selection"]["replicas"][1]["admission"]["receipt"]["verified"], + true + ); + assert_eq!( + availability["storage_market"]["settlement"], + "not_configured" + ); + assert_eq!(availability["storage_market"]["escrow"], "not_configured"); + assert_eq!( + availability["storage_market"]["status"], + "receipt_proven_no_market_settlement" + ); + assert_eq!( + availability["storage_market"]["settlement_policy"]["schema"], + "elastos.content.storage-settlement-policy/v1" + ); + assert_eq!( + availability["storage_market"]["settlement_policy"]["production_federation"] + ["configured"], + false + ); + assert_eq!( + availability["storage_market"]["admission_policy"]["schema"], + CONTENT_STORAGE_MARKET_ADMISSION_POLICY_SCHEMA + ); + assert_eq!( + availability["storage_market"]["admission_policy"]["status"], + "remote_admission_preflight_no_market_admission" + ); + assert_eq!( + availability["storage_market"]["admission_policy"]["current_admission"] + ["remote_content_admission_preflight"], + true + ); + assert_eq!( + availability["storage_market"]["admission_policy"]["current_admission"] + ["signed_admission_receipts"], + true + ); + assert_eq!( + availability["storage_market"]["admission_policy"]["production_market"]["configured"], + false + ); + assert!(availability + .to_string() + .contains(&format!("\"node_did\":\"{remote_did}\""))); + assert!(!availability.to_string().contains("ticket:remote-secret")); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 3); + assert_eq!(requests[0]["ticket"], "ticket:remote-secret"); + assert_eq!(requests[0]["op"], "admission"); + assert_eq!(requests[0]["request"]["object_did"], "did:key:zObject"); + assert_eq!(requests[1]["op"], "ensure"); + assert_eq!(requests[1]["request"]["object_did"], "did:key:zObject"); + assert_eq!(requests[2]["op"], "status"); + endpoint.close().await; + } + + #[tokio::test] + async fn test_carrier_availability_requires_remote_attempt_for_live_proof_when_min_met() { + let cid = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + let topic_name = content_availability_topic_name(cid); + let (remote_message, _remote_did) = signed_content_availability_message( + cid, + [23u8; 32], + "ticket:remote-secret", + "remote-endpoint", + 1_700_000_000, + ); + let (local_sk, local_did) = elastos_identity::derive_did(&[24u8; 32]); + let endpoint = Endpoint::builder() + .secret_key(iroh::SecretKey::from_bytes(&local_sk.to_bytes())) + .bind() + .await + .unwrap(); + let memory_lookup = MemoryLookup::new(); + endpoint.address_lookup().add(memory_lookup.clone()); + let gossip = Gossip::builder().spawn(endpoint.clone()); + let state = Arc::new(Mutex::new(GossipState::new( + endpoint.clone(), + gossip, + memory_lookup, + Some(local_sk), + Some(local_did), + ))); + { + let mut guard = state.lock().await; + guard.joined_topics.insert(topic_name.clone()); + guard.buffers.lock().await.insert( + topic_name, + TopicBuffer { + messages: VecDeque::from([remote_message]), + base_index: 0, + }, + ); + } + + let registry = Arc::new(ProviderRegistry::new()); + let invoker = Arc::new(MockCarrierProviderPlaneInvoker::default()); + registry.set_carrier_invoker(invoker.clone()).await; + let provider = + CarrierAvailabilityProvider::with_provider_registry(state, Arc::downgrade(®istry)); + let response = provider + .send_raw(&serde_json::json!({ + "op": "ensure", + "cid": cid, + "uri": format!("elastos://{cid}"), + "policy": "network_default", + "local": { + "status": "local_pinned", + "provider": "ipfs-provider", + "replicas": 2 + }, + "requirements": { + "min_replicas": 2, + "require_live_multi_peer_proof": true + } + })) + .await + .unwrap(); + + let availability = &response["data"]["availability"]; + assert_eq!(availability["status"], "network_available"); + assert_eq!(availability["replicas"], 3); + assert_eq!( + availability["peer_selection"]["live_multi_peer_proof"], + true + ); + assert_eq!(availability["abuse_controls"]["attempt_limit"], 1); + assert_eq!(availability["abuse_controls"]["attempted_operations"], 1); + + let requests = invoker.requests.lock().await; + assert_eq!(requests.len(), 3); + assert_eq!(requests[0]["op"], "admission"); + assert_eq!(requests[1]["op"], "ensure"); + assert_eq!(requests[2]["op"], "status"); + endpoint.close().await; + } + + #[tokio::test] + async fn test_carrier_content_fetch_reads_from_internal_ipfs_provider() { + let registry = ProviderRegistry::new(); + registry + .register_sub_provider("ipfs", Arc::new(MockCarrierIpfsProvider)) + .await + .unwrap(); + + let bytes = carrier_content_fetch_bytes( + ®istry, + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + "docs/readme.md", + ) + .await + .unwrap(); + + assert_eq!(bytes, b"carrier content"); + let err = carrier_content_fetch_bytes( + ®istry, + "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi", + "../secret", + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("invalid segment")); } #[test] diff --git a/elastos/crates/elastos-server/src/content.rs b/elastos/crates/elastos-server/src/content.rs index 4061c406..acc23e3e 100644 --- a/elastos/crates/elastos-server/src/content.rs +++ b/elastos/crates/elastos-server/src/content.rs @@ -1,15 +1,16 @@ //! Content availability provider. //! //! This is the capsule-facing `elastos://content/*` contract. The first -//! implementation delegates bytes to the existing low-level IPFS/Kubo backend -//! and reports honest local availability status. +//! implementation delegates bytes to the existing low-level IPFS/Kubo backend, +//! then asks the Carrier/provider availability plane to advertise or replicate +//! that CID without exposing raw IPFS/Kubo authority to ordinary capsules. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::fs::OpenOptions; use std::io::{BufRead, Write}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Weak}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use async_trait::async_trait; use base64::Engine as _; @@ -17,7 +18,9 @@ use elastos_common::protected_content::{ validate_protected_content_key_envelope_algorithms, SealedObjectV1, SEALED_OBJECT_SCHEMA, }; use elastos_runtime::provider::{ - Provider, ProviderError, ProviderRegistry, ResourceRequest, ResourceResponse, + Provider, ProviderByteRange, ProviderError, ProviderInvocation, ProviderInvocationTransport, + ProviderProgress, ProviderRegistry, ProviderStreamOptions, ProviderTransfer, ResourceRequest, + ResourceResponse, }; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -25,6 +28,73 @@ use sha2::Digest; const AVAILABILITY_RECEIPT_SCHEMA: &str = "elastos.content.availability.receipt/v1"; const AVAILABILITY_RECEIPT_DOMAIN: &str = "elastos.content.availability.receipt.v1"; +const AVAILABILITY_DASHBOARD_SCHEMA: &str = "elastos.content.availability.dashboard/v1"; +const CONTENT_ADMISSION_DOMAIN: &str = "elastos.content.admission.v1"; +const CONTENT_ACCOUNTING_SCHEMA: &str = "elastos.content.accounting/v1"; +const CONTENT_STORAGE_ACCOUNTING_LEDGER_SCHEMA: &str = + "elastos.content.storage-accounting.ledger/v1"; +const CONTENT_STORAGE_ACCOUNTING_ENTRY_SCHEMA: &str = "elastos.content.storage-accounting.entry/v1"; +const CONTENT_STORAGE_QUOTA_SCHEMA: &str = "elastos.content.storage-quota/v1"; +const CONTENT_FEDERATED_QUOTA_LEDGER_POLICY_SCHEMA: &str = + "elastos.content.federated-quota-ledger-policy/v1"; +const CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_SCHEMA: &str = + "elastos.content.federated-quota-ledger.exchange-request/v1"; +const CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_DOMAIN: &str = + "elastos.content.federated-quota-ledger.exchange-request.v1"; +const CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_SCHEMA: &str = + "elastos.content.federated-quota-ledger.exchange-receipt/v1"; +const CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_DOMAIN: &str = + "elastos.content.federated-quota-ledger.exchange-receipt.v1"; +const CONTENT_ADMISSION_SCHEMA: &str = "elastos.content.admission/v1"; +const CONTENT_ABUSE_CONTROLS_SCHEMA: &str = "elastos.content.abuse-controls/v1"; +const CONTENT_NETWORK_ABUSE_POLICY_SCHEMA: &str = "elastos.content.network-abuse-policy/v1"; +const CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_REQUEST_SCHEMA: &str = + "elastos.content.federated-abuse-control.exchange-request/v1"; +const CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_REQUEST_DOMAIN: &str = + "elastos.content.federated-abuse-control.exchange-request.v1"; +const CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_SCHEMA: &str = + "elastos.content.federated-abuse-control.exchange-receipt/v1"; +const CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_DOMAIN: &str = + "elastos.content.federated-abuse-control.exchange-receipt.v1"; +const CONTENT_OPERATOR_DASHBOARD_SCHEMA: &str = "elastos.content.operator-dashboard/v1"; +const CONTENT_FEDERATED_OPERATOR_ALERTING_POLICY_SCHEMA: &str = + "elastos.content.federated-operator-alerting-policy/v1"; +const CONTENT_OPERATOR_ALERT_SCHEMA: &str = "elastos.content.operator-alert/v1"; +const CONTENT_OPERATOR_ALERT_RECEIPT_SCHEMA: &str = "elastos.content.operator-alert.receipt/v1"; +const CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_REQUEST_SCHEMA: &str = + "elastos.content.federated-operator-alert.exchange-request/v1"; +const CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_RECEIPT_SCHEMA: &str = + "elastos.content.federated-operator-alert.exchange-receipt/v1"; +const CARRIER_PEER_ATTESTATION_EXCHANGE_POLICY_SCHEMA: &str = + "elastos.carrier.peer-attestation-exchange-policy/v1"; +const CONTENT_STORAGE_SETTLEMENT_POLICY_SCHEMA: &str = + "elastos.content.storage-settlement-policy/v1"; +const CONTENT_STORAGE_MARKET_ADMISSION_POLICY_SCHEMA: &str = + "elastos.content.storage-market-admission-policy/v1"; +const CONTENT_STORAGE_MARKET_ADMISSION_REQUEST_SCHEMA: &str = + "elastos.content.storage-market-admission.request/v1"; +const CONTENT_STORAGE_MARKET_ADMISSION_DECISION_SCHEMA: &str = + "elastos.content.storage-market-admission.decision/v1"; +const REPAIR_TASK_SCHEMA: &str = "elastos.content.repair-task/v1"; +const REPAIR_WORKER_RUN_SCHEMA: &str = "elastos.content.repair-worker.run/v1"; +const REPAIR_WORKER_ABUSE_CONTROLS_SCHEMA: &str = "elastos.content.repair-worker.abuse-controls/v1"; +const REPAIR_FLEET_SCHEMA: &str = "elastos.content.repair-fleet/v1"; +const EXTERNAL_REPAIR_FLEET_POLICY_SCHEMA: &str = "elastos.content.external-repair-fleet-policy/v1"; +const EXTERNAL_REPAIR_FLEET_DISPATCH_REQUEST_SCHEMA: &str = + "elastos.content.external-repair-fleet.dispatch-request/v1"; +const EXTERNAL_REPAIR_FLEET_DISPATCH_RECEIPT_SCHEMA: &str = + "elastos.content.external-repair-fleet.dispatch-receipt/v1"; +const REPAIR_RETRY_DELAY_SECS: u64 = 5 * 60; +const REPAIR_HEALTH_CHECK_DELAY_SECS: u64 = 60 * 60; +const REPAIR_WORKER_DEFAULT_LIMIT: usize = 25; +const REPAIR_WORKER_MAX_LIMIT: usize = 100; +const REPAIR_WORKER_DEFAULT_MAX_ATTEMPTS: u32 = 3; +const REPAIR_WORKER_MAX_ATTEMPTS_LIMIT: u32 = 25; +const REPAIR_WORKER_DEFAULT_FAILURE_BUDGET: u32 = 10; +const REPAIR_WORKER_MAX_FAILURE_BUDGET: u32 = 100; +const IMPORT_EXACT_MAX_BYTES: usize = 64 * 1024 * 1024; +const IMPORT_OBJECT_MAX_FILES: usize = 512; +const AVAILABILITY_DASHBOARD_REMOTE_ROW_LIMIT: usize = 10; const OBJECT_MANIFEST_SCHEMA: &str = "elastos.content.object.manifest/v1"; const OBJECT_MANIFEST_PATH: &str = "_elastos_object.json"; const SEALED_OBJECT_PATH: &str = "sealed.json"; @@ -34,1360 +104,8296 @@ pub const CONTENT_OBJECT_MANIFEST_PATH: &str = OBJECT_MANIFEST_PATH; pub struct ContentProvider { data_dir: PathBuf, registry: Weak, + operator_alert_sink: Option, + federated_abuse_control_exchange: Option, + federated_quota_ledger_exchange: Option, + federated_operator_alert_exchange: Option, + storage_market_admission: Option, + external_repair_fleet: Option, } -impl ContentProvider { - pub fn new(data_dir: PathBuf, registry: Weak) -> Self { - Self { data_dir, registry } - } +#[derive(Default)] +pub struct ContentProviderExternalConfigs { + pub operator_alert_sink: Option, + pub storage_market_admission: Option, + pub external_repair_fleet: Option, + pub federated_operator_alert_exchange: Option, + pub federated_quota_ledger_exchange: Option, + pub federated_abuse_control_exchange: Option, +} - fn registry(&self) -> Result, ProviderError> { - self.registry.upgrade().ok_or_else(|| { - ProviderError::Provider("content provider registry unavailable".to_string()) - }) - } +#[derive(Debug, Clone)] +struct ContentOperatorAlertSink { + url: String, + authorization: Option, + timeout_secs: u64, } -pub async fn publish_directory_via_provider( - registry: &ProviderRegistry, - dir: &Path, - object_did: Option<&str>, - publisher_did: Option<&str>, -) -> anyhow::Result { - publish_directory_via_provider_with_kind(registry, dir, "directory", object_did, publisher_did) - .await +#[derive(Debug, Clone)] +struct ContentFederatedAbuseControlExchangeClient { + endpoints: Vec, + quorum: usize, } -pub async fn publish_directory_via_provider_with_kind( - registry: &ProviderRegistry, - dir: &Path, - object_kind: &str, - object_did: Option<&str>, - publisher_did: Option<&str>, -) -> anyhow::Result { - publish_directory_via_provider_with_kind_and_links( - registry, - dir, - object_kind, - object_did, - publisher_did, - &[], - ) - .await +#[derive(Debug, Clone)] +struct ContentFederatedAbuseControlExchangeEndpoint { + id: String, + url: String, + authorization: Option, + timeout_secs: u64, } -pub async fn publish_directory_via_provider_with_kind_and_links( - registry: &ProviderRegistry, - dir: &Path, - object_kind: &str, - object_did: Option<&str>, - publisher_did: Option<&str>, - links: &[(String, String)], -) -> anyhow::Result { - let mut files = Vec::new(); - crate::ipfs::collect_files_for_ipfs(dir, dir, &mut files)?; - if files.is_empty() { - anyhow::bail!("No files found in {}", dir.display()); - } +#[derive(Debug, Clone)] +struct ContentFederatedQuotaLedgerExchangeClient { + endpoints: Vec, + quorum: usize, +} - let mut entries = Vec::new(); - for rel_path in &files { - let abs_path = dir.join(rel_path); - let bytes = std::fs::read(&abs_path)?; - entries.push(json!({ - "path": rel_path.to_string_lossy().replace('\\', "/"), - "data": base64::engine::general_purpose::STANDARD.encode(bytes), - })); - } +#[derive(Debug, Clone)] +struct ContentFederatedQuotaLedgerExchangeEndpoint { + id: String, + url: String, + authorization: Option, + timeout_secs: u64, +} - let mut request = json!({ - "op": "publish", - "kind": "directory", - "object_kind": object_kind, - "files": entries, - "pin": true, - }); - if let Some(object_did) = object_did { - request["object_did"] = Value::String(object_did.to_string()); - } - if let Some(publisher_did) = publisher_did { - request["publisher_did"] = Value::String(publisher_did.to_string()); - } - if !links.is_empty() { - request["links"] = Value::Array( - links - .iter() - .map(|(rel, cid)| { - json!({ - "rel": rel, - "cid": cid, - }) - }) - .collect(), - ); - } +#[derive(Debug, Clone)] +struct ContentFederatedOperatorAlertExchangeClient { + url: String, + authorization: Option, + timeout_secs: u64, +} - let response = registry - .send_raw("content", &request) - .await - .map_err(|err| anyhow::anyhow!("content provider unavailable: {err}"))?; - content_response_cid(&response) +#[derive(Debug, Clone)] +struct ContentStorageMarketAdmissionClient { + endpoints: Vec, + quorum: usize, } -pub async fn publish_bytes_via_provider( - registry: &ProviderRegistry, - filename: &str, - bytes: &[u8], - object_did: Option<&str>, - publisher_did: Option<&str>, -) -> anyhow::Result { - let mut request = json!({ - "op": "publish", - "kind": "file", - "filename": filename, - "data": base64::engine::general_purpose::STANDARD.encode(bytes), - "pin": true, - }); - if let Some(object_did) = object_did { - request["object_did"] = Value::String(object_did.to_string()); - } - if let Some(publisher_did) = publisher_did { - request["publisher_did"] = Value::String(publisher_did.to_string()); - } +#[derive(Debug, Clone)] +struct ContentStorageMarketAdmissionEndpoint { + id: String, + url: String, + authorization: Option, + timeout_secs: u64, +} - let response = registry - .send_raw("content", &request) - .await - .map_err(|err| anyhow::anyhow!("content provider unavailable: {err}"))?; - content_response_cid(&response) +#[derive(Debug, Clone)] +struct ContentExternalRepairFleetClient { + endpoints: Vec, + quorum: usize, } -pub async fn fetch_bytes_via_provider( - registry: &ProviderRegistry, - cid: &str, - path: Option<&str>, -) -> anyhow::Result> { - let mut request = json!({ - "op": "fetch", - "cid": cid, - }); - if let Some(path) = path.filter(|path| !path.is_empty()) { - request["path"] = Value::String(path.to_string()); - } +#[derive(Debug, Clone)] +struct ContentExternalRepairFleetEndpoint { + id: String, + url: String, + authorization: Option, + timeout_secs: u64, +} - let response = registry - .send_raw("content", &request) - .await - .map_err(|err| anyhow::anyhow!("content provider unavailable: {err}"))?; - content_response_bytes(&response) +struct ContentFetchResult { + payload: ContentFetchPayload, + availability: Option, + transfer: Option, } -pub async fn fetch_content_object_manifest( - registry: &ProviderRegistry, - cid: &str, -) -> anyhow::Result { - let bytes = fetch_bytes_via_provider(registry, cid, Some(CONTENT_OBJECT_MANIFEST_PATH)).await?; - parse_content_object_manifest(cid, &bytes) +enum ContentFetchPayload { + Bytes(String), + Stream(Value), } -pub fn parse_content_object_manifest( - cid: &str, - bytes: &[u8], -) -> anyhow::Result { - let manifest: ContentObjectManifest = serde_json::from_slice(bytes).map_err(|err| { - anyhow::anyhow!("content object {cid} has invalid {CONTENT_OBJECT_MANIFEST_PATH}: {err}") - })?; - if manifest.schema != OBJECT_MANIFEST_SCHEMA { - anyhow::bail!( - "content object {cid} uses unsupported object manifest schema {}", - manifest.schema - ); - } - Ok(manifest) +#[derive(Debug, Clone)] +struct ContentFetchTransfer { + transfer: ProviderTransfer, + range: Option, + progress: Option, } -/// Materialize a published capsule through the content availability contract. -/// -/// Data capsules must carry `_elastos_object.json`; that manifest is the file -/// list and integrity contract above the low-level block backend. -pub async fn prepare_capsule_from_content_provider( - registry: &ProviderRegistry, - cid: &str, -) -> anyhow::Result { - let manifest_bytes = match fetch_bytes_via_provider(registry, cid, Some("capsule.json")).await { - Ok(bytes) => bytes, - Err(capsule_err) => { - if let Ok(object_manifest) = fetch_content_object_manifest(registry, cid).await { - anyhow::bail!( - "content object {cid} has kind '{}' and is not a launchable capsule; use `elastos open elastos://{cid}` to inspect release objects or open it with a matching viewer once one is installed", - object_manifest.kind - ); +impl ContentFetchTransfer { + fn from_request(request: &Value) -> Result { + let transfer = match request + .get("transfer") + .and_then(|value| value.as_str()) + .unwrap_or("bytes") + { + "bytes" => ProviderTransfer::Bytes, + "stream" => ProviderTransfer::Stream, + "json" => { + return Err(ProviderError::Provider( + "content fetch transfer must be bytes or stream".into(), + )); } - return Err(capsule_err); - } - }; - let manifest_data = String::from_utf8(manifest_bytes.clone()) - .map_err(|err| anyhow::anyhow!("Manifest is not valid UTF-8 for CID {}: {}", cid, err))?; - let manifest: elastos_common::CapsuleManifest = serde_json::from_str(&manifest_data)?; - manifest - .validate() - .map_err(|err| anyhow::anyhow!("Invalid manifest from CID {}: {}", cid, err))?; - - tracing::info!( - "Loading capsule '{}' ({:?}) through content availability", - manifest.name, - manifest.capsule_type - ); + value => { + return Err(ProviderError::Provider(format!( + "content fetch transfer must be bytes or stream, got {value}" + ))); + } + }; + let range = match request.get("range") { + Some(range) => { + let start = range + .get("start") + .and_then(|value| value.as_u64()) + .ok_or_else(|| { + ProviderError::Provider("content fetch range requires start".into()) + })?; + let end = match range.get("end") { + Some(value) if value.is_null() => None, + Some(value) => Some(value.as_u64().ok_or_else(|| { + ProviderError::Provider( + "content fetch range end must be an unsigned integer".into(), + ) + })?), + None => None, + }; + Some(ProviderByteRange { start, end }) + } + None => None, + }; + let progress = match request.get("progress") { + Some(progress) => { + let request_id = progress + .get("request_id") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + ProviderError::Provider("content fetch progress requires request_id".into()) + })?; + let expected_bytes = match progress.get("expected_bytes") { + Some(value) if value.is_null() => None, + Some(value) => Some(value.as_u64().ok_or_else(|| { + ProviderError::Provider( + "content fetch progress expected_bytes must be an unsigned integer" + .into(), + ) + })?), + None => None, + }; + Some(ProviderProgress { + request_id: request_id.to_string(), + expected_bytes, + }) + } + None => None, + }; + Ok(Self { + transfer, + range, + progress, + }) + } +} - let temp_dir = tempfile::Builder::new() - .prefix("elastos-capsule-") - .tempdir()?; - let capsule_dir = temp_dir.path().to_path_buf(); - write_materialized_file(&capsule_dir, "capsule.json", &manifest_bytes).await?; +impl ContentProvider { + pub fn new(data_dir: PathBuf, registry: Weak) -> Self { + Self::new_with_operator_alert_sink_config(data_dir, registry, None) + } - match manifest.capsule_type { - elastos_common::CapsuleType::MicroVM => { - anyhow::bail!( - "MicroVM capsule opens still require the explicit operator path until content availability supports streamed large-object materialization" - ); - } - elastos_common::CapsuleType::Data => { - materialize_data_capsule(registry, cid, &manifest, &manifest_bytes, &capsule_dir) - .await?; - } - _ => { - let entrypoint_bytes = - fetch_bytes_via_provider(registry, cid, Some(&manifest.entrypoint)).await?; - write_materialized_file(&capsule_dir, &manifest.entrypoint, &entrypoint_bytes).await?; - } + pub fn new_with_operator_alert_sink_config( + data_dir: PathBuf, + registry: Weak, + operator_alert_sink_config: Option, + ) -> Self { + Self::new_with_operator_alert_and_storage_market_config( + data_dir, + registry, + operator_alert_sink_config, + None, + ) } - Ok(temp_dir.keep()) -} + pub fn new_with_operator_alert_and_storage_market_config( + data_dir: PathBuf, + registry: Weak, + operator_alert_sink_config: Option, + storage_market_admission_config: Option, + ) -> Self { + Self::new_with_operator_alert_storage_market_and_repair_fleet_config( + data_dir, + registry, + operator_alert_sink_config, + storage_market_admission_config, + None, + ) + } -#[async_trait] -impl Provider for ContentProvider { - async fn handle(&self, _request: ResourceRequest) -> Result { - Err(ProviderError::Provider( - "content provider only supports capability-scoped raw operations".into(), - )) + pub fn new_with_operator_alert_storage_market_and_repair_fleet_config( + data_dir: PathBuf, + registry: Weak, + operator_alert_sink_config: Option, + storage_market_admission_config: Option, + external_repair_fleet_config: Option, + ) -> Self { + Self::new_with_operator_alert_storage_market_repair_fleet_and_alert_exchange_config( + data_dir, + registry, + operator_alert_sink_config, + storage_market_admission_config, + external_repair_fleet_config, + None, + ) } - fn schemes(&self) -> Vec<&'static str> { - vec!["content"] + pub fn new_with_operator_alert_storage_market_repair_fleet_and_alert_exchange_config( + data_dir: PathBuf, + registry: Weak, + operator_alert_sink_config: Option, + storage_market_admission_config: Option, + external_repair_fleet_config: Option, + federated_operator_alert_exchange_config: Option, + ) -> Self { + Self::new_with_operator_alert_storage_market_repair_fleet_alert_exchange_and_quota_ledger_config( + data_dir, + registry, + operator_alert_sink_config, + storage_market_admission_config, + external_repair_fleet_config, + federated_operator_alert_exchange_config, + None, + ) } - fn name(&self) -> &'static str { - "content-provider" + pub fn new_with_operator_alert_storage_market_repair_fleet_alert_exchange_and_quota_ledger_config( + data_dir: PathBuf, + registry: Weak, + operator_alert_sink_config: Option, + storage_market_admission_config: Option, + external_repair_fleet_config: Option, + federated_operator_alert_exchange_config: Option, + federated_quota_ledger_exchange_config: Option, + ) -> Self { + Self::new_with_external_configs( + data_dir, + registry, + ContentProviderExternalConfigs { + operator_alert_sink: operator_alert_sink_config, + storage_market_admission: storage_market_admission_config, + external_repair_fleet: external_repair_fleet_config, + federated_operator_alert_exchange: federated_operator_alert_exchange_config, + federated_quota_ledger_exchange: federated_quota_ledger_exchange_config, + federated_abuse_control_exchange: None, + }, + ) } - async fn send_raw(&self, request: &Value) -> Result { - match request.get("op").and_then(|op| op.as_str()) { - Some("publish") => self.publish(request).await, - Some("fetch") => self.fetch(request).await, - Some("ensure") => self.ensure(request).await, - Some("repair") => self.repair(request).await, - Some("unpublish") => self.unpublish(request).await, - Some("status") => self.status(request), - Some(op) => Ok(provider_error( - "unsupported_operation", - &format!("unsupported content operation: {op}"), - )), - None => Ok(provider_error( - "invalid_request", - "missing content operation", - )), + pub fn new_with_external_configs( + data_dir: PathBuf, + registry: Weak, + configs: ContentProviderExternalConfigs, + ) -> Self { + let operator_alert_sink = configs.operator_alert_sink.and_then(|config| { + match ContentOperatorAlertSink::from_config(config) { + Ok(sink) => Some(sink), + Err(err) => { + tracing::warn!("content operator alert sink disabled: {}", err); + None + } + } + }); + let storage_market_admission = configs.storage_market_admission.and_then(|config| { + match ContentStorageMarketAdmissionClient::from_config(config) { + Ok(client) => Some(client), + Err(err) => { + tracing::warn!("content storage-market admission disabled: {}", err); + None + } + } + }); + let external_repair_fleet = configs.external_repair_fleet.and_then(|config| { + match ContentExternalRepairFleetClient::from_config(config) { + Ok(client) => Some(client), + Err(err) => { + tracing::warn!("content external repair fleet disabled: {}", err); + None + } + } + }); + let federated_operator_alert_exchange = + configs + .federated_operator_alert_exchange + .and_then( + |config| match ContentFederatedOperatorAlertExchangeClient::from_config(config) + { + Ok(sink) => Some(sink), + Err(err) => { + tracing::warn!( + "content federated operator alert exchange disabled: {}", + err + ); + None + } + }, + ); + let federated_abuse_control_exchange = + configs.federated_abuse_control_exchange.and_then(|config| { + match ContentFederatedAbuseControlExchangeClient::from_config(config) { + Ok(client) => Some(client), + Err(err) => { + tracing::warn!( + "content federated abuse-control exchange disabled: {}", + err + ); + None + } + } + }); + let federated_quota_ledger_exchange = + configs.federated_quota_ledger_exchange.and_then(|config| { + match ContentFederatedQuotaLedgerExchangeClient::from_config(config) { + Ok(client) => Some(client), + Err(err) => { + tracing::warn!("content federated quota-ledger exchange disabled: {}", err); + None + } + } + }); + Self { + data_dir, + registry, + operator_alert_sink, + federated_abuse_control_exchange, + federated_quota_ledger_exchange, + federated_operator_alert_exchange, + storage_market_admission, + external_repair_fleet, } } -} - -impl ContentProvider { - async fn fetch(&self, request: &Value) -> Result { - let cid = request - .get("cid") - .and_then(|cid| cid.as_str()) - .filter(|cid| !cid.trim().is_empty()) - .ok_or_else(|| ProviderError::Provider("content fetch requires cid".into()))?; - if !is_valid_cid(cid) { - return Ok(provider_error( - "invalid_cid", - "content fetch requires a valid CID", - )); - } - let path = request - .get("path") - .and_then(|path| path.as_str()) - .unwrap_or(""); - if let Err(message) = validate_content_path(path) { - return Ok(provider_error("invalid_path", &message)); - } + fn registry(&self) -> Result, ProviderError> { + self.registry.upgrade().ok_or_else(|| { + ProviderError::Provider("content provider registry unavailable".to_string()) + }) + } - let mut ipfs_request = json!({ - "op": "cat", - "cid": cid, - }); - if !path.is_empty() { - ipfs_request["path"] = Value::String(path.to_string()); + fn effective_publisher_did( + &self, + requested_publisher_did: Option<&str>, + ) -> Result { + if let Some(publisher_did) = + requested_publisher_did.filter(|value| !value.trim().is_empty()) + { + return Ok(publisher_did.to_string()); } - let registry = self.registry()?; - let ipfs_response = registry.send_raw("ipfs", &ipfs_request).await?; - provider_response_ok(&ipfs_response, "content fetch")?; - let data = ipfs_response - .get("data") - .and_then(|data| data.get("data")) - .and_then(|data| data.as_str()) - .ok_or_else(|| { - ProviderError::Provider("content backend response missing data".into()) + let (_signing_key, default_did) = elastos_identity::load_or_create_did(&self.data_dir) + .map_err(|err| { + ProviderError::Provider(format!("content default publisher DID unavailable: {err}")) })?; + Ok(default_did) + } +} - let availability = self - .latest_receipt_for_cid(cid) - .transpose()? - .map(|receipt| { - json!({ - "status": receipt.payload.status, - "provider": receipt.payload.provider, - "replicas": receipt.payload.replicas, - "checked_at": receipt.payload.checked_at, - }) - }) - .unwrap_or_else(|| { - json!({ - "status": "unknown", - "provider": "content-provider", - }) - }); +impl ContentOperatorAlertSink { + fn from_config(config: Value) -> Result { + let payload = config + .get("extra") + .filter(|extra| !extra.is_null()) + .unwrap_or(&config); + let url = payload + .get("url") + .or_else(|| payload.get("endpoint_url")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "content operator alert sink requires url".to_string())?; + validate_operator_alert_sink_url(url)?; + let authorization = payload + .get("authorization") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if let Some(value) = &authorization { + validate_operator_alert_header_value(value)?; + } + let timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(5) + .clamp(1, 60); + Ok(Self { + url: url.to_string(), + authorization, + timeout_secs, + }) + } - Ok(provider_ok(json!({ - "cid": cid, - "uri": format!("elastos://{cid}"), - "path": path, - "data": data, - "availability": availability, - }))) + fn redacted_status_json(&self) -> Value { + let parsed = url::Url::parse(&self.url).ok(); + json!({ + "configured": true, + "delivery": "provider_local_webhook", + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self.authorization.is_some(), + "timeout_secs": self.timeout_secs, + "credential_exposed": false, + }) } - async fn publish(&self, request: &Value) -> Result { - let kind = request.get("kind").and_then(|kind| kind.as_str()); - let pin = request - .get("pin") - .and_then(|pin| pin.as_bool()) - .unwrap_or(true); - let registry = self.registry()?; + async fn deliver(&self, alert: &Value) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(self.timeout_secs)) + .build() + .map_err(|err| format!("operator alert client build failed: {err}"))?; + let mut request = client.post(&self.url).json(alert); + if let Some(authorization) = &self.authorization { + request = request.header("Authorization", authorization); + } + let response = request + .send() + .await + .map_err(|err| format!("operator alert delivery failed: {err}"))?; + let status = response.status(); + if status.is_success() { + Ok(json!({ + "configured": true, + "delivered": true, + "status": "delivered", + "http_status": status.as_u16(), + "sink": self.redacted_status_json(), + })) + } else { + Err(format!( + "operator alert sink returned HTTP {}", + status.as_u16() + )) + } + } +} - let ipfs_request = match kind { - Some("directory") => { - let files = request - .get("files") - .cloned() - .unwrap_or_else(|| Value::Array(Vec::new())); - if !files.is_array() { - return Ok(provider_error("invalid_request", "files must be an array")); - } - let files = with_directory_object_manifest( - files, - request - .get("object_kind") - .and_then(|value| value.as_str()) - .unwrap_or("directory"), - request.get("object_did").and_then(|value| value.as_str()), - request - .get("publisher_did") - .and_then(|value| value.as_str()), - request.get("links"), - )?; - json!({ - "op": "add_directory", - "files": files, - "pin": pin, +impl ContentFederatedAbuseControlExchangeClient { + fn from_config(config: Value) -> Result { + let payload = config + .get("extra") + .filter(|extra| !extra.is_null()) + .unwrap_or(&config); + let default_authorization = payload + .get("authorization") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if let Some(value) = &default_authorization { + validate_operator_alert_header_value(value)?; + } + let default_timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(5) + .clamp(1, 60); + let endpoints = match payload.get("endpoints").and_then(|value| value.as_array()) { + Some(values) if !values.is_empty() => values + .iter() + .enumerate() + .map(|(index, endpoint)| { + ContentFederatedAbuseControlExchangeEndpoint::from_config( + endpoint, + index, + default_authorization.as_deref(), + default_timeout_secs, + ) }) + .collect::, _>>()?, + _ => vec![ContentFederatedAbuseControlExchangeEndpoint::from_config( + payload, + 0, + default_authorization.as_deref(), + default_timeout_secs, + )?], + }; + if endpoints.len() > 5 { + return Err( + "content federated abuse-control exchange supports at most 5 endpoints".to_string(), + ); + } + let quorum = payload + .get("quorum") + .or_else(|| payload.get("required_quorum")) + .and_then(|value| value.as_u64()) + .map(|value| value as usize) + .unwrap_or(endpoints.len()); + if quorum == 0 || quorum > endpoints.len() { + return Err(format!( + "content federated abuse-control exchange quorum must be between 1 and {}", + endpoints.len() + )); + } + Ok(Self { endpoints, quorum }) + } + + fn redacted_status_json(&self) -> Value { + let first = self.endpoints.first(); + let parsed = first.and_then(|endpoint| url::Url::parse(&endpoint.url).ok()); + json!({ + "configured": true, + "delivery": "federated_abuse_control_exchange", + "endpoint_count": self.endpoints.len(), + "multi_endpoint": self.endpoints.len() > 1, + "quorum_required": self.quorum, + "endpoints": self + .endpoints + .iter() + .map(ContentFederatedAbuseControlExchangeEndpoint::redacted_status_json) + .collect::>(), + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self + .endpoints + .iter() + .any(|endpoint| endpoint.authorization.is_some()), + "timeout_secs": first.map(|endpoint| endpoint.timeout_secs).unwrap_or(0), + "credential_exposed": false, + }) + } + + async fn exchange(&self, signed_request: &Value) -> Result { + let mut endpoint_receipts = Vec::new(); + let mut accepted_receipts = 0_usize; + let mut rejected_receipts = 0_usize; + let mut failed_receipts = 0_usize; + let mut verified_receipts = 0_usize; + let mut first_verified_signed_receipt = None; + let mut reasons = Vec::new(); + + for endpoint in &self.endpoints { + let receipt = endpoint + .exchange(signed_request) + .await + .unwrap_or_else(|err| { + failed_receipts = failed_receipts.saturating_add(1); + federated_abuse_control_endpoint_unavailable(err, endpoint) + }); + if receipt + .get("accepted") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + accepted_receipts = accepted_receipts.saturating_add(1); + } else if receipt + .get("signed_receipt") + .and_then(|value| value.get("verified")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + rejected_receipts = rejected_receipts.saturating_add(1); } - Some("file") => { - let data = request - .get("data") - .and_then(|data| data.as_str()) - .filter(|data| !data.trim().is_empty()) - .ok_or_else(|| { - ProviderError::Provider("content file publish requires data".into()) - })?; - let filename = request - .get("filename") - .and_then(|filename| filename.as_str()) - .filter(|filename| !filename.trim().is_empty()) - .unwrap_or("content.bin"); - json!({ - "op": "add_bytes", - "data": data, - "filename": filename, - "pin": pin, - }) + if receipt + .get("signed_receipt") + .and_then(|value| value.get("verified")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + verified_receipts = verified_receipts.saturating_add(1); + if first_verified_signed_receipt.is_none() { + first_verified_signed_receipt = receipt.get("signed_receipt").cloned(); + } } - Some(_) | None => { - return Ok(provider_error( - "unsupported_content_kind", - "content publish supports kind=directory or kind=file", - )); + if let Some(reason) = receipt.get("reason").and_then(|value| value.as_str()) { + reasons.push(reason.to_string()); } + endpoint_receipts.push(receipt); + } + + let accepted = accepted_receipts >= self.quorum; + let reason = if accepted { + format!( + "federated abuse-control quorum accepted: {accepted_receipts}/{} verified endpoints accepted", + self.endpoints.len() + ) + } else if reasons.is_empty() { + format!( + "federated abuse-control quorum rejected: {accepted_receipts}/{} accepted, quorum {}", + self.endpoints.len(), + self.quorum + ) + } else { + format!( + "federated abuse-control quorum rejected: {accepted_receipts}/{} accepted, quorum {}; {}", + self.endpoints.len(), + self.quorum, + reasons.join("; ") + ) }; + let mut signed_receipt = first_verified_signed_receipt.unwrap_or_else(|| { + json!({ + "verified": false, + }) + }); + signed_receipt["verified"] = Value::Bool(verified_receipts > 0); + signed_receipt["verified_receipts"] = Value::from(verified_receipts); + + Ok(json!({ + "schema": CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_SCHEMA, + "policy": "configured_federated_abuse_control_exchange", + "provider": "content-provider", + "scope": "content-availability", + "configured": true, + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "exchange": self.redacted_status_json(), + "quorum": { + "required": self.quorum, + "endpoint_count": self.endpoints.len(), + "accepted": accepted_receipts, + "rejected": rejected_receipts, + "failed": failed_receipts, + "verified": verified_receipts, + }, + "endpoint_receipts": endpoint_receipts, + "signed_receipt": signed_receipt, + "reason": reason, + "credential_exposed": false, + "app_visible": false, + })) + } +} - let ipfs_response = registry.send_raw("ipfs", &ipfs_request).await?; - let cid = provider_response_cid(&ipfs_response)?; - let object_did = request - .get("object_did") +impl ContentFederatedAbuseControlExchangeEndpoint { + fn from_config( + payload: &Value, + index: usize, + default_authorization: Option<&str>, + default_timeout_secs: u64, + ) -> Result { + let url = payload + .get("url") + .or_else(|| payload.get("exchange_url")) + .or_else(|| payload.get("endpoint_url")) .and_then(|value| value.as_str()) - .map(str::to_string); - let publisher_did = request - .get("publisher_did") + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "content federated abuse-control exchange endpoint requires url".to_string() + })?; + validate_operator_alert_sink_url(url)?; + let authorization = payload + .get("authorization") .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or(default_authorization) .map(str::to_string); - let local_outcome = AvailabilityOutcome::local_publish(pin); - let outcome = if pin { - self.ensure_network_availability( - ®istry, - &cid, - request, - object_did.as_deref(), - publisher_did.as_deref(), - &local_outcome, - ) - .await? - .unwrap_or(local_outcome) - } else { - local_outcome - }; - let receipt = self.write_receipt(ReceiptInput { - cid: cid.clone(), - object_did, - publisher_did, - provider: outcome.provider.clone(), - policy: outcome.policy.clone(), - status: outcome.status.clone(), - replicas: outcome.replicas, - })?; + if let Some(value) = &authorization { + validate_operator_alert_header_value(value)?; + } + let timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(default_timeout_secs) + .clamp(1, 60); + let id = payload + .get("id") + .or_else(|| payload.get("provider_id")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("abuse-control-{}", index + 1)); + Ok(Self { + id, + url: url.to_string(), + authorization, + timeout_secs, + }) + } - Ok(provider_ok(json!({ - "cid": cid, - "uri": format!("elastos://{cid}"), - "availability": outcome.to_json(), - "receipt": receipt, - }))) + fn redacted_status_json(&self) -> Value { + let parsed = url::Url::parse(&self.url).ok(); + json!({ + "id": self.id, + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self.authorization.is_some(), + "timeout_secs": self.timeout_secs, + "credential_exposed": false, + }) } - async fn unpublish(&self, request: &Value) -> Result { - let cid = request - .get("cid") - .and_then(|cid| cid.as_str()) - .filter(|cid| !cid.trim().is_empty()) - .ok_or_else(|| ProviderError::Provider("content unpublish requires cid".into()))?; - if !is_valid_cid(cid) { - return Ok(provider_error( - "invalid_cid", - "content unpublish requires a valid CID", - )); + async fn exchange(&self, signed_request: &Value) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(self.timeout_secs)) + .build() + .map_err(|err| { + format!("federated abuse-control exchange client build failed: {err}") + })?; + let mut request = client.post(&self.url).json(signed_request); + if let Some(authorization) = &self.authorization { + request = request.header("Authorization", authorization); } - - let registry = self.registry()?; - let ipfs_response = registry - .send_raw( - "ipfs", - &json!({ - "op": "unpin", - "cid": cid, - }), - ) - .await?; - provider_response_ok(&ipfs_response, "content unpublish")?; - let receipt = self.write_receipt(ReceiptInput { - cid: cid.to_string(), - object_did: request - .get("object_did") - .and_then(|value| value.as_str()) - .map(str::to_string), - publisher_did: request - .get("publisher_did") - .and_then(|value| value.as_str()) - .map(str::to_string), - provider: "ipfs-provider".to_string(), - policy: "local_unpublish".to_string(), - status: "local_unpinned".to_string(), - replicas: 0, + let response = request + .send() + .await + .map_err(|err| format!("federated abuse-control exchange request failed: {err}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!( + "federated abuse-control exchange returned HTTP {}", + status.as_u16() + )); + } + let response_json = response.json::().await.map_err(|err| { + format!("federated abuse-control exchange response decode failed: {err}") })?; - - Ok(provider_ok(json!({ - "cid": cid, - "uri": format!("elastos://{cid}"), - "availability": { - "status": "local_unpinned", - "provider": "ipfs-provider", - "replicas": 0, - }, - "receipt": receipt, - }))) + federated_abuse_control_exchange_receipt_from_response( + &response_json, + self.redacted_status_json(), + status.as_u16(), + ) } +} - async fn ensure(&self, request: &Value) -> Result { - self.pin_for_availability(request, "local_ensure_pin", "local_ensure_failed") - .await +impl ContentFederatedQuotaLedgerExchangeClient { + fn from_config(config: Value) -> Result { + let payload = config + .get("extra") + .filter(|extra| !extra.is_null()) + .unwrap_or(&config); + let default_authorization = payload + .get("authorization") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if let Some(value) = &default_authorization { + validate_operator_alert_header_value(value)?; + } + let default_timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(5) + .clamp(1, 60); + let endpoints = match payload.get("endpoints").and_then(|value| value.as_array()) { + Some(values) if !values.is_empty() => values + .iter() + .enumerate() + .map(|(index, endpoint)| { + ContentFederatedQuotaLedgerExchangeEndpoint::from_config( + endpoint, + index, + default_authorization.as_deref(), + default_timeout_secs, + ) + }) + .collect::, _>>()?, + _ => vec![ContentFederatedQuotaLedgerExchangeEndpoint::from_config( + payload, + 0, + default_authorization.as_deref(), + default_timeout_secs, + )?], + }; + if endpoints.len() > 5 { + return Err( + "content federated quota-ledger exchange supports at most 5 endpoints".to_string(), + ); + } + let quorum = payload + .get("quorum") + .or_else(|| payload.get("required_quorum")) + .and_then(|value| value.as_u64()) + .map(|value| value as usize) + .unwrap_or(endpoints.len()); + if quorum == 0 || quorum > endpoints.len() { + return Err(format!( + "content federated quota-ledger exchange quorum must be between 1 and {}", + endpoints.len() + )); + } + Ok(Self { endpoints, quorum }) } - async fn repair(&self, request: &Value) -> Result { - self.pin_for_availability(request, "local_repair_pin", "local_repair_failed") - .await + fn redacted_status_json(&self) -> Value { + let first = self.endpoints.first(); + let parsed = first.and_then(|endpoint| url::Url::parse(&endpoint.url).ok()); + json!({ + "configured": true, + "delivery": "federated_quota_ledger_exchange", + "endpoint_count": self.endpoints.len(), + "multi_endpoint": self.endpoints.len() > 1, + "quorum_required": self.quorum, + "endpoints": self + .endpoints + .iter() + .map(ContentFederatedQuotaLedgerExchangeEndpoint::redacted_status_json) + .collect::>(), + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self + .endpoints + .iter() + .any(|endpoint| endpoint.authorization.is_some()), + "timeout_secs": first.map(|endpoint| endpoint.timeout_secs).unwrap_or(0), + "credential_exposed": false, + }) } - async fn pin_for_availability( - &self, - request: &Value, - success_policy: &str, - failure_policy: &str, - ) -> Result { - let cid = request - .get("cid") - .and_then(|cid| cid.as_str()) - .filter(|cid| !cid.trim().is_empty()) - .ok_or_else(|| ProviderError::Provider("content repair requires cid".into()))?; - if !is_valid_cid(cid) { - return Ok(provider_error( - "invalid_cid", - "content repair requires a valid CID", - )); + async fn exchange(&self, signed_request: &Value) -> Result { + let mut endpoint_receipts = Vec::new(); + let mut accepted_receipts = 0_usize; + let mut rejected_receipts = 0_usize; + let mut failed_receipts = 0_usize; + let mut verified_receipts = 0_usize; + let mut reasons = Vec::new(); + + for endpoint in &self.endpoints { + let receipt = endpoint + .exchange(signed_request) + .await + .unwrap_or_else(|err| { + failed_receipts = failed_receipts.saturating_add(1); + federated_quota_ledger_endpoint_unavailable(err, endpoint) + }); + if receipt + .get("accepted") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + accepted_receipts = accepted_receipts.saturating_add(1); + } else if receipt + .get("signed_receipt") + .and_then(|value| value.get("verified")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + rejected_receipts = rejected_receipts.saturating_add(1); + } + if receipt + .get("signed_receipt") + .and_then(|value| value.get("verified")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + verified_receipts = verified_receipts.saturating_add(1); + } + if let Some(reason) = receipt.get("reason").and_then(|value| value.as_str()) { + reasons.push(reason.to_string()); + } + endpoint_receipts.push(receipt); } - let registry = self.registry()?; - let ipfs_response = registry - .send_raw( - "ipfs", - &json!({ - "op": "pin", - "cid": cid, - }), + let accepted = accepted_receipts >= self.quorum; + let reason = if accepted { + format!( + "federated quota-ledger quorum accepted: {accepted_receipts}/{} verified endpoints accepted", + self.endpoints.len() ) - .await?; - - let (status, policy, replicas, reason) = if ipfs_response - .get("status") - .and_then(|status| status.as_str()) - == Some("error") - { - ( - "repair_needed", - failure_policy, - 0, - ipfs_response - .get("message") - .and_then(|message| message.as_str()) - .map(str::to_string), + } else if reasons.is_empty() { + format!( + "federated quota-ledger quorum rejected: {accepted_receipts}/{} accepted, quorum {}", + self.endpoints.len(), + self.quorum ) } else { - ("local_pinned", success_policy, 1, None) + format!( + "federated quota-ledger quorum rejected: {accepted_receipts}/{} accepted, quorum {}; {}", + self.endpoints.len(), + self.quorum, + reasons.join("; ") + ) }; - let local_outcome = AvailabilityOutcome { - provider: "ipfs-provider".to_string(), - policy: policy.to_string(), - status: status.to_string(), - replicas, - reason, - }; - let object_did = request - .get("object_did") + Ok(json!({ + "schema": CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_SCHEMA, + "policy": "configured_federated_quota_ledger_exchange", + "provider": "content-provider", + "scope": "content-availability", + "configured": true, + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "exchange": self.redacted_status_json(), + "quorum": { + "required": self.quorum, + "endpoint_count": self.endpoints.len(), + "accepted": accepted_receipts, + "rejected": rejected_receipts, + "failed": failed_receipts, + "verified": verified_receipts, + }, + "endpoint_receipts": endpoint_receipts, + "signed_receipt": { + "verified": verified_receipts > 0, + "verified_receipts": verified_receipts, + }, + "reason": reason, + "credential_exposed": false, + "app_visible": false, + })) + } +} + +impl ContentFederatedQuotaLedgerExchangeEndpoint { + fn from_config( + payload: &Value, + index: usize, + default_authorization: Option<&str>, + default_timeout_secs: u64, + ) -> Result { + let url = payload + .get("url") + .or_else(|| payload.get("exchange_url")) + .or_else(|| payload.get("endpoint_url")) .and_then(|value| value.as_str()) - .map(str::to_string); - let publisher_did = request - .get("publisher_did") + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "content federated quota-ledger exchange endpoint requires url".to_string() + })?; + validate_operator_alert_sink_url(url)?; + let authorization = payload + .get("authorization") .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or(default_authorization) .map(str::to_string); - let outcome = if local_outcome.status == "local_pinned" { - self.ensure_network_availability( - ®istry, - cid, - request, - object_did.as_deref(), - publisher_did.as_deref(), - &local_outcome, - ) - .await? - .unwrap_or(local_outcome) - } else { - local_outcome - }; + if let Some(value) = &authorization { + validate_operator_alert_header_value(value)?; + } + let timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(default_timeout_secs) + .clamp(1, 60); + let id = payload + .get("id") + .or_else(|| payload.get("provider_id")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("quota-ledger-{}", index + 1)); + Ok(Self { + id, + url: url.to_string(), + authorization, + timeout_secs, + }) + } - let receipt = self.write_receipt(ReceiptInput { - cid: cid.to_string(), - object_did, - publisher_did, - provider: outcome.provider.clone(), - policy: outcome.policy.clone(), - status: outcome.status.clone(), - replicas: outcome.replicas, - })?; + fn redacted_status_json(&self) -> Value { + let parsed = url::Url::parse(&self.url).ok(); + json!({ + "id": self.id, + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self.authorization.is_some(), + "timeout_secs": self.timeout_secs, + "credential_exposed": false, + }) + } - Ok(provider_ok(json!({ - "cid": cid, - "uri": format!("elastos://{cid}"), - "availability": outcome.to_json(), - "receipt": receipt, - }))) + async fn exchange(&self, signed_request: &Value) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(self.timeout_secs)) + .build() + .map_err(|err| format!("federated quota-ledger exchange client build failed: {err}"))?; + let mut request = client.post(&self.url).json(signed_request); + if let Some(authorization) = &self.authorization { + request = request.header("Authorization", authorization); + } + let response = request + .send() + .await + .map_err(|err| format!("federated quota-ledger exchange request failed: {err}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!( + "federated quota-ledger exchange returned HTTP {}", + status.as_u16() + )); + } + let response_json = response.json::().await.map_err(|err| { + format!("federated quota-ledger exchange response decode failed: {err}") + })?; + federated_quota_ledger_exchange_receipt_from_response( + &response_json, + self.redacted_status_json(), + status.as_u16(), + ) } +} - async fn ensure_network_availability( - &self, - registry: &ProviderRegistry, - cid: &str, - request: &Value, - object_did: Option<&str>, - publisher_did: Option<&str>, - local: &AvailabilityOutcome, - ) -> Result, ProviderError> { - let policy = request - .get("availability_policy") +impl ContentFederatedOperatorAlertExchangeClient { + fn from_config(config: Value) -> Result { + let payload = config + .get("extra") + .filter(|extra| !extra.is_null()) + .unwrap_or(&config); + let url = payload + .get("url") + .or_else(|| payload.get("exchange_url")) + .or_else(|| payload.get("endpoint_url")) .and_then(|value| value.as_str()) - .filter(|value| !value.trim().is_empty()) - .unwrap_or("network_default"); - let mut availability_request = json!({ - "op": "ensure", - "cid": cid, - "uri": format!("elastos://{cid}"), - "policy": policy, - "local": local.to_json(), - }); - if let Some(object_did) = object_did { - availability_request["object_did"] = Value::String(object_did.to_string()); - } - if let Some(publisher_did) = publisher_did { - availability_request["publisher_did"] = Value::String(publisher_did.to_string()); + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "content federated operator alert exchange requires url".to_string())?; + validate_operator_alert_sink_url(url)?; + let authorization = payload + .get("authorization") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if let Some(value) = &authorization { + validate_operator_alert_header_value(value)?; } + let timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(5) + .clamp(1, 60); + Ok(Self { + url: url.to_string(), + authorization, + timeout_secs, + }) + } + + fn redacted_status_json(&self) -> Value { + let parsed = url::Url::parse(&self.url).ok(); + json!({ + "configured": true, + "delivery": "federated_operator_alert_exchange", + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self.authorization.is_some(), + "timeout_secs": self.timeout_secs, + "credential_exposed": false, + }) + } - match registry - .send_raw("availability", &availability_request) + async fn exchange(&self, request_payload: &Value) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(self.timeout_secs)) + .build() + .map_err(|err| { + format!("federated operator alert exchange client build failed: {err}") + })?; + let mut request = client.post(&self.url).json(request_payload); + if let Some(authorization) = &self.authorization { + request = request.header("Authorization", authorization); + } + let response = request + .send() .await - { - Ok(response) => Ok(Some(parse_availability_provider_response( - &response, policy, local, - ))), - Err(ProviderError::NoProvider(_)) => Ok(None), - Err(err) => Ok(Some(AvailabilityOutcome::repair_needed( - "availability-provider", - policy, - local.replicas, - err.to_string(), - ))), + .map_err(|err| format!("federated operator alert exchange request failed: {err}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!( + "federated operator alert exchange returned HTTP {}", + status.as_u16() + )); } + let response_json = response.json::().await.map_err(|err| { + format!("federated operator alert exchange response decode failed: {err}") + })?; + federated_operator_alert_exchange_receipt_from_response( + &response_json, + self, + status.as_u16(), + ) } +} - fn status(&self, request: &Value) -> Result { - if let Some(cid) = request.get("cid").and_then(|cid| cid.as_str()) { - if !is_valid_cid(cid) { - return Ok(provider_error( - "invalid_cid", - "content status requires a valid CID", - )); +impl ContentStorageMarketAdmissionClient { + fn from_config(config: Value) -> Result { + let payload = config + .get("extra") + .filter(|extra| !extra.is_null()) + .unwrap_or(&config); + let default_authorization = payload + .get("authorization") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if let Some(value) = &default_authorization { + validate_operator_alert_header_value(value)?; + } + let default_timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(5) + .clamp(1, 60); + let endpoints = match payload.get("endpoints").and_then(|value| value.as_array()) { + Some(values) if !values.is_empty() => values + .iter() + .enumerate() + .map(|(index, endpoint)| { + ContentStorageMarketAdmissionEndpoint::from_config( + endpoint, + index, + default_authorization.as_deref(), + default_timeout_secs, + ) + }) + .collect::, _>>()?, + _ => vec![ContentStorageMarketAdmissionEndpoint::from_config( + payload, + 0, + default_authorization.as_deref(), + default_timeout_secs, + )?], + }; + if endpoints.len() > 5 { + return Err( + "content storage-market admission supports at most 5 endpoints".to_string(), + ); + } + let quorum = payload + .get("quorum") + .or_else(|| payload.get("required_quorum")) + .and_then(|value| value.as_u64()) + .map(|value| value as usize) + .unwrap_or(endpoints.len()); + if quorum == 0 || quorum > endpoints.len() { + return Err(format!( + "content storage-market admission quorum must be between 1 and {}", + endpoints.len() + )); + } + Ok(Self { endpoints, quorum }) + } + + fn redacted_status_json(&self) -> Value { + let first = self.endpoints.first(); + let parsed = first.and_then(|endpoint| url::Url::parse(&endpoint.url).ok()); + json!({ + "configured": true, + "delivery": "external_storage_market_admission", + "endpoint_count": self.endpoints.len(), + "multi_endpoint": self.endpoints.len() > 1, + "quorum_required": self.quorum, + "endpoints": self + .endpoints + .iter() + .map(ContentStorageMarketAdmissionEndpoint::redacted_status_json) + .collect::>(), + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self + .endpoints + .iter() + .any(|endpoint| endpoint.authorization.is_some()), + "timeout_secs": first.map(|endpoint| endpoint.timeout_secs).unwrap_or(0), + "credential_exposed": false, + }) + } + + async fn decide(&self, request_payload: &Value) -> Result { + let mut endpoint_decisions = Vec::new(); + let mut accepted_decisions = 0_usize; + let mut rejected_decisions = 0_usize; + let mut failed_decisions = 0_usize; + let mut first_accepted_decision = None; + let mut reasons = Vec::new(); + + for endpoint in &self.endpoints { + let decision = endpoint + .decide(request_payload) + .await + .unwrap_or_else(|err| { + failed_decisions = failed_decisions.saturating_add(1); + storage_market_admission_endpoint_unavailable(err, endpoint) + }); + if decision + .get("accepted") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + accepted_decisions = accepted_decisions.saturating_add(1); + if first_accepted_decision.is_none() { + first_accepted_decision = Some(decision.clone()); + } + } else if decision + .get("status") + .and_then(|value| value.as_str()) + .is_some_and(|status| status == "rejected") + { + rejected_decisions = rejected_decisions.saturating_add(1); } - if let Some(receipt) = self.latest_receipt_for_cid(cid) { - let receipt = receipt?; - return Ok(provider_ok(json!({ - "cid": receipt.payload.cid, - "uri": receipt.payload.uri, - "availability": { - "status": receipt.payload.status, - "provider": receipt.payload.provider, - "replicas": receipt.payload.replicas, - "checked_at": receipt.payload.checked_at, - }, - "receipt": receipt, - }))); + if let Some(reason) = decision.get("reason").and_then(|value| value.as_str()) { + reasons.push(reason.to_string()); } + endpoint_decisions.push(decision); } - Ok(provider_ok(json!({ - "availability": { - "status": "unknown", - "provider": "content-provider", - } - }))) + let accepted = accepted_decisions >= self.quorum; + let reason = if accepted { + format!( + "storage-market admission quorum accepted: {accepted_decisions}/{} endpoints accepted", + self.endpoints.len() + ) + } else if reasons.is_empty() { + format!( + "storage-market admission quorum rejected: {accepted_decisions}/{} accepted, quorum {}", + self.endpoints.len(), + self.quorum + ) + } else { + format!( + "storage-market admission quorum rejected: {accepted_decisions}/{} accepted, quorum {}; {}", + self.endpoints.len(), + self.quorum, + reasons.join("; ") + ) + }; + let first_accepted = first_accepted_decision.unwrap_or(Value::Null); + + Ok(json!({ + "schema": CONTENT_STORAGE_MARKET_ADMISSION_DECISION_SCHEMA, + "policy": "external_storage_market_admission", + "scope": "content-availability", + "configured": true, + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "reason": reason, + "market_id": first_accepted.get("market_id").cloned().unwrap_or(Value::Null), + "offer_id": first_accepted.get("offer_id").cloned().unwrap_or(Value::Null), + "receipt": first_accepted.get("receipt").cloned().unwrap_or(Value::Null), + "client": self.redacted_status_json(), + "quorum": { + "required": self.quorum, + "endpoint_count": self.endpoints.len(), + "accepted": accepted_decisions, + "rejected": rejected_decisions, + "failed": failed_decisions, + }, + "endpoint_decisions": endpoint_decisions, + "checked_at": now_unix_secs(), + "app_visible": false, + })) } +} - fn write_receipt( - &self, - input: ReceiptInput, - ) -> Result { - let (signing_key, default_did) = elastos_identity::load_or_create_did(&self.data_dir) - .map_err(|err| { - ProviderError::Provider(format!("content receipt signer unavailable: {err}")) - })?; - let publisher_did = input.publisher_did.unwrap_or(default_did); - let receipt = AvailabilityReceipt { - schema: AVAILABILITY_RECEIPT_SCHEMA.to_string(), - cid: input.cid.clone(), - uri: format!("elastos://{}", input.cid), - object_did: input.object_did, - publisher_did, - provider: input.provider, - policy: input.policy, - status: input.status, - replicas: input.replicas, - checked_at: now_unix_secs(), - }; - let payload_value = serde_json::to_value(&receipt).map_err(|err| { - ProviderError::Provider(format!("content receipt encode failed: {err}")) - })?; - let payload = serde_json::to_string(&payload_value).map_err(|err| { - ProviderError::Provider(format!("content receipt encode failed: {err}")) - })?; - let (signature, signer_did) = crate::crypto::domain_separated_sign( - &signing_key, - AVAILABILITY_RECEIPT_DOMAIN, - payload.as_bytes(), - ); - let signed = SignedAvailabilityReceipt { - payload: receipt, - signature, - signer_did, - }; - append_jsonl(&self.receipts_path(), &signed)?; - Ok(signed) +impl ContentStorageMarketAdmissionEndpoint { + fn from_config( + payload: &Value, + index: usize, + default_authorization: Option<&str>, + default_timeout_secs: u64, + ) -> Result { + let url = payload + .get("url") + .or_else(|| payload.get("admission_url")) + .or_else(|| payload.get("endpoint_url")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "content storage-market admission endpoint requires url".to_string())?; + validate_operator_alert_sink_url(url)?; + let authorization = payload + .get("authorization") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or(default_authorization) + .map(str::to_string); + if let Some(value) = &authorization { + validate_operator_alert_header_value(value)?; + } + let timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(default_timeout_secs) + .clamp(1, 60); + let id = payload + .get("id") + .or_else(|| payload.get("provider_id")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("storage-market-{}", index + 1)); + Ok(Self { + id, + url: url.to_string(), + authorization, + timeout_secs, + }) } - fn latest_receipt_for_cid( - &self, - cid: &str, - ) -> Option> { - let path = self.receipts_path(); - if !path.exists() { - return None; + fn redacted_status_json(&self) -> Value { + let parsed = url::Url::parse(&self.url).ok(); + json!({ + "id": self.id, + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self.authorization.is_some(), + "timeout_secs": self.timeout_secs, + "credential_exposed": false, + }) + } + + async fn decide(&self, request_payload: &Value) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(self.timeout_secs)) + .build() + .map_err(|err| format!("storage-market admission client build failed: {err}"))?; + let mut request = client.post(&self.url).json(request_payload); + if let Some(authorization) = &self.authorization { + request = request.header("Authorization", authorization); + } + let response = request + .send() + .await + .map_err(|err| format!("storage-market admission request failed: {err}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!( + "storage-market admission returned HTTP {}", + status.as_u16() + )); } + let response_json = response + .json::() + .await + .map_err(|err| format!("storage-market admission response decode failed: {err}"))?; + storage_market_admission_decision_from_response(&response_json, self.redacted_status_json()) + } +} - let file = match std::fs::File::open(&path) { - Ok(file) => file, - Err(err) => return Some(Err(err.into())), +impl ContentExternalRepairFleetClient { + fn from_config(config: Value) -> Result { + let payload = config + .get("extra") + .filter(|extra| !extra.is_null()) + .unwrap_or(&config); + let default_authorization = payload + .get("authorization") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if let Some(value) = &default_authorization { + validate_operator_alert_header_value(value)?; + } + let default_timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(5) + .clamp(1, 60); + let endpoints = match payload.get("endpoints").and_then(|value| value.as_array()) { + Some(values) if !values.is_empty() => values + .iter() + .enumerate() + .map(|(index, endpoint)| { + ContentExternalRepairFleetEndpoint::from_config( + endpoint, + index, + default_authorization.as_deref(), + default_timeout_secs, + ) + }) + .collect::, _>>()?, + _ => vec![ContentExternalRepairFleetEndpoint::from_config( + payload, + 0, + default_authorization.as_deref(), + default_timeout_secs, + )?], }; - let reader = std::io::BufReader::new(file); - let mut latest = None; - for line in reader.lines() { - let line = match line { - Ok(line) => line, - Err(err) => return Some(Err(err.into())), - }; - if line.trim().is_empty() { - continue; - } - let receipt: SignedAvailabilityReceipt = match serde_json::from_str(&line) { - Ok(receipt) => receipt, - Err(err) => { - return Some(Err(ProviderError::Provider(format!( - "content receipt ledger decode failed: {err}" - )))) - } - }; - if receipt.payload.cid == cid { - if let Err(err) = verify_signed_receipt(&receipt) { - return Some(Err(err)); + if endpoints.len() > 5 { + return Err("content external repair fleet supports at most 5 endpoints".to_string()); + } + let quorum = payload + .get("quorum") + .or_else(|| payload.get("required_quorum")) + .and_then(|value| value.as_u64()) + .map(|value| value as usize) + .unwrap_or(endpoints.len()); + if quorum == 0 || quorum > endpoints.len() { + return Err(format!( + "content external repair fleet quorum must be between 1 and {}", + endpoints.len() + )); + } + Ok(Self { endpoints, quorum }) + } + + fn endpoint_count(&self) -> usize { + self.endpoints.len() + } + + fn redacted_status_json(&self) -> Value { + let first = self.endpoints.first(); + let parsed = first.and_then(|endpoint| url::Url::parse(&endpoint.url).ok()); + json!({ + "configured": true, + "delivery": "external_repair_fleet_dispatch", + "endpoint_count": self.endpoints.len(), + "multi_endpoint": self.endpoints.len() > 1, + "quorum_required": self.quorum, + "endpoints": self + .endpoints + .iter() + .map(ContentExternalRepairFleetEndpoint::redacted_status_json) + .collect::>(), + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self + .endpoints + .iter() + .any(|endpoint| endpoint.authorization.is_some()), + "timeout_secs": first.map(|endpoint| endpoint.timeout_secs).unwrap_or(0), + "credential_exposed": false, + }) + } + + async fn dispatch(&self, request_payload: &Value) -> Result { + let mut endpoint_receipts = Vec::new(); + let mut accepted_receipts = 0_usize; + let mut rejected_receipts = 0_usize; + let mut failed_receipts = 0_usize; + let mut first_accepted_receipt = None; + let mut reasons = Vec::new(); + + for endpoint in &self.endpoints { + let receipt = endpoint + .dispatch(request_payload) + .await + .unwrap_or_else(|err| { + failed_receipts = failed_receipts.saturating_add(1); + external_repair_fleet_endpoint_dispatch_failed(err, endpoint) + }); + if receipt + .get("accepted") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + accepted_receipts = accepted_receipts.saturating_add(1); + if first_accepted_receipt.is_none() { + first_accepted_receipt = Some(receipt.clone()); } - latest = Some(receipt); + } else if receipt + .get("status") + .and_then(|value| value.as_str()) + .is_some_and(|status| status == "rejected") + { + rejected_receipts = rejected_receipts.saturating_add(1); + } + if let Some(reason) = receipt.get("reason").and_then(|value| value.as_str()) { + reasons.push(reason.to_string()); } + endpoint_receipts.push(receipt); } - latest.map(Ok) - } - fn receipts_path(&self) -> PathBuf { - self.data_dir - .join("ElastOS") - .join("SystemServices") - .join("Content") - .join("availability-receipts.jsonl") + let accepted = accepted_receipts >= self.quorum; + let status = if accepted { + "accepted" + } else { + "dispatch_failed" + }; + let reason = if accepted { + format!( + "external repair-fleet quorum accepted: {accepted_receipts}/{} endpoints accepted", + self.endpoints.len() + ) + } else if reasons.is_empty() { + format!( + "external repair-fleet quorum rejected: {accepted_receipts}/{} accepted, quorum {}", + self.endpoints.len(), + self.quorum + ) + } else { + format!( + "external repair-fleet quorum rejected: {accepted_receipts}/{} accepted, quorum {}; {}", + self.endpoints.len(), + self.quorum, + reasons.join("; ") + ) + }; + let first_accepted = first_accepted_receipt.unwrap_or(Value::Null); + + Ok(json!({ + "schema": EXTERNAL_REPAIR_FLEET_DISPATCH_RECEIPT_SCHEMA, + "policy": "external_repair_fleet_dispatch", + "scope": "content-availability", + "configured": true, + "accepted": accepted, + "status": status, + "reason": reason, + "fleet_id": first_accepted.get("fleet_id").cloned().unwrap_or(Value::Null), + "job_id": first_accepted.get("job_id").cloned().unwrap_or(Value::Null), + "receipt": first_accepted.get("receipt").cloned().unwrap_or(Value::Null), + "client": self.redacted_status_json(), + "quorum": { + "required": self.quorum, + "endpoint_count": self.endpoints.len(), + "accepted": accepted_receipts, + "rejected": rejected_receipts, + "failed": failed_receipts, + }, + "endpoint_receipts": endpoint_receipts, + "dispatched_at": now_unix_secs(), + "app_visible": false, + })) } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AvailabilityReceipt { - pub schema: String, - pub cid: String, - pub uri: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub object_did: Option, - pub publisher_did: String, - pub provider: String, - pub policy: String, - pub status: String, - pub replicas: u32, - pub checked_at: u64, -} +impl ContentExternalRepairFleetEndpoint { + fn from_config( + payload: &Value, + index: usize, + default_authorization: Option<&str>, + default_timeout_secs: u64, + ) -> Result { + let url = payload + .get("url") + .or_else(|| payload.get("dispatch_url")) + .or_else(|| payload.get("endpoint_url")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "content external repair fleet endpoint requires url".to_string())?; + validate_operator_alert_sink_url(url)?; + let authorization = payload + .get("authorization") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or(default_authorization) + .map(str::to_string); + if let Some(value) = &authorization { + validate_operator_alert_header_value(value)?; + } + let timeout_secs = payload + .get("timeout_secs") + .and_then(|value| value.as_u64()) + .unwrap_or(default_timeout_secs) + .clamp(1, 60); + let id = payload + .get("id") + .or_else(|| payload.get("provider_id")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("repair-fleet-{}", index + 1)); + Ok(Self { + id, + url: url.to_string(), + authorization, + timeout_secs, + }) + } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SignedAvailabilityReceipt { - pub payload: AvailabilityReceipt, - pub signature: String, - pub signer_did: String, + fn redacted_status_json(&self) -> Value { + let parsed = url::Url::parse(&self.url).ok(); + json!({ + "id": self.id, + "scheme": parsed.as_ref().map(|url| url.scheme()).unwrap_or("unknown"), + "host": parsed + .as_ref() + .and_then(|url| url.host_str()) + .unwrap_or("unknown"), + "port": parsed.as_ref().and_then(|url| url.port()), + "path_configured": parsed + .as_ref() + .map(|url| !url.path().trim_matches('/').is_empty()) + .unwrap_or(false), + "authorization_configured": self.authorization.is_some(), + "timeout_secs": self.timeout_secs, + "credential_exposed": false, + }) + } + + async fn dispatch(&self, request_payload: &Value) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(self.timeout_secs)) + .build() + .map_err(|err| format!("external repair fleet client build failed: {err}"))?; + let mut request = client.post(&self.url).json(request_payload); + if let Some(authorization) = &self.authorization { + request = request.header("Authorization", authorization); + } + let response = request + .send() + .await + .map_err(|err| format!("external repair fleet dispatch failed: {err}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!( + "external repair fleet returned HTTP {}", + status.as_u16() + )); + } + let response_json = response + .json::() + .await + .map_err(|err| format!("external repair fleet response decode failed: {err}"))?; + external_repair_fleet_dispatch_receipt_from_response( + &response_json, + self.redacted_status_json(), + ) + } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ContentObjectManifest { - pub schema: String, - pub kind: String, - pub content_digest: String, - pub files: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub links: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub object_did: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub publisher_did: Option, +fn validate_operator_alert_sink_url(raw: &str) -> Result<(), String> { + let url = url::Url::parse(raw).map_err(|err| format!("invalid operator alert URL: {err}"))?; + if !url.username().is_empty() || url.password().is_some() { + return Err("operator alert URL must not contain inline credentials".to_string()); + } + match url.scheme() { + "https" => Ok(()), + "http" if matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "::1")) => Ok(()), + _ => Err("operator alert URL must use https or local loopback http".to_string()), + } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ContentObjectFile { - pub path: String, - pub sha256: String, - pub size: u64, +fn validate_operator_alert_header_value(value: &str) -> Result<(), String> { + if value.bytes().any(|byte| matches!(byte, b'\r' | b'\n')) { + return Err("operator alert authorization header contains invalid newline".to_string()); + } + Ok(()) } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ContentObjectLink { - pub rel: String, - pub cid: String, +fn federated_operator_alert_exchange_request(alert: &Value, emitted_at: u64) -> Value { + json!({ + "schema": CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_REQUEST_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "emitted_at": emitted_at, + "alert": alert, + "authority": { + "runtime_invocation_required": true, + "provider_owned_exchange": true, + "credential_exposed": false, + "raw_backend_access": false, + }, + }) } -struct ReceiptInput { - cid: String, - object_did: Option, - publisher_did: Option, - provider: String, - policy: String, - status: String, - replicas: u32, +fn federated_operator_alert_exchange_receipt_from_response( + response: &Value, + client: &ContentFederatedOperatorAlertExchangeClient, + http_status: u16, +) -> Result { + let accepted = response + .get("accepted") + .and_then(|value| value.as_bool()) + .ok_or_else(|| { + "federated operator alert exchange response requires accepted boolean".to_string() + })?; + Ok(json!({ + "schema": CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_RECEIPT_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "configured": true, + "delivered": accepted, + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "http_status": http_status, + "exchange": client.redacted_status_json(), + "remote_schema": response.get("schema").cloned().unwrap_or(Value::Null), + "remote_exchange_id": response.get("exchange_id").cloned().unwrap_or(Value::Null), + "remote_receipt_id": response.get("receipt_id").cloned().unwrap_or(Value::Null), + "reason": response.get("reason").cloned().unwrap_or(Value::Null), + "credential_exposed": false, + })) } -#[derive(Debug, Clone)] -struct AvailabilityOutcome { - provider: String, - policy: String, - status: String, - replicas: u32, - reason: Option, +fn federated_abuse_control_exchange_request(local_admission: &Value, request: &Value) -> Value { + json!({ + "schema": CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_REQUEST_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "cid": local_admission.get("cid").cloned().unwrap_or(Value::Null), + "publisher_did": local_admission + .get("publisher_did") + .cloned() + .unwrap_or(Value::Null), + "estimated_content_bytes": local_admission + .get("estimated_content_bytes") + .cloned() + .unwrap_or(Value::Null), + "quota": local_admission.get("quota").cloned().unwrap_or(Value::Null), + "availability_requirements": request + .get("availability_requirements") + .cloned() + .unwrap_or_else(|| json!({})), + "local_admission": local_admission, + "requested_at": now_unix_secs(), + "authority": { + "runtime_invocation_required": true, + "provider_owned_exchange": true, + "preflight_only": true, + "app_visible": false, + "credential_exposed": false, + "raw_backend_access": false, + "raw_peer_authority": false, + }, + }) } -impl AvailabilityOutcome { - fn local_publish(pin: bool) -> Self { - if pin { - Self { - provider: "ipfs-provider".to_string(), - policy: "local_pin".to_string(), - status: "local_pinned".to_string(), - replicas: 1, - reason: None, - } - } else { - Self { - provider: "ipfs-provider".to_string(), - policy: "local_add".to_string(), - status: "local_unpinned".to_string(), - replicas: 0, - reason: None, - } - } +fn federated_abuse_control_exchange_receipt_from_response( + response: &Value, + exchange: Value, + http_status: u16, +) -> Result { + let payload = response + .get("data") + .filter(|value| value.is_object()) + .unwrap_or(response); + let accepted = payload + .get("accepted") + .and_then(|value| value.as_bool()) + .ok_or_else(|| { + "federated abuse-control exchange response requires accepted boolean".to_string() + })?; + let signed_receipt = payload.get("receipt").ok_or_else(|| { + "federated abuse-control exchange response requires signed receipt".to_string() + })?; + let signer_did = signed_receipt + .get("signer_did") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + "federated abuse-control exchange receipt requires signer_did".to_string() + })?; + let receipt_bytes = serde_json::to_vec(signed_receipt) + .map_err(|err| format!("federated abuse-control exchange receipt encode failed: {err}"))?; + let expected_signers = [signer_did.to_string()]; + crate::crypto::verify_signed_json_envelope_against_dids( + &receipt_bytes, + CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_DOMAIN, + &expected_signers, + ) + .map_err(|err| { + format!("federated abuse-control exchange receipt verification failed: {err}") + })?; + let receipt_payload = signed_receipt + .get("payload") + .filter(|value| value.is_object()) + .ok_or_else(|| "federated abuse-control exchange receipt requires payload".to_string())?; + if receipt_payload + .get("schema") + .and_then(|value| value.as_str()) + != Some(CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_SCHEMA) + { + return Err("federated abuse-control exchange receipt schema mismatch".to_string()); } - fn repair_needed(provider: &str, policy: &str, replicas: u32, reason: String) -> Self { - Self { - provider: provider.to_string(), - policy: policy.to_string(), - status: "repair_needed".to_string(), - replicas, - reason: Some(reason), - } - } + Ok(json!({ + "schema": CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_SCHEMA, + "policy": "configured_federated_abuse_control_exchange", + "provider": "content-provider", + "scope": "content-availability", + "configured": true, + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "http_status": http_status, + "exchange": exchange, + "remote_schema": payload.get("schema").cloned().unwrap_or(Value::Null), + "remote_exchange_id": payload.get("exchange_id").cloned().unwrap_or(Value::Null), + "remote_receipt_id": payload.get("receipt_id").cloned().unwrap_or(Value::Null), + "signed_receipt": { + "verified": true, + "signer_did": signer_did, + "payload_schema": receipt_payload + .get("schema") + .cloned() + .unwrap_or(Value::Null), + "exchange_id": receipt_payload + .get("exchange_id") + .cloned() + .unwrap_or(Value::Null), + "receipt_id": receipt_payload + .get("receipt_id") + .cloned() + .unwrap_or(Value::Null), + "abuse_ledger_id": receipt_payload + .get("abuse_ledger_id") + .cloned() + .unwrap_or(Value::Null), + }, + "reason": payload.get("reason").cloned().unwrap_or(Value::Null), + "credential_exposed": false, + "app_visible": false, + })) +} - fn to_json(&self) -> Value { - let mut availability = json!({ - "status": self.status, - "provider": self.provider, - "replicas": self.replicas, - }); - if let Some(reason) = &self.reason { - availability["reason"] = Value::String(reason.clone()); - } - availability - } +fn federated_abuse_control_exchange_unavailable( + reason: String, + client: &ContentFederatedAbuseControlExchangeClient, +) -> Value { + json!({ + "schema": CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_SCHEMA, + "policy": "configured_federated_abuse_control_exchange", + "provider": "content-provider", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "abuse_control_unavailable", + "exchange": client.redacted_status_json(), + "signed_receipt": { + "verified": false, + "reason": reason, + }, + "reason": reason, + "credential_exposed": false, + "app_visible": false, + }) } -fn provider_ok(data: Value) -> Value { +fn federated_abuse_control_endpoint_unavailable( + reason: String, + endpoint: &ContentFederatedAbuseControlExchangeEndpoint, +) -> Value { json!({ - "status": "ok", - "data": data, + "schema": CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_SCHEMA, + "policy": "configured_federated_abuse_control_exchange", + "provider": "content-provider", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "abuse_control_unavailable", + "exchange": endpoint.redacted_status_json(), + "signed_receipt": { + "verified": false, + "reason": reason, + }, + "reason": reason, + "credential_exposed": false, + "app_visible": false, }) } -fn provider_error(code: &str, message: &str) -> Value { +fn federated_quota_ledger_exchange_request(local_admission: &Value, request: &Value) -> Value { json!({ - "status": "error", - "code": code, - "message": message, + "schema": CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "cid": local_admission.get("cid").cloned().unwrap_or(Value::Null), + "publisher_did": local_admission + .get("publisher_did") + .cloned() + .unwrap_or(Value::Null), + "estimated_content_bytes": local_admission + .get("estimated_content_bytes") + .cloned() + .unwrap_or(Value::Null), + "quota": local_admission.get("quota").cloned().unwrap_or(Value::Null), + "availability_requirements": request + .get("availability_requirements") + .cloned() + .unwrap_or_else(|| json!({})), + "local_admission": local_admission, + "requested_at": now_unix_secs(), + "authority": { + "runtime_invocation_required": true, + "provider_owned_exchange": true, + "preflight_only": true, + "app_visible": false, + "credential_exposed": false, + "raw_backend_access": false, + }, }) } -fn parse_availability_provider_response( +fn federated_quota_ledger_exchange_receipt_from_response( response: &Value, - requested_policy: &str, - local: &AvailabilityOutcome, -) -> AvailabilityOutcome { - if response.get("status").and_then(|status| status.as_str()) == Some("error") { - let message = response - .get("message") - .and_then(|message| message.as_str()) - .unwrap_or("availability provider returned an error") - .to_string(); - return AvailabilityOutcome::repair_needed( - "availability-provider", - requested_policy, - local.replicas, - message, - ); - } - - let data = response.get("data").unwrap_or(response); - let availability = data.get("availability").unwrap_or(data); - let provider = availability - .get("provider") - .and_then(|value| value.as_str()) - .filter(|value| !value.trim().is_empty()) - .unwrap_or("availability-provider"); - let policy = availability - .get("policy") + exchange: Value, + http_status: u16, +) -> Result { + let payload = response + .get("data") + .filter(|value| value.is_object()) + .unwrap_or(response); + let accepted = payload + .get("accepted") + .and_then(|value| value.as_bool()) + .ok_or_else(|| { + "federated quota-ledger exchange response requires accepted boolean".to_string() + })?; + let signed_receipt = payload.get("receipt").ok_or_else(|| { + "federated quota-ledger exchange response requires signed receipt".to_string() + })?; + let signer_did = signed_receipt + .get("signer_did") .and_then(|value| value.as_str()) .filter(|value| !value.trim().is_empty()) - .unwrap_or(requested_policy); - let replicas = availability - .get("replicas") - .and_then(|value| value.as_u64()) - .and_then(|value| u32::try_from(value).ok()) - .unwrap_or(local.replicas); - let status = availability - .get("status") + .ok_or_else(|| "federated quota-ledger exchange receipt requires signer_did".to_string())?; + let receipt_bytes = serde_json::to_vec(signed_receipt) + .map_err(|err| format!("federated quota-ledger exchange receipt encode failed: {err}"))?; + let expected_signers = [signer_did.to_string()]; + crate::crypto::verify_signed_json_envelope_against_dids( + &receipt_bytes, + CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_DOMAIN, + &expected_signers, + ) + .map_err(|err| format!("federated quota-ledger exchange receipt verification failed: {err}"))?; + let receipt_payload = signed_receipt + .get("payload") + .filter(|value| value.is_object()) + .ok_or_else(|| "federated quota-ledger exchange receipt requires payload".to_string())?; + if receipt_payload + .get("schema") .and_then(|value| value.as_str()) - .unwrap_or(""); + != Some(CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_SCHEMA) + { + return Err("federated quota-ledger exchange receipt schema mismatch".to_string()); + } - match status { - "network_available" if replicas > 0 => AvailabilityOutcome { - provider: provider.to_string(), - policy: policy.to_string(), - status: status.to_string(), - replicas, - reason: None, + Ok(json!({ + "schema": CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_SCHEMA, + "policy": "configured_federated_quota_ledger_exchange", + "provider": "content-provider", + "scope": "content-availability", + "configured": true, + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "http_status": http_status, + "exchange": exchange, + "remote_schema": payload.get("schema").cloned().unwrap_or(Value::Null), + "remote_exchange_id": payload.get("exchange_id").cloned().unwrap_or(Value::Null), + "remote_receipt_id": payload.get("receipt_id").cloned().unwrap_or(Value::Null), + "signed_receipt": { + "verified": true, + "signer_did": signer_did, + "payload_schema": receipt_payload + .get("schema") + .cloned() + .unwrap_or(Value::Null), + "exchange_id": receipt_payload + .get("exchange_id") + .cloned() + .unwrap_or(Value::Null), + "receipt_id": receipt_payload + .get("receipt_id") + .cloned() + .unwrap_or(Value::Null), }, - "repair_needed" => AvailabilityOutcome::repair_needed( - provider, - policy, - replicas, - availability - .get("reason") - .and_then(|value| value.as_str()) - .unwrap_or("availability provider reported repair_needed") - .to_string(), - ), - "network_available" => AvailabilityOutcome::repair_needed( - provider, - policy, - local.replicas, - "availability provider reported network_available without replicas".to_string(), - ), - _ => AvailabilityOutcome::repair_needed( - provider, - policy, - local.replicas, - "availability provider returned an unsupported status".to_string(), - ), - } + "reason": payload.get("reason").cloned().unwrap_or(Value::Null), + "credential_exposed": false, + "app_visible": false, + })) } -fn provider_response_cid(response: &Value) -> Result { - provider_response_ok(response, "content publish")?; - response +fn federated_quota_ledger_exchange_unavailable( + reason: String, + client: &ContentFederatedQuotaLedgerExchangeClient, +) -> Value { + json!({ + "schema": CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_SCHEMA, + "policy": "configured_federated_quota_ledger_exchange", + "provider": "content-provider", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "quota_ledger_unavailable", + "exchange": client.redacted_status_json(), + "signed_receipt": { + "verified": false, + "reason": reason, + }, + "reason": reason, + "credential_exposed": false, + "app_visible": false, + }) +} + +fn federated_quota_ledger_endpoint_unavailable( + reason: String, + endpoint: &ContentFederatedQuotaLedgerExchangeEndpoint, +) -> Value { + json!({ + "schema": CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_SCHEMA, + "policy": "configured_federated_quota_ledger_exchange", + "provider": "content-provider", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "quota_ledger_unavailable", + "exchange": endpoint.redacted_status_json(), + "signed_receipt": { + "verified": false, + "reason": reason, + }, + "reason": reason, + "credential_exposed": false, + "app_visible": false, + }) +} + +fn storage_market_admission_request(local_admission: &Value, request: &Value) -> Value { + json!({ + "schema": CONTENT_STORAGE_MARKET_ADMISSION_REQUEST_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "cid": local_admission.get("cid").cloned().unwrap_or(Value::Null), + "publisher_did": local_admission + .get("publisher_did") + .cloned() + .unwrap_or(Value::Null), + "estimated_content_bytes": local_admission + .get("estimated_content_bytes") + .cloned() + .unwrap_or(Value::Null), + "quota": local_admission.get("quota").cloned().unwrap_or(Value::Null), + "availability_requirements": request + .get("availability_requirements") + .cloned() + .unwrap_or_else(|| json!({})), + "local_admission": local_admission, + "requested_at": now_unix_secs(), + "app_visible": false, + }) +} + +fn storage_market_admission_decision_from_response( + response: &Value, + client: Value, +) -> Result { + let payload = response .get("data") - .and_then(|data| data.get("cid")) - .and_then(|cid| cid.as_str()) + .filter(|value| value.is_object()) + .unwrap_or(response); + let accepted = payload + .get("accepted") + .and_then(|value| value.as_bool()) + .ok_or_else(|| "storage-market admission response requires accepted boolean".to_string())?; + let status = payload + .get("status") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) .map(str::to_string) - .ok_or_else(|| ProviderError::Provider("content backend response missing cid".into())) + .unwrap_or_else(|| { + if accepted { + "accepted".to_string() + } else { + "rejected".to_string() + } + }); + Ok(json!({ + "schema": CONTENT_STORAGE_MARKET_ADMISSION_DECISION_SCHEMA, + "policy": "external_storage_market_admission", + "scope": "content-availability", + "configured": true, + "accepted": accepted, + "status": status, + "reason": payload + .get("reason") + .and_then(|value| value.as_str()) + .map(str::to_string), + "market_id": payload + .get("market_id") + .and_then(|value| value.as_str()) + .map(str::to_string), + "offer_id": payload + .get("offer_id") + .and_then(|value| value.as_str()) + .map(str::to_string), + "receipt": payload.get("receipt").cloned().unwrap_or(Value::Null), + "client": client, + "checked_at": now_unix_secs(), + "app_visible": false, + })) } -fn content_response_cid(response: &Value) -> anyhow::Result { - if response.get("status").and_then(|status| status.as_str()) == Some("error") { - let message = response - .get("message") - .and_then(|message| message.as_str()) - .unwrap_or("unknown error"); - anyhow::bail!("content publish failed: {message}"); - } - response +fn storage_market_admission_endpoint_unavailable( + reason: String, + endpoint: &ContentStorageMarketAdmissionEndpoint, +) -> Value { + json!({ + "schema": CONTENT_STORAGE_MARKET_ADMISSION_DECISION_SCHEMA, + "policy": "external_storage_market_admission", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "market_unavailable", + "reason": reason, + "receipt": Value::Null, + "client": endpoint.redacted_status_json(), + "checked_at": now_unix_secs(), + "app_visible": false, + }) +} + +fn storage_market_admission_unavailable(reason: String) -> Value { + json!({ + "schema": CONTENT_STORAGE_MARKET_ADMISSION_DECISION_SCHEMA, + "policy": "external_storage_market_admission", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "market_unavailable", + "reason": reason, + "receipt": Value::Null, + "checked_at": now_unix_secs(), + "app_visible": false, + }) +} + +fn external_repair_fleet_dispatch_request(task: &ContentRepairTask, now: u64) -> Value { + json!({ + "schema": EXTERNAL_REPAIR_FLEET_DISPATCH_REQUEST_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "cid": task.cid, + "uri": format!("elastos://{}", task.cid), + "object_did": task.object_did.clone(), + "publisher_did": task.publisher_did.clone(), + "availability_policy": task.policy.clone(), + "availability_requirements": task.requirements.clone(), + "repair_task": { + "schema": REPAIR_TASK_SCHEMA, + "status": task.status.clone(), + "attempts": task.attempts, + "next_check_after": task.next_check_after, + "checked_at": task.checked_at, + }, + "requested_at": now, + "runtime_invocation_required": true, + "app_visible": false, + }) +} + +fn external_repair_fleet_dispatch_receipt_from_response( + response: &Value, + client: Value, +) -> Result { + let payload = response .get("data") - .and_then(|data| data.get("cid")) - .and_then(|cid| cid.as_str()) + .filter(|value| value.is_object()) + .unwrap_or(response); + let accepted = payload + .get("accepted") + .and_then(|value| value.as_bool()) + .ok_or_else(|| "external repair fleet response requires accepted boolean".to_string())?; + let status = payload + .get("status") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) .map(str::to_string) - .ok_or_else(|| anyhow::anyhow!("No CID in content provider response")) + .unwrap_or_else(|| { + if accepted { + "accepted".to_string() + } else { + "rejected".to_string() + } + }); + Ok(json!({ + "schema": EXTERNAL_REPAIR_FLEET_DISPATCH_RECEIPT_SCHEMA, + "policy": "external_repair_fleet_dispatch", + "scope": "content-availability", + "configured": true, + "accepted": accepted, + "status": status, + "reason": payload + .get("reason") + .and_then(|value| value.as_str()) + .map(str::to_string), + "fleet_id": payload + .get("fleet_id") + .and_then(|value| value.as_str()) + .map(str::to_string), + "job_id": payload + .get("job_id") + .and_then(|value| value.as_str()) + .map(str::to_string), + "receipt": payload.get("receipt").cloned().unwrap_or(Value::Null), + "client": client, + "dispatched_at": now_unix_secs(), + "app_visible": false, + })) } -fn content_response_bytes(response: &Value) -> anyhow::Result> { - if response.get("status").and_then(|status| status.as_str()) == Some("error") { - let message = response - .get("message") - .and_then(|message| message.as_str()) - .unwrap_or("unknown error"); - anyhow::bail!("content fetch failed: {message}"); - } - let data = response - .get("data") - .and_then(|data| data.get("data")) - .and_then(|data| data.as_str()) - .ok_or_else(|| anyhow::anyhow!("No data in content provider response"))?; - base64::engine::general_purpose::STANDARD - .decode(data) - .map_err(|err| anyhow::anyhow!("Content provider returned invalid base64: {err}")) +fn external_repair_fleet_endpoint_dispatch_failed( + reason: String, + endpoint: &ContentExternalRepairFleetEndpoint, +) -> Value { + json!({ + "schema": EXTERNAL_REPAIR_FLEET_DISPATCH_RECEIPT_SCHEMA, + "policy": "external_repair_fleet_dispatch", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "dispatch_failed", + "reason": reason, + "receipt": Value::Null, + "client": endpoint.redacted_status_json(), + "dispatched_at": now_unix_secs(), + "app_visible": false, + }) } -fn provider_response_ok(response: &Value, operation: &str) -> Result<(), ProviderError> { - if response.get("status").and_then(|status| status.as_str()) == Some("error") { - let message = response - .get("message") - .and_then(|message| message.as_str()) - .unwrap_or("unknown error"); - return Err(ProviderError::Provider(format!( - "{operation} failed: {message}" - ))); - } - Ok(()) +fn external_repair_fleet_dispatch_failed(reason: String) -> Value { + json!({ + "schema": EXTERNAL_REPAIR_FLEET_DISPATCH_RECEIPT_SCHEMA, + "policy": "external_repair_fleet_dispatch", + "scope": "content-availability", + "configured": true, + "accepted": false, + "status": "dispatch_failed", + "reason": reason, + "receipt": Value::Null, + "dispatched_at": now_unix_secs(), + "app_visible": false, + }) } -fn is_valid_cid(value: &str) -> bool { - cid::Cid::try_from(value).is_ok() +pub async fn publish_directory_via_provider( + registry: &ProviderRegistry, + dir: &Path, + object_did: Option<&str>, + publisher_did: Option<&str>, +) -> anyhow::Result { + publish_directory_via_provider_with_kind(registry, dir, "directory", object_did, publisher_did) + .await } -fn validate_content_path(path: &str) -> Result<(), String> { - if path.is_empty() { - return Ok(()); +pub async fn publish_directory_via_provider_with_kind( + registry: &ProviderRegistry, + dir: &Path, + object_kind: &str, + object_did: Option<&str>, + publisher_did: Option<&str>, +) -> anyhow::Result { + publish_directory_via_provider_with_kind_and_links( + registry, + dir, + object_kind, + object_did, + publisher_did, + &[], + ) + .await +} + +pub async fn publish_directory_via_provider_with_kind_and_links( + registry: &ProviderRegistry, + dir: &Path, + object_kind: &str, + object_did: Option<&str>, + publisher_did: Option<&str>, + links: &[(String, String)], +) -> anyhow::Result { + let mut files = Vec::new(); + crate::ipfs::collect_files_for_ipfs(dir, dir, &mut files)?; + if files.is_empty() { + anyhow::bail!("No files found in {}", dir.display()); } - if path.starts_with('/') || path.starts_with('\\') { - return Err("content fetch path must be relative".to_string()); + + let mut entries = Vec::new(); + for rel_path in &files { + let abs_path = dir.join(rel_path); + let bytes = std::fs::read(&abs_path)?; + entries.push(json!({ + "path": rel_path.to_string_lossy().replace('\\', "/"), + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + })); } - if path.contains('\\') || path.contains('\0') { - return Err("content fetch path contains invalid characters".to_string()); + + let mut request = json!({ + "op": "publish", + "kind": "directory", + "object_kind": object_kind, + "files": entries, + "pin": true, + }); + if let Some(object_did) = object_did { + request["object_did"] = Value::String(object_did.to_string()); } - for segment in path.split('/') { - if segment.is_empty() || segment == "." || segment == ".." { - return Err("content fetch path contains an invalid segment".to_string()); - } + if let Some(publisher_did) = publisher_did { + request["publisher_did"] = Value::String(publisher_did.to_string()); } - Ok(()) + if !links.is_empty() { + request["links"] = Value::Array( + links + .iter() + .map(|(rel, cid)| { + json!({ + "rel": rel, + "cid": cid, + }) + }) + .collect(), + ); + } + + let response = registry + .send_raw("content", &request) + .await + .map_err(|err| anyhow::anyhow!("content provider unavailable: {err}"))?; + content_response_cid(&response) } -fn with_directory_object_manifest( - files: Value, - kind: &str, +pub async fn publish_bytes_via_provider( + registry: &ProviderRegistry, + filename: &str, + bytes: &[u8], object_did: Option<&str>, publisher_did: Option<&str>, - links: Option<&Value>, -) -> Result { - let mut files = files - .as_array() - .cloned() - .ok_or_else(|| ProviderError::Provider("files must be an array".into()))?; - let manifest = directory_object_manifest(&files, kind, object_did, publisher_did, links)?; - let manifest_bytes = serde_json::to_vec_pretty(&manifest).map_err(|err| { - ProviderError::Provider(format!("content object manifest encode failed: {err}")) +) -> anyhow::Result { + let mut request = json!({ + "op": "publish", + "kind": "file", + "filename": filename, + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + "pin": true, + }); + if let Some(object_did) = object_did { + request["object_did"] = Value::String(object_did.to_string()); + } + if let Some(publisher_did) = publisher_did { + request["publisher_did"] = Value::String(publisher_did.to_string()); + } + + let response = registry + .send_raw("content", &request) + .await + .map_err(|err| anyhow::anyhow!("content provider unavailable: {err}"))?; + content_response_cid(&response) +} + +pub async fn fetch_bytes_via_provider( + registry: &ProviderRegistry, + cid: &str, + path: Option<&str>, +) -> anyhow::Result> { + let mut request = json!({ + "op": "fetch", + "cid": cid, + "transfer": "stream", + "progress": { + "request_id": format!("content-fetch:{cid}:{}", path.unwrap_or("root")), + "expected_bytes": Value::Null, + }, + }); + if let Some(path) = path.filter(|path| !path.is_empty()) { + request["path"] = Value::String(path.to_string()); + } + + let mut session = registry + .open_provider_stream( + ProviderInvocation { + source: "runtime-content-consumer".to_string(), + target: "content".to_string(), + op: "fetch".to_string(), + request, + transfer: ProviderTransfer::Stream, + range: None, + progress: Some(ProviderProgress { + request_id: format!("content-fetch:{cid}:{}", path.unwrap_or("root")), + expected_bytes: None, + }), + transport: ProviderInvocationTransport::Local, + }, + ProviderStreamOptions::default(), + ) + .await + .map_err(|err| anyhow::anyhow!("content provider stream unavailable: {err}"))?; + session + .drain_to_vec() + .map_err(|err| anyhow::anyhow!("content provider stream read failed: {err}")) +} + +pub async fn fetch_content_object_manifest( + registry: &ProviderRegistry, + cid: &str, +) -> anyhow::Result { + let bytes = fetch_bytes_via_provider(registry, cid, Some(CONTENT_OBJECT_MANIFEST_PATH)).await?; + parse_content_object_manifest(cid, &bytes) +} + +pub fn parse_content_object_manifest( + cid: &str, + bytes: &[u8], +) -> anyhow::Result { + let manifest: ContentObjectManifest = serde_json::from_slice(bytes).map_err(|err| { + anyhow::anyhow!("content object {cid} has invalid {CONTENT_OBJECT_MANIFEST_PATH}: {err}") })?; - files.push(json!({ - "path": OBJECT_MANIFEST_PATH, - "data": base64::engine::general_purpose::STANDARD.encode(manifest_bytes), - })); - sort_directory_entries(&mut files)?; - Ok(Value::Array(files)) + if manifest.schema != OBJECT_MANIFEST_SCHEMA { + anyhow::bail!( + "content object {cid} uses unsupported object manifest schema {}", + manifest.schema + ); + } + Ok(manifest) } -fn directory_object_manifest( - files: &[Value], - kind: &str, - object_did: Option<&str>, - publisher_did: Option<&str>, - links: Option<&Value>, -) -> Result { - let kind = validate_content_object_kind(kind)?; - let links = parse_content_object_links(links)?; - let mut seen_paths = BTreeSet::new(); - let mut object_files = Vec::with_capacity(files.len()); - let mut sealed_object = None; - for file in files { - let path = file - .get("path") - .and_then(|path| path.as_str()) - .filter(|path| !path.trim().is_empty()) - .ok_or_else(|| { - ProviderError::Provider("directory publish file is missing path".into()) - })?; - if path == OBJECT_MANIFEST_PATH { - return Err(ProviderError::Provider(format!( - "{OBJECT_MANIFEST_PATH} is reserved for the content object manifest" - ))); +/// Materialize a published capsule through the content availability contract. +/// +/// Data capsules must carry `_elastos_object.json`; that manifest is the file +/// list and integrity contract above the low-level block backend. +pub async fn prepare_capsule_from_content_provider( + registry: &ProviderRegistry, + cid: &str, +) -> anyhow::Result { + let manifest_bytes = match fetch_bytes_via_provider(registry, cid, Some("capsule.json")).await { + Ok(bytes) => bytes, + Err(capsule_err) => { + if let Ok(object_manifest) = fetch_content_object_manifest(registry, cid).await { + anyhow::bail!( + "content object {cid} has kind '{}' and is not a launchable capsule; use `elastos open elastos://{cid}` to inspect release objects or open it with a matching viewer once one is installed", + object_manifest.kind + ); + } + return Err(capsule_err); } - validate_content_path(path).map_err(ProviderError::Provider)?; - if !seen_paths.insert(path.to_string()) { - return Err(ProviderError::Provider(format!( - "duplicate directory publish path: {path}" - ))); + }; + let manifest_data = String::from_utf8(manifest_bytes.clone()) + .map_err(|err| anyhow::anyhow!("Manifest is not valid UTF-8 for CID {}: {}", cid, err))?; + let manifest: elastos_common::CapsuleManifest = serde_json::from_str(&manifest_data)?; + manifest + .validate() + .map_err(|err| anyhow::anyhow!("Invalid manifest from CID {}: {}", cid, err))?; + + tracing::info!( + "Loading capsule '{}' ({:?}) through content availability", + manifest.name, + manifest.capsule_type + ); + + let temp_dir = tempfile::Builder::new() + .prefix("elastos-capsule-") + .tempdir()?; + let capsule_dir = temp_dir.path().to_path_buf(); + write_materialized_file(&capsule_dir, "capsule.json", &manifest_bytes).await?; + + match manifest.capsule_type { + elastos_common::CapsuleType::MicroVM => { + anyhow::bail!( + "MicroVM capsule opens still require the explicit operator path until content availability supports streamed large-object materialization" + ); } - let data = file - .get("data") - .and_then(|data| data.as_str()) - .ok_or_else(|| { - ProviderError::Provider(format!( - "directory publish file {path} is missing base64 data" - )) - })?; - let bytes = base64::engine::general_purpose::STANDARD - .decode(data) - .map_err(|err| { - ProviderError::Provider(format!( - "directory publish file {path} has invalid base64 data: {err}" - )) - })?; - if kind == "sealed" && path == SEALED_OBJECT_PATH { - let sealed: SealedObjectV1 = serde_json::from_slice(&bytes).map_err(|err| { - ProviderError::Provider(format!( - "sealed content object has invalid {SEALED_OBJECT_PATH}: {err}" - )) - })?; - validate_sealed_object_descriptor(&sealed)?; - sealed_object = Some(sealed); + elastos_common::CapsuleType::Data => { + materialize_data_capsule(registry, cid, &manifest, &manifest_bytes, &capsule_dir) + .await?; + } + _ => { + let entrypoint_bytes = + fetch_bytes_via_provider(registry, cid, Some(&manifest.entrypoint)).await?; + write_materialized_file(&capsule_dir, &manifest.entrypoint, &entrypoint_bytes).await?; } - object_files.push(ContentObjectFile { - path: path.to_string(), - sha256: format!("{:x}", sha2::Sha256::digest(&bytes)), - size: bytes.len() as u64, - }); } - object_files.sort_by(|a, b| a.path.cmp(&b.path)); - if kind == "sealed" { - let sealed_object = sealed_object.ok_or_else(|| { - ProviderError::Provider(format!( - "sealed content object requires {SEALED_OBJECT_PATH}" - )) - })?; - validate_sealed_content_links(&sealed_object, &links)?; + + Ok(temp_dir.keep()) +} + +#[async_trait] +impl Provider for ContentProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "content provider only supports capability-scoped raw operations".into(), + )) } - let mut hasher = sha2::Sha256::new(); - for file in &object_files { - hasher.update(file.path.as_bytes()); - hasher.update(b"\0"); - hasher.update(file.sha256.as_bytes()); - hasher.update(b"\0"); - hasher.update(file.size.to_string().as_bytes()); - hasher.update(b"\0"); + fn schemes(&self) -> Vec<&'static str> { + vec!["content"] } - Ok(ContentObjectManifest { - schema: OBJECT_MANIFEST_SCHEMA.to_string(), - kind, - content_digest: format!("sha256:{:x}", hasher.finalize()), - files: object_files, - links, - object_did: object_did.map(str::to_string), - publisher_did: publisher_did.map(str::to_string), - }) -} + fn name(&self) -> &'static str { + "content-provider" + } -fn validate_content_object_kind(kind: &str) -> Result { - match kind { - "capsule" | "directory" | "document" | "release" | "sealed" | "share" | "site" => { - Ok(kind.to_string()) + async fn send_raw(&self, request: &Value) -> Result { + match request.get("op").and_then(|op| op.as_str()) { + Some("publish") => self.publish(request).await, + Some("fetch") => self.fetch(request).await, + Some("import_exact") => self.import_exact(request).await, + Some("import_object") => self.import_object(request).await, + Some("admission") => self.admission(request).await, + Some("ensure") => self.ensure(request).await, + Some("repair") => self.repair(request).await, + Some("repair_worker") => self.run_repair_worker(request).await, + Some("unpublish") => self.unpublish(request).await, + Some("status") => self.status(request).await, + Some(op) => Ok(provider_error( + "unsupported_operation", + &format!("unsupported content operation: {op}"), + )), + None => Ok(provider_error( + "invalid_request", + "missing content operation", + )), } - _ => Err(ProviderError::Provider(format!( - "unsupported content object kind: {kind}" - ))), } } -fn parse_content_object_links( - links: Option<&Value>, -) -> Result, ProviderError> { - let Some(links) = links else { - return Ok(Vec::new()); - }; - let links = links - .as_array() - .ok_or_else(|| ProviderError::Provider("content object links must be an array".into()))?; - let mut parsed = Vec::with_capacity(links.len()); - let mut seen = BTreeSet::new(); - for link in links { - let rel = link - .get("rel") - .and_then(|rel| rel.as_str()) - .filter(|rel| !rel.trim().is_empty()) - .ok_or_else(|| ProviderError::Provider("content object link is missing rel".into()))?; - validate_content_object_link_rel(rel)?; - let cid = link +impl ContentProvider { + async fn invoke_provider( + &self, + registry: &ProviderRegistry, + target: &str, + op: &str, + request: Value, + transfer: ProviderTransfer, + ) -> Result { + registry + .invoke_provider(ProviderInvocation { + source: self.name().to_string(), + target: target.to_string(), + op: op.to_string(), + request, + transfer, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + } + + async fn invoke_provider_with_fetch_transfer( + &self, + registry: &ProviderRegistry, + target: &str, + op: &str, + request: Value, + transfer: &ContentFetchTransfer, + ) -> Result { + registry + .invoke_provider(ProviderInvocation { + source: self.name().to_string(), + target: target.to_string(), + op: op.to_string(), + request, + transfer: transfer.transfer, + range: transfer.range, + progress: transfer.progress.clone(), + transport: ProviderInvocationTransport::Local, + }) + .await + } + + async fn fetch(&self, request: &Value) -> Result { + let cid = request .get("cid") .and_then(|cid| cid.as_str()) .filter(|cid| !cid.trim().is_empty()) - .ok_or_else(|| ProviderError::Provider("content object link is missing cid".into()))?; - cid::Cid::try_from(cid).map_err(|err| { - ProviderError::Provider(format!("invalid content object link cid: {err}")) - })?; - if !seen.insert((rel.to_string(), cid.to_string())) { - return Err(ProviderError::Provider(format!( - "duplicate content object link: {rel} {cid}" - ))); + .ok_or_else(|| ProviderError::Provider("content fetch requires cid".into()))?; + if !is_valid_cid(cid) { + return Ok(provider_error( + "invalid_cid", + "content fetch requires a valid CID", + )); } - parsed.push(ContentObjectLink { - rel: rel.to_string(), - cid: cid.to_string(), - }); - } - parsed.sort_by(|a, b| a.rel.cmp(&b.rel).then_with(|| a.cid.cmp(&b.cid))); - Ok(parsed) -} - -fn validate_sealed_object_descriptor(object: &SealedObjectV1) -> Result<(), ProviderError> { - if object.schema != SEALED_OBJECT_SCHEMA { - return Err(ProviderError::Provider( - "sealed content object schema is unsupported".to_string(), - )); - } - validate_linked_cid(&object.payload_cid, "payload_cid")?; - validate_linked_cid(&object.rights_policy_cid, "rights_policy_cid")?; - validate_linked_cid(&object.availability_receipt_cid, "availability_receipt_cid")?; - require_field(&object.key_envelope.scheme, "key_envelope.scheme")?; - require_field(&object.key_envelope.kid, "key_envelope.kid")?; - require_field(&object.key_envelope.wrapped_cek, "key_envelope.wrapped_cek")?; - require_field(&object.key_envelope.policy_hash, "key_envelope.policy_hash")?; - validate_protected_content_key_envelope_algorithms(&object.key_envelope.algorithms) - .map_err(|err| ProviderError::Provider(format!("sealed content object {err}")))?; - require_field( - &object.viewer.required_interface, - "viewer.required_interface", - ) -} -fn validate_sealed_content_links( - object: &SealedObjectV1, - links: &[ContentObjectLink], -) -> Result<(), ProviderError> { - require_link(links, "payload", &object.payload_cid)?; - require_link(links, "rights.policy", &object.rights_policy_cid)?; - require_link( - links, - "availability.receipt", - &object.availability_receipt_cid, - )?; - if !links.iter().any(|link| link.rel == "provenance") { - return Err(ProviderError::Provider( - "sealed content object requires provenance link".to_string(), - )); - } - Ok(()) -} + let path = request + .get("path") + .and_then(|path| path.as_str()) + .unwrap_or(""); + if let Err(message) = validate_content_path(path) { + return Ok(provider_error("invalid_path", &message)); + } -fn require_link(links: &[ContentObjectLink], rel: &str, cid: &str) -> Result<(), ProviderError> { - if links.iter().any(|link| link.rel == rel && link.cid == cid) { - Ok(()) - } else { - Err(ProviderError::Provider(format!( - "sealed content object requires {rel} link to {cid}" - ))) - } -} + let registry = self.registry()?; + let transfer = ContentFetchTransfer::from_request(request)?; + let result = match self + .fetch_from_local_backend(®istry, cid, path, &transfer) + .await + { + Ok(result) => result, + Err(local_err) + if request.get("local_only").and_then(|value| value.as_bool()) == Some(true) => + { + return Err(local_err); + } + Err(local_err) => match self + .fetch_from_availability_provider(®istry, cid, path, &transfer) + .await + { + Ok(Some(result)) => result, + Ok(None) => return Err(local_err), + Err(availability_err) => { + return Err(ProviderError::Provider(format!( + "{local_err}; availability fetch failed: {availability_err}" + ))) + } + }, + }; -fn validate_linked_cid(value: &str, field: &str) -> Result<(), ProviderError> { - require_field(value, field)?; - cid::Cid::try_from(value) - .map(|_| ()) - .map_err(|err| ProviderError::Provider(format!("invalid sealed object {field}: {err}"))) -} + let receipt_availability = self + .latest_receipt_for_cid(cid) + .transpose()? + .map(|receipt| { + json!({ + "status": receipt.payload.status, + "provider": receipt.payload.provider, + "replicas": receipt.payload.replicas, + "checked_at": receipt.payload.checked_at, + }) + }) + .unwrap_or_else(|| { + json!({ + "status": "unknown", + "provider": "content-provider", + }) + }); + let availability = result.availability.unwrap_or(receipt_availability); -fn require_field(value: &str, field: &str) -> Result<(), ProviderError> { - if value.trim().is_empty() { - Err(ProviderError::Provider(format!( - "sealed content object {field} is required" - ))) - } else { - Ok(()) + let mut response = json!({ + "cid": cid, + "uri": format!("elastos://{cid}"), + "path": path, + "availability": availability, + }); + match result.payload { + ContentFetchPayload::Bytes(data) => { + response["data"] = Value::String(data); + } + ContentFetchPayload::Stream(stream) => { + response["stream"] = stream; + } + } + if let Some(transfer) = result.transfer { + response["transfer"] = transfer; + } + Ok(provider_ok(response)) } -} -fn validate_content_object_link_rel(rel: &str) -> Result<(), ProviderError> { - if rel.len() > 64 { - return Err(ProviderError::Provider( - "content object link rel is too long".into(), - )); - } - if !rel.bytes().all(|b| { - b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_' || b == b'.' - }) { - return Err(ProviderError::Provider( - "content object link rel must use lowercase ASCII, digits, '-', '_', or '.'".into(), - )); - } - Ok(()) -} + async fn fetch_from_local_backend( + &self, + registry: &ProviderRegistry, + cid: &str, + path: &str, + transfer: &ContentFetchTransfer, + ) -> Result { + let mut ipfs_request = json!({ + "op": "cat", + "cid": cid, + }); + if !path.is_empty() { + ipfs_request["path"] = Value::String(path.to_string()); + } -fn sort_directory_entries(files: &mut [Value]) -> Result<(), ProviderError> { - for file in files.iter() { - file.get("path") - .and_then(|path| path.as_str()) - .filter(|path| !path.trim().is_empty()) - .ok_or_else(|| { - ProviderError::Provider("directory publish file is missing path".into()) - })?; + let ipfs_response = self + .invoke_provider_with_fetch_transfer(registry, "ipfs", "cat", ipfs_request, transfer) + .await?; + provider_response_ok(&ipfs_response, "content fetch")?; + Ok(ContentFetchResult { + payload: provider_response_payload(&ipfs_response, "content backend", transfer)?, + availability: None, + transfer: provider_transfer_value(&ipfs_response), + }) } - files.sort_by(|a, b| { - let a = a.get("path").and_then(|path| path.as_str()).unwrap_or(""); - let b = b.get("path").and_then(|path| path.as_str()).unwrap_or(""); - a.cmp(b) - }); - Ok(()) -} - -async fn materialize_data_capsule( - registry: &ProviderRegistry, - cid: &str, - manifest: &elastos_common::CapsuleManifest, - manifest_bytes: &[u8], - capsule_dir: &Path, -) -> anyhow::Result<()> { - let object_manifest_bytes = - fetch_bytes_via_provider(registry, cid, Some(OBJECT_MANIFEST_PATH)) - .await - .map_err(|err| { - anyhow::anyhow!( - "published data capsule {cid} is missing {OBJECT_MANIFEST_PATH}; republish it through content availability: {err}" - ) - })?; - let object_manifest = parse_content_object_manifest(cid, &object_manifest_bytes)?; - - write_materialized_file(capsule_dir, OBJECT_MANIFEST_PATH, &object_manifest_bytes).await?; - let mut saw_capsule_manifest = false; - for file in &object_manifest.files { - validate_content_path(&file.path).map_err(|err| anyhow::anyhow!("{err}"))?; - if file.path == OBJECT_MANIFEST_PATH { - anyhow::bail!("{OBJECT_MANIFEST_PATH} cannot appear inside its own file list"); + async fn fetch_from_availability_provider( + &self, + registry: &ProviderRegistry, + cid: &str, + path: &str, + transfer: &ContentFetchTransfer, + ) -> Result, ProviderError> { + let mut request = json!({ + "op": "fetch", + "cid": cid, + "uri": format!("elastos://{cid}"), + }); + if !path.is_empty() { + request["path"] = Value::String(path.to_string()); } - let bytes = if file.path == "capsule.json" { - saw_capsule_manifest = true; - manifest_bytes.to_vec() - } else { - fetch_bytes_via_provider(registry, cid, Some(&file.path)).await? + let response = match self + .invoke_provider_with_fetch_transfer( + registry, + "availability", + "fetch", + request, + transfer, + ) + .await + { + Ok(response) => response, + Err(ProviderError::NoProvider(_)) => return Ok(None), + Err(err) => return Err(err), }; - verify_content_object_file(cid, file, &bytes)?; - write_materialized_file(capsule_dir, &file.path, &bytes).await?; + if response.get("status").and_then(|status| status.as_str()) == Some("error") { + return Ok(None); + } + let availability = response + .get("data") + .and_then(|data| data.get("availability")) + .cloned(); + Ok(Some(ContentFetchResult { + payload: provider_response_payload(&response, "availability provider", transfer)?, + availability, + transfer: provider_transfer_value(&response), + })) } - if !saw_capsule_manifest { - anyhow::bail!("published data capsule {cid} object manifest is missing capsule.json"); - } + async fn publish(&self, request: &Value) -> Result { + let kind = request.get("kind").and_then(|kind| kind.as_str()); + let pin = request + .get("pin") + .and_then(|pin| pin.as_bool()) + .unwrap_or(true); + let registry = self.registry()?; - let entrypoint_path = capsule_dir.join(&manifest.entrypoint); - if !entrypoint_path.is_file() { + let ipfs_request = match kind { + Some("directory") => { + let files = request + .get("files") + .cloned() + .unwrap_or_else(|| Value::Array(Vec::new())); + if !files.is_array() { + return Ok(provider_error("invalid_request", "files must be an array")); + } + let files = with_directory_object_manifest( + files, + request + .get("object_kind") + .and_then(|value| value.as_str()) + .unwrap_or("directory"), + request.get("object_did").and_then(|value| value.as_str()), + request + .get("publisher_did") + .and_then(|value| value.as_str()), + request.get("links"), + )?; + json!({ + "op": "add_directory", + "files": files, + "pin": pin, + }) + } + Some("file") => { + let data = request + .get("data") + .and_then(|data| data.as_str()) + .filter(|data| !data.trim().is_empty()) + .ok_or_else(|| { + ProviderError::Provider("content file publish requires data".into()) + })?; + let filename = request + .get("filename") + .and_then(|filename| filename.as_str()) + .filter(|filename| !filename.trim().is_empty()) + .unwrap_or("content.bin"); + json!({ + "op": "add_bytes", + "data": data, + "filename": filename, + "pin": pin, + }) + } + Some(_) | None => { + return Ok(provider_error( + "unsupported_content_kind", + "content publish supports kind=directory or kind=file", + )); + } + }; + + let ipfs_op = ipfs_request + .get("op") + .and_then(|value| value.as_str()) + .unwrap_or("publish") + .to_string(); + let accounting_observation = content_accounting_observation_from_publish_request(request); + let requirements = AvailabilityRequirements::from_request(request); + let object_did = request + .get("object_did") + .and_then(|value| value.as_str()) + .map(str::to_string); + let publisher_did = request + .get("publisher_did") + .and_then(|value| value.as_str()) + .map(str::to_string); + let publisher_did = Some(self.effective_publisher_did(publisher_did.as_deref())?); + let storage_quota = self.principal_storage_quota_for_request( + publisher_did.as_deref().unwrap_or_default(), + &requirements, + if pin { + accounting_observation.bytes + } else { + Some(0) + }, + None, + )?; + if storage_quota.get("status").and_then(|value| value.as_str()) == Some("quota_exceeded") { + return Ok(provider_error( + "storage_quota_exceeded", + "content publish exceeds the principal storage quota", + )); + } + let ipfs_response = self + .invoke_provider( + ®istry, + "ipfs", + &ipfs_op, + ipfs_request, + ProviderTransfer::Bytes, + ) + .await?; + let cid = provider_response_cid(&ipfs_response)?; + let local_outcome = AvailabilityOutcome::local_publish(pin); + let outcome = if pin { + self.ensure_network_availability( + ®istry, + &cid, + request, + &local_outcome, + AvailabilityRequestContext { + object_did: object_did.as_deref(), + publisher_did: publisher_did.as_deref(), + accounting_observation, + }, + ) + .await? + .unwrap_or(local_outcome) + } else { + local_outcome + }; + let receipt = self.write_receipt(ReceiptInput { + cid: cid.clone(), + object_did, + publisher_did, + provider: outcome.provider.clone(), + policy: outcome.policy.clone(), + status: outcome.status.clone(), + replicas: outcome.replicas, + peer_selection: outcome.peer_selection.clone(), + quota: outcome.quota.clone(), + repair_worker: outcome.repair_worker.clone(), + storage_market: outcome.storage_market.clone(), + repair_graph: outcome.repair_graph.clone(), + abuse_controls: outcome.abuse_controls.clone(), + accounting: content_accounting_json_with_storage_quota( + "publish_request", + accounting_observation, + outcome.replicas, + storage_quota, + ), + })?; + let repair_task = self.record_repair_task(&receipt, &outcome, requirements, false)?; + + Ok(provider_ok(json!({ + "cid": cid, + "uri": format!("elastos://{cid}"), + "availability": outcome.to_json(), + "repair_task": repair_task, + "receipt": receipt, + }))) + } + + async fn admission(&self, request: &Value) -> Result { + let cid = request + .get("cid") + .and_then(|cid| cid.as_str()) + .filter(|cid| !cid.trim().is_empty()) + .ok_or_else(|| ProviderError::Provider("content admission requires cid".into()))?; + if !is_valid_cid(cid) { + return Ok(provider_error( + "invalid_cid", + "content admission requires a valid CID", + )); + } + + let requirements = AvailabilityRequirements::from_request(request); + let publisher_did = request + .get("publisher_did") + .and_then(|value| value.as_str()) + .map(str::to_string); + let publisher_did = self.effective_publisher_did(publisher_did.as_deref())?; + let incoming_content_bytes = admission_content_bytes_from_request(request); + let mut storage_quota = if requirements.max_storage_bytes_per_principal.is_some() + && incoming_content_bytes.is_none() + { + json!({ + "schema": CONTENT_STORAGE_QUOTA_SCHEMA, + "policy": "principal_storage_quota", + "scope": "content-availability", + "enforced": true, + "status": "known_size_required", + "principal_did": publisher_did, + "reason": "remote admission with a storage quota requires estimated content bytes before transfer", + }) + } else { + self.principal_storage_quota_for_request( + &publisher_did, + &requirements, + incoming_content_bytes, + Some(cid), + )? + }; + let quota_status = storage_quota + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + let accepted = !matches!(quota_status, "quota_exceeded" | "known_size_required"); + let reason = match quota_status { + "quota_exceeded" => { + Some("remote content replica would exceed the principal storage quota") + } + "known_size_required" => Some("remote content admission requires known content bytes"), + _ => None, + }; + + let mut admission = json!({ + "schema": CONTENT_ADMISSION_SCHEMA, + "policy": "content_provider_principal_quota_preflight", + "scope": "content-availability", + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "reason": reason, + "cid": cid, + "publisher_did": publisher_did, + "estimated_content_bytes": incoming_content_bytes, + "quota": storage_quota, + "checked_at": now_unix_secs(), + "app_visible": false, + }); + if accepted { + if let Some(federated_abuse_control_exchange) = &self.federated_abuse_control_exchange { + let signed_exchange_request = + self.sign_federated_abuse_control_exchange_request(&admission, request)?; + let abuse_control_exchange = federated_abuse_control_exchange + .exchange(&signed_exchange_request) + .await + .unwrap_or_else(|err| { + federated_abuse_control_exchange_unavailable( + err, + federated_abuse_control_exchange, + ) + }); + if abuse_control_exchange + .get("accepted") + .and_then(|value| value.as_bool()) + != Some(true) + { + admission["accepted"] = Value::Bool(false); + admission["status"] = Value::String("rejected".to_string()); + let abuse_reason = abuse_control_exchange + .get("reason") + .and_then(|value| value.as_str()) + .unwrap_or("federated abuse control rejected admission"); + admission["reason"] = Value::String(format!( + "federated abuse control rejected admission: {abuse_reason}" + )); + } + admission["federated_abuse_control_exchange"] = abuse_control_exchange; + } + } + if admission.get("accepted").and_then(|value| value.as_bool()) == Some(true) { + if let Some(federated_quota_ledger_exchange) = &self.federated_quota_ledger_exchange { + let signed_exchange_request = + self.sign_federated_quota_ledger_exchange_request(&admission, request)?; + let quota_ledger_exchange = federated_quota_ledger_exchange + .exchange(&signed_exchange_request) + .await + .unwrap_or_else(|err| { + federated_quota_ledger_exchange_unavailable( + err, + federated_quota_ledger_exchange, + ) + }); + let quota_ledger_policy = federated_quota_ledger_policy_from_exchange( + &storage_quota, + "a_ledger_exchange, + ); + if let Some(quota) = storage_quota.as_object_mut() { + quota.insert( + "federated_quota_ledger_policy".to_string(), + quota_ledger_policy, + ); + } + admission["quota"] = storage_quota.clone(); + if quota_ledger_exchange + .get("accepted") + .and_then(|value| value.as_bool()) + != Some(true) + { + admission["accepted"] = Value::Bool(false); + admission["status"] = Value::String("rejected".to_string()); + let quota_reason = quota_ledger_exchange + .get("reason") + .and_then(|value| value.as_str()) + .unwrap_or("federated quota ledger rejected admission"); + admission["reason"] = Value::String(format!( + "federated quota ledger rejected admission: {quota_reason}" + )); + } + admission["federated_quota_ledger_exchange"] = quota_ledger_exchange; + } + } + if admission.get("accepted").and_then(|value| value.as_bool()) == Some(true) { + if let Some(storage_market_admission) = &self.storage_market_admission { + let market_request = storage_market_admission_request(&admission, request); + let market_decision = storage_market_admission + .decide(&market_request) + .await + .unwrap_or_else(storage_market_admission_unavailable); + if market_decision + .get("accepted") + .and_then(|value| value.as_bool()) + != Some(true) + { + admission["accepted"] = Value::Bool(false); + admission["status"] = Value::String("rejected".to_string()); + let market_reason = market_decision + .get("reason") + .and_then(|value| value.as_str()) + .unwrap_or("external storage market rejected admission"); + admission["reason"] = Value::String(format!( + "storage market admission rejected: {market_reason}" + )); + } + admission["storage_market_admission"] = market_decision; + } + } + let receipt = self.sign_admission_receipt(&admission)?; + + Ok(provider_ok(json!({ + "cid": cid, + "admission": admission, + "receipt": receipt, + }))) + } + + async fn import_exact(&self, request: &Value) -> Result { + validate_import_exact_invocation(request)?; + let cid = request + .get("cid") + .and_then(|cid| cid.as_str()) + .filter(|cid| !cid.trim().is_empty()) + .ok_or_else(|| ProviderError::Provider("content import_exact requires cid".into()))?; + if !is_valid_cid(cid) { + return Ok(provider_error( + "invalid_cid", + "content import_exact requires a valid CID", + )); + } + let bytes = import_exact_payload_bytes(request)?; + let requirements = AvailabilityRequirements::from_request(request); + let publisher_did = request + .get("publisher_did") + .and_then(|value| value.as_str()) + .map(str::to_string); + let publisher_did = Some(self.effective_publisher_did(publisher_did.as_deref())?); + let storage_quota = self.principal_storage_quota_for_request( + publisher_did.as_deref().unwrap_or_default(), + &requirements, + Some(bytes.len() as u64), + Some(cid), + )?; + if storage_quota.get("status").and_then(|value| value.as_str()) == Some("quota_exceeded") { + return Ok(provider_error( + "storage_quota_exceeded", + "content import_exact exceeds the principal storage quota", + )); + } + let filename = request + .get("filename") + .and_then(|filename| filename.as_str()) + .filter(|filename| !filename.trim().is_empty()) + .unwrap_or("content.bin"); + let registry = self.registry()?; + let ipfs_response = self + .invoke_provider( + ®istry, + "ipfs", + "add_bytes", + json!({ + "op": "add_bytes", + "data": base64::engine::general_purpose::STANDARD.encode(&bytes), + "filename": filename, + "pin": true, + }), + ProviderTransfer::Bytes, + ) + .await?; + provider_response_ok(&ipfs_response, "content import_exact")?; + let imported_cid = provider_response_cid(&ipfs_response).map_err(|err| { + ProviderError::Provider(format!("content import_exact missing imported CID: {err}")) + })?; + if imported_cid != cid { + let _ = self + .invoke_provider( + ®istry, + "ipfs", + "unpin", + json!({ + "op": "unpin", + "cid": imported_cid, + }), + ProviderTransfer::Json, + ) + .await; + return Ok(provider_error( + "cid_mismatch", + "content import_exact produced a different CID; exact-CID import requires block-level compatible bytes", + )); + } + + let outcome = AvailabilityOutcome { + provider: "ipfs-provider".to_string(), + policy: "carrier_exact_import".to_string(), + status: "local_pinned".to_string(), + replicas: 1, + reason: None, + peer_selection: local_peer_selection_json(), + quota: local_quota_json(), + repair_worker: repair_worker_json(false), + storage_market: local_storage_market_json(), + repair_graph: local_repair_graph_json(), + abuse_controls: local_abuse_controls_json(), + }; + let object_did = request + .get("object_did") + .and_then(|value| value.as_str()) + .map(str::to_string); + let receipt = self.write_receipt(ReceiptInput { + cid: cid.to_string(), + object_did, + publisher_did, + provider: outcome.provider.clone(), + policy: outcome.policy.clone(), + status: outcome.status.clone(), + replicas: outcome.replicas, + peer_selection: outcome.peer_selection.clone(), + quota: outcome.quota.clone(), + repair_worker: outcome.repair_worker.clone(), + storage_market: outcome.storage_market.clone(), + repair_graph: outcome.repair_graph.clone(), + abuse_controls: outcome.abuse_controls.clone(), + accounting: content_accounting_json_with_storage_quota( + "carrier_exact_import", + ContentAccountingObservation { + files: Some(1), + bytes: Some(bytes.len() as u64), + }, + outcome.replicas, + storage_quota, + ), + })?; + let repair_task = self.record_repair_task(&receipt, &outcome, requirements, false)?; + + Ok(provider_ok(json!({ + "cid": cid, + "uri": format!("elastos://{cid}"), + "availability": outcome.to_json(), + "repair_task": repair_task, + "receipt": receipt, + "import": { + "schema": "elastos.content.import-exact/v1", + "method": "carrier_provider_stream", + "bytes": bytes.len(), + "verified_cid": true, + } + }))) + } + + async fn import_object(&self, request: &Value) -> Result { + validate_import_object_invocation(request)?; + let cid = request + .get("cid") + .and_then(|cid| cid.as_str()) + .filter(|cid| !cid.trim().is_empty()) + .ok_or_else(|| ProviderError::Provider("content import_object requires cid".into()))?; + if !is_valid_cid(cid) { + return Ok(provider_error( + "invalid_cid", + "content import_object requires a valid CID", + )); + } + let files = request + .get("files") + .cloned() + .unwrap_or_else(|| Value::Array(Vec::new())); + let (file_count, total_bytes) = validate_import_object_payload_bounds(&files)?; + let requirements = AvailabilityRequirements::from_request(request); + let publisher_did = request + .get("publisher_did") + .and_then(|value| value.as_str()) + .map(str::to_string); + let publisher_did = Some(self.effective_publisher_did(publisher_did.as_deref())?); + let storage_quota = self.principal_storage_quota_for_request( + publisher_did.as_deref().unwrap_or_default(), + &requirements, + Some(total_bytes as u64), + Some(cid), + )?; + if storage_quota.get("status").and_then(|value| value.as_str()) == Some("quota_exceeded") { + return Ok(provider_error( + "storage_quota_exceeded", + "content import_object exceeds the principal storage quota", + )); + } + let object_kind = request + .get("object_kind") + .or_else(|| request.get("kind")) + .and_then(|value| value.as_str()) + .unwrap_or("directory"); + let files = with_directory_object_manifest( + files, + object_kind, + request.get("object_did").and_then(|value| value.as_str()), + request + .get("publisher_did") + .and_then(|value| value.as_str()), + request.get("links"), + )?; + let registry = self.registry()?; + let ipfs_response = self + .invoke_provider( + ®istry, + "ipfs", + "add_directory", + json!({ + "op": "add_directory", + "files": files, + "pin": true, + }), + ProviderTransfer::Json, + ) + .await?; + provider_response_ok(&ipfs_response, "content import_object")?; + let imported_cid = provider_response_cid(&ipfs_response).map_err(|err| { + ProviderError::Provider(format!("content import_object missing imported CID: {err}")) + })?; + if imported_cid != cid { + let _ = self + .invoke_provider( + ®istry, + "ipfs", + "unpin", + json!({ + "op": "unpin", + "cid": imported_cid, + }), + ProviderTransfer::Json, + ) + .await; + return Ok(provider_error( + "cid_mismatch", + "content import_object produced a different CID; exact object import requires matching manifest and bytes", + )); + } + + let outcome = AvailabilityOutcome { + provider: "ipfs-provider".to_string(), + policy: "carrier_object_import".to_string(), + status: "local_pinned".to_string(), + replicas: 1, + reason: None, + peer_selection: local_peer_selection_json(), + quota: local_quota_json(), + repair_worker: repair_worker_json(false), + storage_market: local_storage_market_json(), + repair_graph: local_repair_graph_json(), + abuse_controls: local_abuse_controls_json(), + }; + let object_did = request + .get("object_did") + .and_then(|value| value.as_str()) + .map(str::to_string); + let receipt = self.write_receipt(ReceiptInput { + cid: cid.to_string(), + object_did, + publisher_did, + provider: outcome.provider.clone(), + policy: outcome.policy.clone(), + status: outcome.status.clone(), + replicas: outcome.replicas, + peer_selection: outcome.peer_selection.clone(), + quota: outcome.quota.clone(), + repair_worker: outcome.repair_worker.clone(), + storage_market: outcome.storage_market.clone(), + repair_graph: outcome.repair_graph.clone(), + abuse_controls: outcome.abuse_controls.clone(), + accounting: content_accounting_json_with_storage_quota( + "carrier_object_import", + ContentAccountingObservation { + files: Some(file_count as u64), + bytes: Some(total_bytes as u64), + }, + outcome.replicas, + storage_quota, + ), + })?; + let repair_task = self.record_repair_task(&receipt, &outcome, requirements, false)?; + + Ok(provider_ok(json!({ + "cid": cid, + "uri": format!("elastos://{cid}"), + "availability": outcome.to_json(), + "repair_task": repair_task, + "receipt": receipt, + "import": { + "schema": "elastos.content.import-object/v1", + "method": "carrier_provider_object_manifest", + "files": file_count, + "bytes": total_bytes, + "verified_cid": true, + } + }))) + } + + async fn unpublish(&self, request: &Value) -> Result { + let cid = request + .get("cid") + .and_then(|cid| cid.as_str()) + .filter(|cid| !cid.trim().is_empty()) + .ok_or_else(|| ProviderError::Provider("content unpublish requires cid".into()))?; + if !is_valid_cid(cid) { + return Ok(provider_error( + "invalid_cid", + "content unpublish requires a valid CID", + )); + } + + let registry = self.registry()?; + let ipfs_response = self + .invoke_provider( + ®istry, + "ipfs", + "unpin", + json!({ + "op": "unpin", + "cid": cid, + }), + ProviderTransfer::Json, + ) + .await?; + provider_response_ok(&ipfs_response, "content unpublish")?; + let previous_receipt = self.latest_receipt_for_cid(cid).transpose()?; + let outcome = AvailabilityOutcome { + provider: "ipfs-provider".to_string(), + policy: "local_unpublish".to_string(), + status: "local_unpinned".to_string(), + replicas: 0, + reason: None, + peer_selection: local_peer_selection_json(), + quota: local_quota_json(), + repair_worker: repair_worker_json(false), + storage_market: local_storage_market_json(), + repair_graph: local_repair_graph_json(), + abuse_controls: local_abuse_controls_json(), + }; + let receipt = self.write_receipt(ReceiptInput { + cid: cid.to_string(), + object_did: request + .get("object_did") + .and_then(|value| value.as_str()) + .map(str::to_string) + .or_else(|| { + previous_receipt + .as_ref() + .and_then(|receipt| receipt.payload.object_did.clone()) + }), + publisher_did: request + .get("publisher_did") + .and_then(|value| value.as_str()) + .map(str::to_string) + .or_else(|| { + previous_receipt + .as_ref() + .map(|receipt| receipt.payload.publisher_did.clone()) + }), + provider: outcome.provider.clone(), + policy: outcome.policy.clone(), + status: outcome.status.clone(), + replicas: outcome.replicas, + peer_selection: outcome.peer_selection.clone(), + quota: outcome.quota.clone(), + repair_worker: outcome.repair_worker.clone(), + storage_market: outcome.storage_market.clone(), + repair_graph: outcome.repair_graph.clone(), + abuse_controls: outcome.abuse_controls.clone(), + accounting: self.content_accounting_from_previous_or_unknown( + cid, + "local_unpublish", + outcome.replicas, + )?, + })?; + let repair_task = self.record_repair_task( + &receipt, + &outcome, + AvailabilityRequirements::from_request(request), + false, + )?; + + Ok(provider_ok(json!({ + "cid": cid, + "uri": format!("elastos://{cid}"), + "availability": outcome.to_json(), + "repair_task": repair_task, + "receipt": receipt, + }))) + } + + async fn ensure(&self, request: &Value) -> Result { + self.pin_for_availability(request, "local_ensure_pin", "local_ensure_failed", false) + .await + } + + async fn repair(&self, request: &Value) -> Result { + self.pin_for_availability(request, "local_repair_pin", "local_repair_failed", false) + .await + } + + async fn pin_for_availability( + &self, + request: &Value, + success_policy: &str, + failure_policy: &str, + repair_worker_attempt: bool, + ) -> Result { + let cid = request + .get("cid") + .and_then(|cid| cid.as_str()) + .filter(|cid| !cid.trim().is_empty()) + .ok_or_else(|| ProviderError::Provider("content repair requires cid".into()))?; + if !is_valid_cid(cid) { + return Ok(provider_error( + "invalid_cid", + "content repair requires a valid CID", + )); + } + + let registry = self.registry()?; + let ipfs_response = self + .invoke_provider( + ®istry, + "ipfs", + "pin", + json!({ + "op": "pin", + "cid": cid, + }), + ProviderTransfer::Json, + ) + .await?; + + let (status, policy, replicas, reason) = if ipfs_response + .get("status") + .and_then(|status| status.as_str()) + == Some("error") + { + ( + "repair_needed", + failure_policy, + 0, + ipfs_response + .get("message") + .and_then(|message| message.as_str()) + .map(str::to_string), + ) + } else { + ("local_pinned", success_policy, 1, None) + }; + + let local_outcome = AvailabilityOutcome { + provider: "ipfs-provider".to_string(), + policy: policy.to_string(), + status: status.to_string(), + replicas, + reason, + peer_selection: local_peer_selection_json(), + quota: local_quota_json(), + repair_worker: repair_worker_json(status == "repair_needed"), + storage_market: local_storage_market_json(), + repair_graph: local_repair_graph_json(), + abuse_controls: local_abuse_controls_json(), + }; + let object_did = request + .get("object_did") + .and_then(|value| value.as_str()) + .map(str::to_string); + let publisher_did = request + .get("publisher_did") + .and_then(|value| value.as_str()) + .map(str::to_string); + let accounting_observation = self + .latest_receipt_for_cid(cid) + .transpose()? + .map(|receipt| content_accounting_observation_from_value(&receipt.payload.accounting)) + .unwrap_or_default(); + let outcome = if local_outcome.status == "local_pinned" { + self.ensure_network_availability( + ®istry, + cid, + request, + &local_outcome, + AvailabilityRequestContext { + object_did: object_did.as_deref(), + publisher_did: publisher_did.as_deref(), + accounting_observation, + }, + ) + .await? + .unwrap_or(local_outcome) + } else { + local_outcome + }; + + let receipt = self.write_receipt(ReceiptInput { + cid: cid.to_string(), + object_did, + publisher_did, + provider: outcome.provider.clone(), + policy: outcome.policy.clone(), + status: outcome.status.clone(), + replicas: outcome.replicas, + peer_selection: outcome.peer_selection.clone(), + quota: outcome.quota.clone(), + repair_worker: outcome.repair_worker.clone(), + storage_market: outcome.storage_market.clone(), + repair_graph: outcome.repair_graph.clone(), + abuse_controls: outcome.abuse_controls.clone(), + accounting: self.content_accounting_from_previous_or_unknown( + cid, + if repair_worker_attempt { + "repair_worker" + } else { + outcome.policy.as_str() + }, + outcome.replicas, + )?, + })?; + let repair_task = self.record_repair_task( + &receipt, + &outcome, + AvailabilityRequirements::from_request(request), + repair_worker_attempt, + )?; + + Ok(provider_ok(json!({ + "cid": cid, + "uri": format!("elastos://{cid}"), + "availability": outcome.to_json(), + "repair_task": repair_task, + "receipt": receipt, + }))) + } + + async fn ensure_network_availability( + &self, + registry: &ProviderRegistry, + cid: &str, + request: &Value, + local: &AvailabilityOutcome, + context: AvailabilityRequestContext<'_>, + ) -> Result, ProviderError> { + let policy = request + .get("availability_policy") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("network_default"); + let requirements = AvailabilityRequirements::from_request(request); + let mut availability_request = json!({ + "op": "ensure", + "cid": cid, + "uri": format!("elastos://{cid}"), + "policy": policy, + "local": local.to_json(), + "requirements": requirements.to_json(), + "accounting": content_accounting_json( + "availability_admission_estimate", + context.accounting_observation, + local.replicas, + ), + }); + if let Some(content_bytes) = context.accounting_observation.bytes { + availability_request["estimated_content_bytes"] = Value::from(content_bytes); + } + if let Some(object_did) = context.object_did { + availability_request["object_did"] = Value::String(object_did.to_string()); + } + if let Some(publisher_did) = context.publisher_did { + availability_request["publisher_did"] = Value::String(publisher_did.to_string()); + } + + match self + .invoke_provider( + registry, + "availability", + "ensure", + availability_request, + ProviderTransfer::Json, + ) + .await + { + Ok(response) => Ok(Some(parse_availability_provider_response( + &response, + policy, + local, + &requirements, + ))), + Err(ProviderError::NoProvider(_)) => Ok(None), + Err(err) => Ok(Some(AvailabilityOutcome::repair_needed( + "availability-provider", + policy, + local.replicas, + err.to_string(), + ))), + } + } + + async fn run_repair_worker(&self, request: &Value) -> Result { + validate_repair_worker_invocation(request)?; + let force = request + .get("force") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let include_healthy_check = request + .get("include_healthy_check") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let limit = request + .get("limit") + .and_then(|value| value.as_u64()) + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(REPAIR_WORKER_DEFAULT_LIMIT) + .clamp(1, REPAIR_WORKER_MAX_LIMIT); + let max_attempts = request + .get("max_attempts") + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(REPAIR_WORKER_DEFAULT_MAX_ATTEMPTS) + .clamp(1, REPAIR_WORKER_MAX_ATTEMPTS_LIMIT); + let failure_budget = request + .get("failure_budget") + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(REPAIR_WORKER_DEFAULT_FAILURE_BUDGET) + .clamp(1, REPAIR_WORKER_MAX_FAILURE_BUDGET); + let now = now_unix_secs(); + let tasks = self.latest_repair_tasks()?; + let total_tasks = tasks.len(); + let mut checked = 0_u32; + let mut repaired = 0_u32; + let mut failed = 0_u32; + let mut skipped = 0_u32; + let mut exhausted_attempts_skipped = 0_u32; + let mut throttled = false; + let mut external_dispatches = 0_u32; + let mut external_dispatch_accepted = 0_u32; + let mut external_dispatch_failed = 0_u32; + let mut results = Vec::new(); + + for task in tasks { + if results.len() >= limit { + skipped = skipped.saturating_add(1); + continue; + } + if !task.is_repair_candidate(include_healthy_check) { + skipped = skipped.saturating_add(1); + continue; + } + if !force && !task.is_due(now) { + skipped = skipped.saturating_add(1); + continue; + } + if task.attempts >= max_attempts { + skipped = skipped.saturating_add(1); + exhausted_attempts_skipped = exhausted_attempts_skipped.saturating_add(1); + continue; + } + if failed >= failure_budget { + skipped = skipped.saturating_add(1); + throttled = true; + continue; + } + + checked = checked.saturating_add(1); + let external_dispatch = if let Some(external_repair_fleet) = &self.external_repair_fleet + { + external_dispatches = external_dispatches.saturating_add(1); + let dispatch_request = external_repair_fleet_dispatch_request(&task, now); + let receipt = external_repair_fleet + .dispatch(&dispatch_request) + .await + .unwrap_or_else(external_repair_fleet_dispatch_failed); + if receipt.get("accepted").and_then(|value| value.as_bool()) == Some(true) { + external_dispatch_accepted = external_dispatch_accepted.saturating_add(1); + } else { + external_dispatch_failed = external_dispatch_failed.saturating_add(1); + } + Some(receipt) + } else { + None + }; + let mut repair_request = json!({ + "op": "repair", + "cid": task.cid, + "availability_policy": task.policy, + "availability_requirements": task.requirements, + }); + if let Some(object_did) = task.object_did { + repair_request["object_did"] = Value::String(object_did); + } + if let Some(publisher_did) = task.publisher_did { + repair_request["publisher_did"] = Value::String(publisher_did); + } + + match self + .pin_for_availability( + &repair_request, + "local_repair_pin", + "local_repair_failed", + true, + ) + .await + { + Ok(response) + if response.get("status").and_then(|value| value.as_str()) == Some("ok") => + { + let availability = response + .get("data") + .and_then(|data| data.get("availability")) + .cloned() + .unwrap_or_else(|| json!({"status": "unknown"})); + let status = availability + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + .to_string(); + if status != "repair_needed" { + repaired = repaired.saturating_add(1); + } else { + failed = failed.saturating_add(1); + } + results.push(json!({ + "cid": repair_request["cid"], + "status": status, + "availability": availability, + "external_repair_fleet_dispatch": external_dispatch, + })); + } + Ok(response) => { + failed = failed.saturating_add(1); + results.push(json!({ + "cid": repair_request["cid"], + "status": "failed", + "response": response, + "external_repair_fleet_dispatch": external_dispatch, + })); + } + Err(err) => { + failed = failed.saturating_add(1); + results.push(json!({ + "cid": repair_request["cid"], + "status": "failed", + "message": err.to_string(), + "external_repair_fleet_dispatch": external_dispatch, + })); + } + } + } + + Ok(provider_ok(json!({ + "schema": REPAIR_WORKER_RUN_SCHEMA, + "total_tasks": total_tasks, + "checked": checked, + "repaired": repaired, + "failed": failed, + "skipped": skipped, + "quota": { + "policy": "content_repair_worker_guardrail", + "scope": "content-availability", + "limit": limit, + "max_limit": REPAIR_WORKER_MAX_LIMIT, + "max_attempts": max_attempts, + "failure_budget": failure_budget, + }, + "abuse_controls": { + "schema": REPAIR_WORKER_ABUSE_CONTROLS_SCHEMA, + "runtime_invocation_required": true, + "app_visible": false, + "force_due_override": force, + "exhausted_attempts_skipped": exhausted_attempts_skipped, + "throttled": throttled, + }, + "network_abuse_policy": network_abuse_policy_run_json( + checked, + failed, + exhausted_attempts_skipped, + throttled, + ), + "repair_fleet": repair_fleet_run_json( + checked, + repaired, + failed, + skipped, + exhausted_attempts_skipped, + throttled, + ), + "external_repair_fleet_policy": external_repair_fleet_run_policy_json( + ExternalRepairFleetRunSummary { + checked, + repaired, + failed, + skipped, + exhausted_attempts_skipped, + throttled, + external_dispatches, + external_dispatch_accepted, + external_dispatch_failed, + }, + self.external_repair_fleet.as_ref(), + ), + "results": results, + }))) + } + + async fn status(&self, request: &Value) -> Result { + if let Some(cid) = request.get("cid").and_then(|cid| cid.as_str()) { + if !is_valid_cid(cid) { + return Ok(provider_error( + "invalid_cid", + "content status requires a valid CID", + )); + } + if let Some(receipt) = self.latest_receipt_for_cid(cid) { + let receipt = receipt?; + let repair_task = self.latest_repair_task_for_cid(cid).transpose()?; + let mut availability = json!({ + "status": receipt.payload.status, + "provider": receipt.payload.provider, + "policy": receipt.payload.policy, + "replicas": receipt.payload.replicas, + "peer_selection": receipt.payload.peer_selection, + "quota": receipt.payload.quota, + "federated_quota_ledger_policy": federated_quota_ledger_policy_from_quota_json( + &receipt.payload.quota, + false, + ), + "repair_worker": receipt.payload.repair_worker, + "storage_market": receipt.payload.storage_market, + "storage_settlement_policy": storage_settlement_policy_from_market_json( + &receipt.payload.storage_market, + ), + "storage_market_admission_policy": storage_market_admission_policy_from_market_json( + &receipt.payload.storage_market, + ), + "repair_graph": receipt.payload.repair_graph, + "abuse_controls": receipt.payload.abuse_controls, + "network_abuse_policy": network_abuse_policy_for_availability_json( + &receipt.payload.abuse_controls, + &receipt.payload.peer_selection, + ), + "accounting": receipt.payload.accounting, + "checked_at": receipt.payload.checked_at, + }); + if let Some(repair_task) = repair_task { + availability["repair_task"] = + serde_json::to_value(repair_task).map_err(|err| { + ProviderError::Provider(format!( + "content repair task encode failed: {err}" + )) + })?; + } + if let Some(storage_accounting) = self + .latest_storage_accounting_entry_for_cid(cid) + .transpose()? + { + availability["storage_accounting"] = serde_json::to_value(storage_accounting) + .map_err(|err| { + ProviderError::Provider(format!( + "content storage accounting encode failed: {err}" + )) + })?; + } + return Ok(provider_ok(json!({ + "cid": receipt.payload.cid, + "uri": receipt.payload.uri, + "availability": availability, + "receipt": receipt, + }))); + } + } + + let emit_operator_alert = request + .get("emit_operator_alert") + .or_else(|| request.get("emit_operator_alerts")) + .and_then(|value| value.as_bool()) + .unwrap_or(false); + self.availability_dashboard(emit_operator_alert).await + } + + fn write_receipt( + &self, + input: ReceiptInput, + ) -> Result { + let (signing_key, default_did) = elastos_identity::load_or_create_did(&self.data_dir) + .map_err(|err| { + ProviderError::Provider(format!("content receipt signer unavailable: {err}")) + })?; + let publisher_did = input.publisher_did.unwrap_or(default_did); + let receipt = AvailabilityReceipt { + schema: AVAILABILITY_RECEIPT_SCHEMA.to_string(), + cid: input.cid.clone(), + uri: format!("elastos://{}", input.cid), + object_did: input.object_did, + publisher_did, + provider: input.provider, + policy: input.policy, + status: input.status, + replicas: input.replicas, + peer_selection: input.peer_selection, + quota: input.quota, + repair_worker: input.repair_worker, + storage_market: input.storage_market, + repair_graph: input.repair_graph, + abuse_controls: input.abuse_controls, + accounting: input.accounting, + checked_at: now_unix_secs(), + }; + let payload_value = serde_json::to_value(&receipt).map_err(|err| { + ProviderError::Provider(format!("content receipt encode failed: {err}")) + })?; + let payload = serde_json::to_string(&payload_value).map_err(|err| { + ProviderError::Provider(format!("content receipt encode failed: {err}")) + })?; + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &signing_key, + AVAILABILITY_RECEIPT_DOMAIN, + payload.as_bytes(), + ); + let signed = SignedAvailabilityReceipt { + payload: receipt, + signature, + signer_did, + }; + append_jsonl(&self.receipts_path(), &signed)?; + self.record_storage_accounting(&signed.payload)?; + Ok(signed) + } + + fn record_storage_accounting( + &self, + receipt: &AvailabilityReceipt, + ) -> Result { + let entry = content_storage_accounting_entry_from_receipt(receipt); + append_jsonl(&self.storage_accounting_path(), &entry)?; + Ok(entry) + } + + fn latest_receipt_for_cid( + &self, + cid: &str, + ) -> Option> { + match self.latest_receipts() { + Ok(receipts) => receipts + .into_iter() + .find(|receipt| receipt.payload.cid == cid) + .map(Ok), + Err(err) => Some(Err(err)), + } + } + + fn latest_receipts(&self) -> Result, ProviderError> { + let path = self.receipts_path(); + if !path.exists() { + return Ok(Vec::new()); + } + + let file = std::fs::File::open(&path)?; + let reader = std::io::BufReader::new(file); + let mut latest = BTreeMap::new(); + for line in reader.lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let receipt: SignedAvailabilityReceipt = + serde_json::from_str(&line).map_err(|err| { + ProviderError::Provider(format!("content receipt ledger decode failed: {err}")) + })?; + verify_signed_receipt(&receipt)?; + latest.insert(receipt.payload.cid.clone(), receipt); + } + Ok(latest.into_values().collect()) + } + + fn latest_storage_accounting_entry_for_cid( + &self, + cid: &str, + ) -> Option> { + match self.latest_storage_accounting_entries() { + Ok(entries) => entries.into_iter().find(|entry| entry.cid == cid).map(Ok), + Err(err) => Some(Err(err)), + } + } + + fn latest_storage_accounting_entries( + &self, + ) -> Result, ProviderError> { + let path = self.storage_accounting_path(); + if !path.exists() { + return Ok(Vec::new()); + } + + let file = std::fs::File::open(&path)?; + let reader = std::io::BufReader::new(file); + let mut latest = BTreeMap::new(); + for line in reader.lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let entry: ContentStorageAccountingEntry = + serde_json::from_str(&line).map_err(|err| { + ProviderError::Provider(format!( + "content storage accounting ledger decode failed: {err}" + )) + })?; + if entry.schema != CONTENT_STORAGE_ACCOUNTING_ENTRY_SCHEMA { + return Err(ProviderError::Provider(format!( + "content storage accounting ledger schema mismatch: {}", + entry.schema + ))); + } + latest.insert(entry.cid.clone(), entry); + } + Ok(latest.into_values().collect()) + } + + async fn availability_dashboard( + &self, + emit_operator_alert: bool, + ) -> Result { + let receipts = self.latest_receipts()?; + let tasks = self.latest_repair_tasks()?; + let storage_accounting_ledger = self.storage_accounting_ledger_status()?; + let storage_accounting_entries = self.latest_storage_accounting_entries()?; + let mut by_status: BTreeMap = BTreeMap::new(); + let mut by_provider: BTreeMap = BTreeMap::new(); + let mut by_quota_status: BTreeMap = BTreeMap::new(); + let mut total_replicas = 0_u32; + let mut latest_checked_at = 0_u64; + let mut quota_enforced = 0_u32; + let mut quota_requirements_exceeded = 0_u32; + let mut live_multi_peer_proofs = 0_u32; + let mut remote_replicas = 0_u32; + let mut remote_receipts = 0_u32; + let mut verified_remote_receipts = 0_u32; + let mut recent_remote_replicas = Vec::new(); + let mut peer_reputation_by_status: BTreeMap = BTreeMap::new(); + let mut peer_reputation_local_history_applied = 0_u32; + let mut peer_reputation_federated = 0_u32; + let mut accounted_objects = 0_u32; + let mut accounted_files = 0_u64; + let mut accounted_content_bytes = 0_u64; + let mut accounted_replica_bytes = 0_u64; + let mut storage_quota_enforced = 0_u32; + let mut by_accounting_source: BTreeMap = BTreeMap::new(); + let mut abuse_controls_enforced = 0_u32; + let mut abuse_controls_throttled = 0_u32; + let mut abuse_attempted_operations = 0_u64; + let mut abuse_failed_operations = 0_u64; + let mut by_abuse_policy: BTreeMap = BTreeMap::new(); + for receipt in &receipts { + *by_status.entry(receipt.payload.status.clone()).or_insert(0) += 1; + *by_provider + .entry(receipt.payload.provider.clone()) + .or_insert(0) += 1; + total_replicas = total_replicas.saturating_add(receipt.payload.replicas); + latest_checked_at = latest_checked_at.max(receipt.payload.checked_at); + + let quota_status = availability_quota_status(&receipt.payload.quota); + *by_quota_status.entry(quota_status).or_insert(0) += 1; + if receipt + .payload + .quota + .get("enforced") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + quota_enforced = quota_enforced.saturating_add(1); + } + if receipt + .payload + .quota + .get("requirements_exceed_quota") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + quota_requirements_exceeded = quota_requirements_exceeded.saturating_add(1); + } + if receipt + .payload + .peer_selection + .get("live_multi_peer_proof") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + live_multi_peer_proofs = live_multi_peer_proofs.saturating_add(1); + } + let peer_reputation_policy = + receipt.payload.peer_selection.get("peer_reputation_policy"); + let peer_attestation_exchange_policy = receipt + .payload + .peer_selection + .get("peer_attestation_exchange_policy"); + let peer_reputation_status = peer_reputation_policy + .and_then(|policy| policy.get("status")) + .and_then(|value| value.as_str()) + .unwrap_or("not_reported") + .to_string(); + *peer_reputation_by_status + .entry(peer_reputation_status.clone()) + .or_insert(0) += 1; + if peer_reputation_status == "local_history_applied" { + peer_reputation_local_history_applied = + peer_reputation_local_history_applied.saturating_add(1); + } + if peer_reputation_policy + .and_then(|policy| policy.get("federation")) + .and_then(|federation| federation.get("configured")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + peer_reputation_federated = peer_reputation_federated.saturating_add(1); + } + for replica in receipt + .payload + .peer_selection + .get("replicas") + .and_then(|value| value.as_array()) + .into_iter() + .flatten() + { + if replica.get("role").and_then(|value| value.as_str()) == Some("remote") { + remote_replicas = remote_replicas.saturating_add(1); + let remote_receipt = replica.get("remote_receipt"); + recent_remote_replicas.push(json!({ + "cid": receipt.payload.cid, + "node_did": replica + .get("node_did") + .cloned() + .unwrap_or(Value::Null), + "endpoint_id": replica + .get("endpoint_id") + .cloned() + .unwrap_or(Value::Null), + "score": replica + .get("score") + .cloned() + .unwrap_or(Value::Null), + "selection_reason": replica + .get("selection_reason") + .cloned() + .unwrap_or(Value::Null), + "peer_reputation_policy": peer_reputation_policy + .cloned() + .unwrap_or(Value::Null), + "peer_attestation_exchange_policy": peer_attestation_exchange_policy + .cloned() + .unwrap_or(Value::Null), + "local_reputation": replica + .get("local_reputation") + .cloned() + .unwrap_or(Value::Null), + "status": replica + .get("status") + .cloned() + .unwrap_or(Value::Null), + "checked_at": receipt.payload.checked_at, + "remote_receipt": { + "verified": remote_receipt + .and_then(|receipt| receipt.get("verified")) + .cloned() + .unwrap_or(Value::Bool(false)), + "status": remote_receipt + .and_then(|receipt| receipt.get("status")) + .cloned() + .unwrap_or(Value::Null), + "signer_did": remote_receipt + .and_then(|receipt| receipt.get("signer_did")) + .cloned() + .unwrap_or(Value::Null), + "quota_status": remote_receipt + .and_then(|receipt| receipt.get("quota")) + .and_then(|quota| quota.get("status")) + .cloned() + .unwrap_or(Value::Null), + "content_bytes": remote_receipt + .and_then(|receipt| receipt.get("accounting")) + .and_then(|accounting| accounting.get("content_bytes")) + .cloned() + .unwrap_or(Value::Null), + "abuse_controls": remote_receipt + .and_then(|receipt| receipt.get("abuse_controls")) + .cloned() + .unwrap_or(Value::Null), + }, + })); + } + if let Some(remote_receipt) = replica.get("remote_receipt") { + remote_receipts = remote_receipts.saturating_add(1); + if remote_receipt + .get("verified") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + verified_remote_receipts = verified_remote_receipts.saturating_add(1); + } + } + } + + let accounting = &receipt.payload.accounting; + if accounting.get("schema").and_then(|value| value.as_str()) + == Some(CONTENT_ACCOUNTING_SCHEMA) + && accounting + .get("observed") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + accounted_objects = accounted_objects.saturating_add(1); + if let Some(files) = accounting.get("files").and_then(|value| value.as_u64()) { + accounted_files = accounted_files.saturating_add(files); + } + if let Some(bytes) = accounting + .get("content_bytes") + .and_then(|value| value.as_u64()) + { + accounted_content_bytes = accounted_content_bytes.saturating_add(bytes); + } + if let Some(bytes) = accounting + .get("replica_bytes_estimate") + .and_then(|value| value.as_u64()) + { + accounted_replica_bytes = accounted_replica_bytes.saturating_add(bytes); + } + if accounting + .get("storage_quota") + .and_then(|quota| quota.get("enforced")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + storage_quota_enforced = storage_quota_enforced.saturating_add(1); + } + let source = accounting + .get("source") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + .to_string(); + *by_accounting_source.entry(source).or_insert(0) += 1; + } + + let abuse_controls = &receipt.payload.abuse_controls; + if abuse_controls + .get("schema") + .and_then(|value| value.as_str()) + == Some(CONTENT_ABUSE_CONTROLS_SCHEMA) + { + if abuse_controls + .get("enforced") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + abuse_controls_enforced = abuse_controls_enforced.saturating_add(1); + } + if abuse_controls + .get("throttled") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + abuse_controls_throttled = abuse_controls_throttled.saturating_add(1); + } + if let Some(attempted) = abuse_controls + .get("attempted_operations") + .and_then(|value| value.as_u64()) + { + abuse_attempted_operations = + abuse_attempted_operations.saturating_add(attempted); + } + if let Some(failed) = abuse_controls + .get("failed_operations") + .and_then(|value| value.as_u64()) + { + abuse_failed_operations = abuse_failed_operations.saturating_add(failed); + } + let policy = abuse_controls + .get("policy") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + .to_string(); + *by_abuse_policy.entry(policy).or_insert(0) += 1; + } + } + + let mut repair_status_counts: BTreeMap = BTreeMap::new(); + let mut queued = 0_u32; + let mut due = 0_u32; + let mut healthy = 0_u32; + let now = now_unix_secs(); + for task in &tasks { + *repair_status_counts.entry(task.status.clone()).or_insert(0) += 1; + if task.status == "queued" { + queued = queued.saturating_add(1); + if task.is_due(now) { + due = due.saturating_add(1); + } + } + if task.status == "healthy" { + healthy = healthy.saturating_add(1); + } + } + + let mut recent_tasks = tasks.clone(); + recent_tasks.sort_by(|a, b| { + b.checked_at + .cmp(&a.checked_at) + .then_with(|| a.cid.cmp(&b.cid)) + }); + let recent_tasks = recent_tasks + .iter() + .take(10) + .map(|task| { + json!({ + "cid": task.cid, + "status": task.status, + "attempts": task.attempts, + "checked_at": task.checked_at, + "next_check_after": task.next_check_after, + "due": task.is_due(now), + }) + }) + .collect::>(); + recent_remote_replicas.sort_by(|a, b| { + b.get("checked_at") + .and_then(|value| value.as_u64()) + .unwrap_or(0) + .cmp( + &a.get("checked_at") + .and_then(|value| value.as_u64()) + .unwrap_or(0), + ) + .then_with(|| { + a.get("cid") + .and_then(|value| value.as_str()) + .unwrap_or("") + .cmp(b.get("cid").and_then(|value| value.as_str()).unwrap_or("")) + }) + }); + let recent_remote_replicas = recent_remote_replicas + .into_iter() + .take(AVAILABILITY_DASHBOARD_REMOTE_ROW_LIMIT) + .collect::>(); + let recent_remote_replicas_truncated = + remote_replicas as usize > recent_remote_replicas.len(); + + let mut dashboard = json!({ + "schema": AVAILABILITY_DASHBOARD_SCHEMA, + "provider": "content-provider", + "objects": { + "tracked": receipts.len(), + "by_status": by_status, + "by_provider": by_provider, + "total_replicas": total_replicas, + "latest_checked_at": latest_checked_at, + }, + "quota": { + "by_status": by_quota_status, + "enforced": quota_enforced, + "requirements_exceed_quota": quota_requirements_exceeded, + }, + "federated_quota_ledger_policy": federated_quota_ledger_policy_status_json( + &receipts, + &storage_accounting_ledger, + self.federated_quota_ledger_exchange.as_ref(), + ), + "proofs": { + "live_multi_peer": live_multi_peer_proofs, + "remote_replicas": remote_replicas, + "remote_receipts": remote_receipts, + "verified_remote_receipts": verified_remote_receipts, + "recent_remote_replica_limit": AVAILABILITY_DASHBOARD_REMOTE_ROW_LIMIT, + "recent_remote_replicas_truncated": recent_remote_replicas_truncated, + "recent_remote_replicas": recent_remote_replicas, + "peer_reputation_policy": { + "schema": "elastos.carrier.peer-reputation/v1", + "policy": "local_runtime_reputation", + "scope": "content-availability", + "by_status": peer_reputation_by_status, + "local_history_applied": peer_reputation_local_history_applied, + "federated_policy_receipts": peer_reputation_federated, + "federation": { + "configured": peer_reputation_federated > 0, + "cross_runtime_reputation": peer_reputation_federated > 0, + "reason": if peer_reputation_federated > 0 { + "federated reputation receipts were observed" + } else { + "this branch records local Runtime success/failure reputation only" + }, + }, + }, + "peer_attestation_exchange_policy": peer_attestation_exchange_policy_status_json( + &receipts, + ), + }, + "accounting": { + "schema": CONTENT_ACCOUNTING_SCHEMA, + "accounted_objects": accounted_objects, + "accounted_files": accounted_files, + "content_bytes": accounted_content_bytes, + "replica_bytes_estimate": accounted_replica_bytes, + "by_source": by_accounting_source, + "storage_quota_enforced": storage_quota_enforced, + "storage_quota_policy": "principal_ledger", + "ledger": storage_accounting_ledger, + }, + "abuse_controls": { + "schema": CONTENT_ABUSE_CONTROLS_SCHEMA, + "enforced": abuse_controls_enforced, + "throttled": abuse_controls_throttled, + "attempted_operations": abuse_attempted_operations, + "failed_operations": abuse_failed_operations, + "by_policy": by_abuse_policy, + }, + "network_abuse_policy": network_abuse_policy_status_json( + NetworkAbusePolicyStatusCounters { + tracked_objects: receipts.len(), + remote_replicas, + remote_receipts, + abuse_controls_enforced, + abuse_controls_throttled, + abuse_attempted_operations, + abuse_failed_operations, + }, + self.federated_abuse_control_exchange.as_ref(), + ), + "storage_settlement_policy": storage_settlement_policy_status_json( + &receipts, + &storage_accounting_ledger, + ), + "storage_market_admission_policy": storage_market_admission_policy_status_json( + &receipts, + &storage_accounting_ledger, + self.storage_market_admission.as_ref(), + ), + "repair": { + "tracked_tasks": tasks.len(), + "by_status": repair_status_counts, + "queued": queued, + "due": due, + "healthy": healthy, + "recent": recent_tasks, + }, + "scheduler": { + "manual_trigger": "elastos content repair-worker", + "env": "ELASTOS_CONTENT_REPAIR_SCHEDULER", + "enabled_by_default": false, + "provider_invocation_required": true, + }, + "repair_fleet": repair_fleet_status_json(&tasks, now), + "external_repair_fleet_policy": external_repair_fleet_policy_json( + &tasks, + now, + self.external_repair_fleet.as_ref(), + ), + "federated_operator_alerting_policy": federated_operator_alerting_policy_json( + &receipts, + &tasks, + &storage_accounting_entries, + &storage_accounting_ledger, + now, + self.operator_alert_sink.as_ref(), + self.federated_operator_alert_exchange.as_ref(), + ), + "operator_dashboard": operator_dashboard_json( + &receipts, + &tasks, + &storage_accounting_entries, + &storage_accounting_ledger, + now, + ContentOperatorDashboardIntegrations { + operator_alert_sink: self.operator_alert_sink.as_ref(), + external_repair_fleet: self.external_repair_fleet.as_ref(), + federated_quota_ledger_exchange: self + .federated_quota_ledger_exchange + .as_ref(), + federated_operator_alert_exchange: self + .federated_operator_alert_exchange + .as_ref(), + }, + ), + }); + if emit_operator_alert { + dashboard["operator_alert_delivery"] = self.emit_operator_alert(&dashboard).await?; + } + Ok(provider_ok(dashboard)) + } + + fn content_accounting_from_previous_or_unknown( + &self, + cid: &str, + source: &str, + replicas: u32, + ) -> Result { + let observation = self + .latest_receipt_for_cid(cid) + .transpose()? + .map(|receipt| content_accounting_observation_from_value(&receipt.payload.accounting)) + .unwrap_or_default(); + Ok(content_accounting_json(source, observation, replicas)) + } + + fn principal_storage_quota_for_request( + &self, + principal_did: &str, + requirements: &AvailabilityRequirements, + incoming_content_bytes: Option, + exclude_cid: Option<&str>, + ) -> Result { + let Some(max_content_bytes) = requirements.max_storage_bytes_per_principal else { + return Ok(default_storage_quota_json()); + }; + let incoming_content_bytes = incoming_content_bytes.ok_or_else(|| { + ProviderError::Provider( + "content storage quota requires known incoming content bytes".to_string(), + ) + })?; + let active_content_bytes = + self.principal_active_content_bytes(principal_did, exclude_cid)?; + let projected_content_bytes = active_content_bytes.saturating_add(incoming_content_bytes); + let quota_exceeded = projected_content_bytes > max_content_bytes; + let status = if quota_exceeded { + "quota_exceeded" + } else { + "within_quota" + }; + Ok(json!({ + "schema": CONTENT_STORAGE_QUOTA_SCHEMA, + "policy": "principal_storage_quota", + "scope": "content-availability", + "enforced": true, + "status": status, + "principal_did": principal_did, + "active_content_bytes": active_content_bytes, + "incoming_content_bytes": incoming_content_bytes, + "projected_content_bytes": projected_content_bytes, + "max_content_bytes": max_content_bytes, + "federated_quota_ledger_policy": federated_quota_ledger_policy_json( + "principal_storage_quota", + status, + true, + false, + true, + ), + })) + } + + fn principal_active_content_bytes( + &self, + principal_did: &str, + exclude_cid: Option<&str>, + ) -> Result { + Ok(self + .latest_storage_accounting_entries()? + .into_iter() + .filter(|entry| { + entry.principal_did == principal_did + && Some(entry.cid.as_str()) != exclude_cid + && entry.replicas > 0 + && entry.status != "local_unpinned" + }) + .filter_map(|entry| entry.content_bytes) + .fold(0_u64, u64::saturating_add)) + } + + fn record_repair_task( + &self, + receipt: &SignedAvailabilityReceipt, + outcome: &AvailabilityOutcome, + requirements: AvailabilityRequirements, + repair_worker_attempt: bool, + ) -> Result { + let previous_attempts = self + .latest_repair_task_for_cid(&receipt.payload.cid) + .transpose()? + .map(|task| task.attempts) + .unwrap_or(0); + let attempts = previous_attempts.saturating_add(if repair_worker_attempt { 1 } else { 0 }); + let status = repair_task_status_for_availability(&outcome.status).to_string(); + let next_check_after = + repair_task_next_check_after(&status, receipt.payload.checked_at, outcome); + let mut repair_worker = outcome.repair_worker.clone(); + if !repair_worker.is_object() { + repair_worker = repair_worker_json(status == "queued"); + } + if let Some(metadata) = repair_worker.as_object_mut() { + metadata.insert( + "worker".to_string(), + Value::String("content-provider".to_string()), + ); + metadata.insert("status".to_string(), Value::String(status.clone())); + metadata.insert( + "scheduled".to_string(), + Value::Bool(matches!(status.as_str(), "queued" | "healthy")), + ); + metadata.insert( + "next_check_after".to_string(), + Value::from(next_check_after), + ); + } + let task = ContentRepairTask { + schema: REPAIR_TASK_SCHEMA.to_string(), + cid: receipt.payload.cid.clone(), + uri: receipt.payload.uri.clone(), + object_did: receipt.payload.object_did.clone(), + publisher_did: Some(receipt.payload.publisher_did.clone()), + policy: outcome.policy.clone(), + status, + reason: outcome.reason.clone(), + attempts, + requirements: requirements.to_json(), + availability: outcome.to_json(), + repair_worker, + checked_at: receipt.payload.checked_at, + next_check_after, + }; + append_jsonl(&self.repair_tasks_path(), &task)?; + Ok(task) + } + + fn latest_repair_task_for_cid( + &self, + cid: &str, + ) -> Option> { + match self.latest_repair_tasks() { + Ok(tasks) => tasks.into_iter().find(|task| task.cid == cid).map(Ok), + Err(err) => Some(Err(err)), + } + } + + fn latest_repair_tasks(&self) -> Result, ProviderError> { + let path = self.repair_tasks_path(); + if !path.exists() { + return Ok(Vec::new()); + } + + let file = std::fs::File::open(&path)?; + let reader = std::io::BufReader::new(file); + let mut latest = BTreeMap::new(); + for line in reader.lines() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let task: ContentRepairTask = serde_json::from_str(&line).map_err(|err| { + ProviderError::Provider(format!("content repair task ledger decode failed: {err}")) + })?; + latest.insert(task.cid.clone(), task); + } + Ok(latest.into_values().collect()) + } + + fn receipts_path(&self) -> PathBuf { + self.data_dir + .join("ElastOS") + .join("SystemServices") + .join("Content") + .join("availability-receipts.jsonl") + } + + fn repair_tasks_path(&self) -> PathBuf { + self.data_dir + .join("ElastOS") + .join("SystemServices") + .join("Content") + .join("repair-tasks.jsonl") + } + + fn storage_accounting_path(&self) -> PathBuf { + self.data_dir + .join("ElastOS") + .join("SystemServices") + .join("Content") + .join("storage-accounting.jsonl") + } + + fn operator_alert_receipts_path(&self) -> PathBuf { + self.data_dir + .join("ElastOS") + .join("SystemServices") + .join("Content") + .join("operator-alert-receipts.jsonl") + } + + async fn emit_operator_alert(&self, dashboard: &Value) -> Result { + let emitted_at = now_unix_secs(); + let alert = operator_alert_payload(dashboard, emitted_at); + let local_delivery = match &self.operator_alert_sink { + Some(sink) => sink.deliver(&alert).await, + None => Ok(json!({ + "configured": false, + "delivered": false, + "status": "not_configured", + "reason": "no content operator alert sink is configured", + })), + }; + let delivery = match local_delivery { + Ok(delivery) => delivery, + Err(err) => json!({ + "configured": true, + "delivered": false, + "status": "failed", + "reason": err, + }), + }; + let federated_exchange = match &self.federated_operator_alert_exchange { + Some(exchange) => { + let request = federated_operator_alert_exchange_request(&alert, emitted_at); + match exchange.exchange(&request).await { + Ok(receipt) => receipt, + Err(err) => json!({ + "schema": CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_RECEIPT_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "configured": true, + "delivered": false, + "accepted": false, + "status": "failed", + "reason": err, + "exchange": exchange.redacted_status_json(), + "credential_exposed": false, + }), + } + } + None => json!({ + "schema": CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_RECEIPT_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "configured": false, + "delivered": false, + "accepted": false, + "status": "not_configured", + "reason": "no federated operator alert exchange is configured", + }), + }; + let receipt = json!({ + "schema": CONTENT_OPERATOR_ALERT_RECEIPT_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "emitted_at": emitted_at, + "requested": true, + "alert": alert, + "delivery": delivery, + "federated_exchange": federated_exchange, + }); + append_jsonl(&self.operator_alert_receipts_path(), &receipt)?; + Ok(receipt) + } + + fn sign_admission_receipt(&self, admission: &Value) -> Result { + let (signing_key, _) = + elastos_identity::load_or_create_did(&self.data_dir).map_err(|err| { + ProviderError::Provider(format!( + "content admission receipt signer unavailable: {err}" + )) + })?; + let payload = serde_json::to_string(admission).map_err(|err| { + ProviderError::Provider(format!("content admission receipt encode failed: {err}")) + })?; + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &signing_key, + CONTENT_ADMISSION_DOMAIN, + payload.as_bytes(), + ); + Ok(json!({ + "payload": admission, + "signature": signature, + "signer_did": signer_did, + })) + } + + fn sign_federated_quota_ledger_exchange_request( + &self, + local_admission: &Value, + request: &Value, + ) -> Result { + let (signing_key, _) = + elastos_identity::load_or_create_did(&self.data_dir).map_err(|err| { + ProviderError::Provider(format!( + "content federated quota-ledger exchange signer unavailable: {err}" + )) + })?; + let payload = federated_quota_ledger_exchange_request(local_admission, request); + let canonical = serde_json::to_string(&payload).map_err(|err| { + ProviderError::Provider(format!( + "content federated quota-ledger exchange request encode failed: {err}" + )) + })?; + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &signing_key, + CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_DOMAIN, + canonical.as_bytes(), + ); + Ok(json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + })) + } + + fn sign_federated_abuse_control_exchange_request( + &self, + local_admission: &Value, + request: &Value, + ) -> Result { + let (signing_key, _) = + elastos_identity::load_or_create_did(&self.data_dir).map_err(|err| { + ProviderError::Provider(format!( + "content federated abuse-control exchange signer unavailable: {err}" + )) + })?; + let payload = federated_abuse_control_exchange_request(local_admission, request); + let canonical = serde_json::to_string(&payload).map_err(|err| { + ProviderError::Provider(format!( + "content federated abuse-control exchange request encode failed: {err}" + )) + })?; + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &signing_key, + CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_REQUEST_DOMAIN, + canonical.as_bytes(), + ); + Ok(json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + })) + } + + fn storage_accounting_ledger_status(&self) -> Result { + #[derive(Default)] + struct PrincipalTotals { + tracked_objects: u32, + active_objects: u32, + files: u64, + content_bytes: u64, + replica_bytes_estimate: u64, + quota_enforced: u32, + latest_recorded_at: u64, + by_status: BTreeMap, + by_quota_status: BTreeMap, + } + + let entries = self.latest_storage_accounting_entries()?; + let mut by_principal: BTreeMap = BTreeMap::new(); + let mut active_objects = 0_u32; + let mut active_principals = BTreeSet::new(); + let mut content_bytes = 0_u64; + let mut replica_bytes_estimate = 0_u64; + let mut quota_enforced = 0_u32; + let mut latest_recorded_at = 0_u64; + + for entry in &entries { + let active = entry.replicas > 0 && entry.status != "local_unpinned"; + let quota_status = availability_quota_status(&entry.quota); + let storage_quota_enforced = entry + .storage_quota + .get("enforced") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + || entry + .quota + .get("enforced") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + + let principal = by_principal.entry(entry.principal_did.clone()).or_default(); + principal.tracked_objects = principal.tracked_objects.saturating_add(1); + *principal.by_status.entry(entry.status.clone()).or_insert(0) += 1; + *principal.by_quota_status.entry(quota_status).or_insert(0) += 1; + principal.latest_recorded_at = principal.latest_recorded_at.max(entry.recorded_at); + + if storage_quota_enforced { + quota_enforced = quota_enforced.saturating_add(1); + principal.quota_enforced = principal.quota_enforced.saturating_add(1); + } + + if active { + active_objects = active_objects.saturating_add(1); + active_principals.insert(entry.principal_did.clone()); + principal.active_objects = principal.active_objects.saturating_add(1); + if let Some(files) = entry.files { + principal.files = principal.files.saturating_add(files); + } + if let Some(bytes) = entry.content_bytes { + content_bytes = content_bytes.saturating_add(bytes); + principal.content_bytes = principal.content_bytes.saturating_add(bytes); + } + if let Some(bytes) = entry.replica_bytes_estimate { + replica_bytes_estimate = replica_bytes_estimate.saturating_add(bytes); + principal.replica_bytes_estimate = + principal.replica_bytes_estimate.saturating_add(bytes); + } + } + + latest_recorded_at = latest_recorded_at.max(entry.recorded_at); + } + + let by_principal = by_principal + .into_iter() + .map(|(principal_did, totals)| { + ( + principal_did, + json!({ + "tracked_objects": totals.tracked_objects, + "active_objects": totals.active_objects, + "files": totals.files, + "content_bytes": totals.content_bytes, + "replica_bytes_estimate": totals.replica_bytes_estimate, + "quota_enforced": totals.quota_enforced, + "by_status": totals.by_status, + "by_quota_status": totals.by_quota_status, + "latest_recorded_at": totals.latest_recorded_at, + }), + ) + }) + .collect::>(); + + Ok(json!({ + "schema": CONTENT_STORAGE_ACCOUNTING_LEDGER_SCHEMA, + "durable": true, + "source": "signed_availability_receipts", + "tracked_objects": entries.len(), + "active_objects": active_objects, + "tracked_principals": by_principal.len(), + "active_principals": active_principals.len(), + "content_bytes": content_bytes, + "replica_bytes_estimate": replica_bytes_estimate, + "quota_enforced": quota_enforced, + "latest_recorded_at": latest_recorded_at, + "by_principal": by_principal, + "market_policy": { + "schema": "elastos.content.storage-market/v1", + "mode": "provider_receipt_accounting", + "status": if quota_enforced > 0 { + "quota_policy_recorded_no_settlement" + } else { + "accounting_recorded_no_settlement" + }, + "settlement": "not_configured", + "escrow": "not_configured", + "admission_policy": storage_market_admission_policy_json( + "provider_receipt_accounting", + if quota_enforced > 0 { + "quota_policy_recorded_no_settlement" + } else { + "accounting_recorded_no_settlement" + }, + quota_enforced > 0, + false, + false, + ), + "settlement_policy": storage_settlement_policy_json( + "provider_receipt_accounting", + if quota_enforced > 0 { + "quota_policy_recorded_no_settlement" + } else { + "accounting_recorded_no_settlement" + }, + quota_enforced > 0, + ), + "next": "External pricing, escrow/settlement, storage-market admission, and cross-peer SLA policy remain production-network work." + }, + })) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AvailabilityReceipt { + pub schema: String, + pub cid: String, + pub uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub object_did: Option, + pub publisher_did: String, + pub provider: String, + pub policy: String, + pub status: String, + pub replicas: u32, + pub peer_selection: Value, + pub quota: Value, + pub repair_worker: Value, + #[serde( + default = "default_content_storage_market_json", + skip_serializing_if = "is_default_content_storage_market_json" + )] + pub storage_market: Value, + #[serde( + default = "default_content_repair_graph_json", + skip_serializing_if = "is_default_content_repair_graph_json" + )] + pub repair_graph: Value, + #[serde(default = "default_content_abuse_controls_json")] + pub abuse_controls: Value, + #[serde(default = "default_content_accounting_json")] + pub accounting: Value, + pub checked_at: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignedAvailabilityReceipt { + pub payload: AvailabilityReceipt, + pub signature: String, + pub signer_did: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ContentRepairTask { + schema: String, + cid: String, + uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + object_did: Option, + #[serde(skip_serializing_if = "Option::is_none")] + publisher_did: Option, + policy: String, + status: String, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, + attempts: u32, + requirements: Value, + availability: Value, + repair_worker: Value, + checked_at: u64, + next_check_after: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ContentStorageAccountingEntry { + schema: String, + cid: String, + uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + object_did: Option, + principal_did: String, + provider: String, + policy: String, + status: String, + source: String, + #[serde(skip_serializing_if = "Option::is_none")] + files: Option, + #[serde(skip_serializing_if = "Option::is_none")] + content_bytes: Option, + replicas: u32, + #[serde(skip_serializing_if = "Option::is_none")] + replica_bytes_estimate: Option, + quota: Value, + storage_quota: Value, + recorded_at: u64, +} + +impl ContentRepairTask { + fn is_repair_candidate(&self, include_healthy_check: bool) -> bool { + matches!(self.status.as_str(), "queued" | "repair_needed") + || (include_healthy_check && self.status == "healthy") + } + + fn is_due(&self, now: u64) -> bool { + self.next_check_after == 0 || self.next_check_after <= now + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContentObjectManifest { + pub schema: String, + pub kind: String, + pub content_digest: String, + pub files: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub links: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub object_did: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub publisher_did: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContentObjectFile { + pub path: String, + pub sha256: String, + pub size: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContentObjectLink { + pub rel: String, + pub cid: String, +} + +struct ReceiptInput { + cid: String, + object_did: Option, + publisher_did: Option, + provider: String, + policy: String, + status: String, + replicas: u32, + peer_selection: Value, + quota: Value, + repair_worker: Value, + storage_market: Value, + repair_graph: Value, + abuse_controls: Value, + accounting: Value, +} + +struct AvailabilityRequestContext<'a> { + object_did: Option<&'a str>, + publisher_did: Option<&'a str>, + accounting_observation: ContentAccountingObservation, +} + +#[derive(Debug, Clone)] +struct AvailabilityRequirements { + min_replicas: u32, + max_replicas: Option, + require_live_multi_peer_proof: bool, + max_storage_bytes_per_principal: Option, + repair_graph_kind: Option, +} + +impl AvailabilityRequirements { + fn from_request(request: &Value) -> Self { + let requirements = request + .get("availability_requirements") + .or_else(|| request.get("replication_requirements")); + let min_replicas = requirements + .and_then(|value| value.get("min_replicas")) + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(1) + .max(1); + let max_replicas = requirements + .and_then(|value| value.get("max_replicas")) + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .filter(|value| *value > 0); + let require_live_multi_peer_proof = requirements + .and_then(|value| value.get("require_live_multi_peer_proof")) + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let max_storage_bytes_per_principal = requirements + .and_then(|value| { + value + .get("max_storage_bytes_per_principal") + .or_else(|| value.get("max_principal_storage_bytes")) + .or_else(|| value.get("max_storage_bytes")) + .or_else(|| value.get("storage_quota_bytes")) + }) + .and_then(|value| value.as_u64()) + .filter(|value| *value > 0); + let repair_graph_kind = requirements + .and_then(|value| { + value + .get("repair_graph_kind") + .or_else(|| value.get("content_graph_kind")) + .or_else(|| value.get("graph_kind")) + .or_else(|| { + value + .get("repair_graph") + .and_then(|graph| graph.get("kind")) + }) + .or_else(|| { + value + .get("content_graph") + .and_then(|graph| graph.get("kind")) + }) + }) + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string); + Self { + min_replicas, + max_replicas, + require_live_multi_peer_proof, + max_storage_bytes_per_principal, + repair_graph_kind, + } + } + + fn to_json(&self) -> Value { + json!({ + "min_replicas": self.min_replicas, + "max_replicas": self.max_replicas, + "require_live_multi_peer_proof": self.require_live_multi_peer_proof, + "max_storage_bytes_per_principal": self.max_storage_bytes_per_principal, + "repair_graph_kind": self.repair_graph_kind, + }) + } +} + +impl Default for AvailabilityRequirements { + fn default() -> Self { + Self { + min_replicas: 1, + max_replicas: None, + require_live_multi_peer_proof: false, + max_storage_bytes_per_principal: None, + repair_graph_kind: None, + } + } +} + +#[derive(Debug, Clone)] +struct AvailabilityOutcome { + provider: String, + policy: String, + status: String, + replicas: u32, + reason: Option, + peer_selection: Value, + quota: Value, + repair_worker: Value, + storage_market: Value, + repair_graph: Value, + abuse_controls: Value, +} + +impl AvailabilityOutcome { + fn local_publish(pin: bool) -> Self { + if pin { + Self { + provider: "ipfs-provider".to_string(), + policy: "local_pin".to_string(), + status: "local_pinned".to_string(), + replicas: 1, + reason: None, + peer_selection: local_peer_selection_json(), + quota: local_quota_json(), + repair_worker: repair_worker_json(false), + storage_market: local_storage_market_json(), + repair_graph: local_repair_graph_json(), + abuse_controls: local_abuse_controls_json(), + } + } else { + Self { + provider: "ipfs-provider".to_string(), + policy: "local_add".to_string(), + status: "local_unpinned".to_string(), + replicas: 0, + reason: None, + peer_selection: local_peer_selection_json(), + quota: local_quota_json(), + repair_worker: repair_worker_json(false), + storage_market: local_storage_market_json(), + repair_graph: local_repair_graph_json(), + abuse_controls: local_abuse_controls_json(), + } + } + } + + fn repair_needed(provider: &str, policy: &str, replicas: u32, reason: String) -> Self { + Self { + provider: provider.to_string(), + policy: policy.to_string(), + status: "repair_needed".to_string(), + replicas, + reason: Some(reason), + peer_selection: local_peer_selection_json(), + quota: local_quota_json(), + repair_worker: repair_worker_json(true), + storage_market: local_storage_market_json(), + repair_graph: local_repair_graph_json(), + abuse_controls: local_abuse_controls_json(), + } + } + + fn to_json(&self) -> Value { + let mut availability = json!({ + "status": self.status, + "provider": self.provider, + "policy": self.policy, + "replicas": self.replicas, + "peer_selection": self.peer_selection, + "quota": self.quota, + "federated_quota_ledger_policy": federated_quota_ledger_policy_from_quota_json( + &self.quota, + false, + ), + "repair_worker": self.repair_worker, + "storage_market": self.storage_market, + "storage_settlement_policy": storage_settlement_policy_from_market_json( + &self.storage_market, + ), + "storage_market_admission_policy": storage_market_admission_policy_from_market_json( + &self.storage_market, + ), + "repair_graph": self.repair_graph, + "abuse_controls": self.abuse_controls, + "network_abuse_policy": network_abuse_policy_for_availability_json( + &self.abuse_controls, + &self.peer_selection, + ), + }); + if let Some(reason) = &self.reason { + availability["reason"] = Value::String(reason.clone()); + } + availability + } +} + +fn repair_task_status_for_availability(status: &str) -> &'static str { + match status { + "repair_needed" => "queued", + "network_available" | "carrier_announced" => "healthy", + "local_unpinned" => "retired", + "local_pinned" => "local_only", + _ => "observed", + } +} + +fn repair_task_next_check_after( + task_status: &str, + checked_at: u64, + outcome: &AvailabilityOutcome, +) -> u64 { + match task_status { + "queued" => checked_at.saturating_add(REPAIR_RETRY_DELAY_SECS), + "healthy" => checked_at.saturating_add(REPAIR_HEALTH_CHECK_DELAY_SECS), + "local_only" if outcome.status == "local_pinned" => 0, + _ => 0, + } +} + +fn network_abuse_policy_for_availability_json( + abuse_controls: &Value, + peer_selection: &Value, +) -> Value { + let enforced = abuse_controls + .get("enforced") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let throttled = abuse_controls + .get("throttled") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let attempted_operations = abuse_controls + .get("attempted_operations") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let failed_operations = abuse_controls + .get("failed_operations") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let remote_replicas = peer_selection + .get("replicas") + .and_then(|value| value.as_array()) + .map(|replicas| { + replicas + .iter() + .filter(|replica| { + replica.get("role").and_then(|value| value.as_str()) == Some("remote") + }) + .count() + }) + .unwrap_or(0); + json!({ + "schema": CONTENT_NETWORK_ABUSE_POLICY_SCHEMA, + "policy": "provider_plane_local_guardrails", + "scope": "content-availability", + "status": if enforced { + "local_provider_guardrails_recorded" + } else { + "local_backend_no_network_guardrail" + }, + "authority": { + "provider": "content-provider", + "runtime_invocation_required": true, + "app_visible": false, + }, + "receipt": { + "abuse_controls_policy": abuse_controls + .get("policy") + .cloned() + .unwrap_or(Value::String("not_reported".to_string())), + "enforced": enforced, + "throttled": throttled, + "attempted_operations": attempted_operations, + "failed_operations": failed_operations, + "remote_replicas": remote_replicas, + }, + "local_guardrails": { + "signed_availability_receipts": true, + "carrier_provider_candidate_cap": enforced, + "carrier_provider_admission_preflight": enforced && remote_replicas > 0, + "repair_worker_attempt_budget": true, + "provider_invocation_required": true, + }, + "network_federation": { + "configured": false, + "cross_peer_rate_limit": false, + "federated_banlist": false, + "federated_abuse_ledger": false, + "reason": "this receipt exposes provider-owned local guardrails; production network-wide throttles, banlists, and abuse ledgers require a configured policy plane", + }, + }) +} + +struct NetworkAbusePolicyStatusCounters { + tracked_objects: usize, + remote_replicas: u32, + remote_receipts: u32, + abuse_controls_enforced: u32, + abuse_controls_throttled: u32, + abuse_attempted_operations: u64, + abuse_failed_operations: u64, +} + +fn network_abuse_policy_status_json( + counters: NetworkAbusePolicyStatusCounters, + federated_abuse_control_exchange: Option<&ContentFederatedAbuseControlExchangeClient>, +) -> Value { + json!({ + "schema": CONTENT_NETWORK_ABUSE_POLICY_SCHEMA, + "policy": "provider_plane_local_guardrails", + "scope": "content-availability", + "status": if federated_abuse_control_exchange.is_some() { + "configured_federated_abuse_control_exchange" + } else if counters.abuse_controls_enforced > 0 || counters.remote_replicas > 0 { + "local_guardrails_recorded_no_federated_throttle" + } else { + "local_only_no_network_activity" + }, + "authority": { + "provider": "content-provider", + "runtime_invocation_required": true, + "app_visible": false, + }, + "local_guardrails": { + "signed_availability_receipts": true, + "carrier_provider_candidate_cap": true, + "carrier_provider_admission_preflight": true, + "repair_worker_attempt_budget": true, + "repair_worker_failure_budget": true, + "provider_invocation_required": true, + }, + "counters": { + "tracked_objects": counters.tracked_objects, + "remote_replicas": counters.remote_replicas, + "remote_receipts": counters.remote_receipts, + "guardrail_receipts": counters.abuse_controls_enforced, + "throttled_receipts": counters.abuse_controls_throttled, + "attempted_provider_operations": counters.abuse_attempted_operations, + "failed_provider_operations": counters.abuse_failed_operations, + }, + "network_federation": { + "configured": federated_abuse_control_exchange.is_some(), + "cross_peer_rate_limit": federated_abuse_control_exchange.is_some(), + "federated_banlist": false, + "federated_abuse_ledger": federated_abuse_control_exchange.is_some(), + "exchange_client": federated_abuse_control_exchange + .map(ContentFederatedAbuseControlExchangeClient::redacted_status_json) + .unwrap_or_else(|| { + json!({ + "configured": false, + "delivery": "not_configured", + "authorization_configured": false, + }) + }), + "reason": if federated_abuse_control_exchange.is_some() { + "configured federated abuse-control exchange is enforced by content/admission before remote bytes or DAG repair data move; production banlists and network-wide abuse policy remain external infrastructure" + } else { + "cross-peer throttles, banlists, and abuse ledgers are production-network policy, not app-visible Library authority" + }, + }, + }) +} + +fn network_abuse_policy_run_json( + checked: u32, + failed: u32, + exhausted_attempts_skipped: u32, + throttled: bool, +) -> Value { + json!({ + "schema": CONTENT_NETWORK_ABUSE_POLICY_SCHEMA, + "policy": "provider_plane_local_guardrails", + "scope": "content-availability", + "status": if throttled || exhausted_attempts_skipped > 0 { + "local_worker_throttled" + } else { + "local_worker_within_budget" + }, + "authority": { + "provider": "content-provider", + "runtime_invocation_required": true, + "app_visible": false, + }, + "local_guardrails": { + "repair_worker_attempt_budget": true, + "repair_worker_failure_budget": true, + "force_due_override_audited": true, + }, + "run": { + "checked": checked, + "failed": failed, + "exhausted_attempts_skipped": exhausted_attempts_skipped, + "throttled": throttled, + }, + "network_federation": { + "configured": false, + "cross_peer_rate_limit": false, + "federated_banlist": false, + "federated_abuse_ledger": false, + }, + }) +} + +fn peer_attestation_exchange_policy_status_json(receipts: &[SignedAvailabilityReceipt]) -> Value { + let mut by_status: BTreeMap = BTreeMap::new(); + let mut policy_receipts = 0_u32; + let mut configured_receipts = 0_u32; + let mut remote_provider_proofs = 0_u64; + let mut verified_remote_content_receipts = 0_u64; + + for receipt in receipts { + let policy = receipt + .payload + .peer_selection + .get("peer_attestation_exchange_policy"); + if policy.is_some() { + policy_receipts = policy_receipts.saturating_add(1); + } + let status = policy + .and_then(|policy| policy.get("status")) + .and_then(|value| value.as_str()) + .unwrap_or("not_reported") + .to_string(); + *by_status.entry(status).or_insert(0) += 1; + if policy + .and_then(|policy| policy.get("attestation_exchange")) + .and_then(|exchange| exchange.get("configured")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + configured_receipts = configured_receipts.saturating_add(1); + } + if let Some(proofs) = policy + .and_then(|policy| policy.get("local_proof")) + .and_then(|proof| proof.get("remote_provider_proofs")) + .and_then(|value| value.as_u64()) + { + remote_provider_proofs = remote_provider_proofs.saturating_add(proofs); + } + if let Some(verified) = policy + .and_then(|policy| policy.get("local_proof")) + .and_then(|proof| proof.get("verified_remote_content_receipts")) + .and_then(|value| value.as_u64()) + { + verified_remote_content_receipts = + verified_remote_content_receipts.saturating_add(verified); + } + } + + json!({ + "schema": CARRIER_PEER_ATTESTATION_EXCHANGE_POLICY_SCHEMA, + "policy": "provider_receipt_attestation_exchange_status", + "scope": "content-availability", + "status": if configured_receipts > 0 { + "attestation_exchange_observed" + } else if remote_provider_proofs > 0 { + "live_peer_proof_without_attestation_exchange" + } else { + "attestation_exchange_not_configured" + }, + "receipt_count": receipts.len(), + "policy_receipts": policy_receipts, + "by_status": by_status, + "local_proof": { + "signed_availability_announcements": true, + "remote_provider_proofs": remote_provider_proofs, + "verified_remote_content_receipts": verified_remote_content_receipts, + "local_runtime_reputation": true, + }, + "attestation_exchange": { + "configured": configured_receipts > 0, + "signed_reputation_receipts": configured_receipts > 0, + "third_party_attestations": false, + "cross_runtime_trust_policy": false, + "revocation": "not_configured", + "reason": if configured_receipts > 0 { + "one or more receipts reported cross-runtime reputation attestations" + } else { + "no signed cross-runtime reputation attestation exchange is configured" + }, + }, + }) +} + +#[derive(Clone, Copy)] +struct ContentOperatorDashboardIntegrations<'a> { + operator_alert_sink: Option<&'a ContentOperatorAlertSink>, + external_repair_fleet: Option<&'a ContentExternalRepairFleetClient>, + federated_quota_ledger_exchange: Option<&'a ContentFederatedQuotaLedgerExchangeClient>, + federated_operator_alert_exchange: Option<&'a ContentFederatedOperatorAlertExchangeClient>, +} + +fn operator_dashboard_json( + receipts: &[SignedAvailabilityReceipt], + tasks: &[ContentRepairTask], + storage_entries: &[ContentStorageAccountingEntry], + storage_ledger: &Value, + now: u64, + integrations: ContentOperatorDashboardIntegrations<'_>, +) -> Value { + #[derive(Default)] + struct PrincipalPressure { + active_objects: u32, + files: u64, + content_bytes: u64, + replica_bytes_estimate: u64, + quota_enforced: u32, + latest_recorded_at: u64, + } + + let mut principals: BTreeMap = BTreeMap::new(); + let mut quota_exceeded_records = 0_u32; + for entry in storage_entries { + let active = entry.replicas > 0 && entry.status != "local_unpinned"; + if entry + .storage_quota + .get("status") + .or_else(|| entry.quota.get("status")) + .and_then(|value| value.as_str()) + == Some("quota_exceeded") + { + quota_exceeded_records = quota_exceeded_records.saturating_add(1); + } + if !active { + continue; + } + + let principal = principals.entry(entry.principal_did.clone()).or_default(); + principal.active_objects = principal.active_objects.saturating_add(1); + principal.latest_recorded_at = principal.latest_recorded_at.max(entry.recorded_at); + if let Some(files) = entry.files { + principal.files = principal.files.saturating_add(files); + } + if let Some(bytes) = entry.content_bytes { + principal.content_bytes = principal.content_bytes.saturating_add(bytes); + } + if let Some(bytes) = entry.replica_bytes_estimate { + principal.replica_bytes_estimate = + principal.replica_bytes_estimate.saturating_add(bytes); + } + if entry + .storage_quota + .get("enforced") + .or_else(|| entry.quota.get("enforced")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + principal.quota_enforced = principal.quota_enforced.saturating_add(1); + } + } + + let mut top_principals = principals + .into_iter() + .map(|(principal_did, totals)| { + json!({ + "principal_did": principal_did, + "active_objects": totals.active_objects, + "files": totals.files, + "content_bytes": totals.content_bytes, + "replica_bytes_estimate": totals.replica_bytes_estimate, + "quota_enforced": totals.quota_enforced, + "latest_recorded_at": totals.latest_recorded_at, + }) + }) + .collect::>(); + top_principals.sort_by(|a, b| { + b.get("content_bytes") + .and_then(|value| value.as_u64()) + .unwrap_or(0) + .cmp( + &a.get("content_bytes") + .and_then(|value| value.as_u64()) + .unwrap_or(0), + ) + .then_with(|| { + a.get("principal_did") + .and_then(|value| value.as_str()) + .unwrap_or("") + .cmp( + b.get("principal_did") + .and_then(|value| value.as_str()) + .unwrap_or(""), + ) + }) + }); + let top_principals_truncated = top_principals.len() > AVAILABILITY_DASHBOARD_REMOTE_ROW_LIMIT; + top_principals.truncate(AVAILABILITY_DASHBOARD_REMOTE_ROW_LIMIT); + + let active_objects = storage_ledger + .get("active_objects") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let quota_enforced = storage_ledger + .get("quota_enforced") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let pressure_level = if quota_exceeded_records > 0 { + "quota_exceeded" + } else if quota_enforced > 0 { + "quota_enforced" + } else if active_objects > 0 { + "accounting_observed" + } else { + "idle" + }; + + let mut by_task_status: BTreeMap = BTreeMap::new(); + let mut due = 0_u32; + let mut next_due_after: Option = None; + let mut total_attempts = 0_u64; + let mut tasks_with_failures = 0_u32; + for task in tasks { + *by_task_status.entry(task.status.clone()).or_insert(0) += 1; + total_attempts = total_attempts.saturating_add(task.attempts as u64); + if task.attempts > 0 && !matches!(task.status.as_str(), "healthy" | "local_only") { + tasks_with_failures = tasks_with_failures.saturating_add(1); + } + if task.is_repair_candidate(true) { + if task.is_due(now) { + due = due.saturating_add(1); + } else if task.next_check_after > 0 { + next_due_after = Some( + next_due_after + .map(|current| current.min(task.next_check_after)) + .unwrap_or(task.next_check_after), + ); + } + } + } + + let mut recent_tasks = tasks.to_vec(); + recent_tasks.sort_by(|a, b| { + b.checked_at + .cmp(&a.checked_at) + .then_with(|| a.cid.cmp(&b.cid)) + }); + let recent_fleet_history = recent_tasks + .iter() + .take(AVAILABILITY_DASHBOARD_REMOTE_ROW_LIMIT) + .map(|task| { + json!({ + "cid": task.cid, + "status": task.status, + "policy": task.policy, + "attempts": task.attempts, + "checked_at": task.checked_at, + "next_check_after": task.next_check_after, + "due": task.is_due(now), + "reason": task.reason.clone(), + }) + }) + .collect::>(); + + let live_multi_peer_proofs = receipts + .iter() + .filter(|receipt| { + receipt + .payload + .peer_selection + .get("live_multi_peer_proof") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + }) + .count(); + + json!({ + "schema": CONTENT_OPERATOR_DASHBOARD_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "authority": { + "runtime_invocation_required": true, + "app_visible": false, + "raw_backend_access": false, + }, + "storage_pressure": { + "status": pressure_level, + "tracked_objects": storage_ledger + .get("tracked_objects") + .cloned() + .unwrap_or(Value::from(storage_entries.len() as u64)), + "active_objects": active_objects, + "tracked_principals": storage_ledger + .get("tracked_principals") + .cloned() + .unwrap_or(Value::from(0)), + "active_principals": storage_ledger + .get("active_principals") + .cloned() + .unwrap_or(Value::from(0)), + "content_bytes": storage_ledger + .get("content_bytes") + .cloned() + .unwrap_or(Value::from(0)), + "replica_bytes_estimate": storage_ledger + .get("replica_bytes_estimate") + .cloned() + .unwrap_or(Value::from(0)), + "quota_enforced": quota_enforced, + "quota_exceeded_records": quota_exceeded_records, + "quota_ledger_policy": federated_quota_ledger_policy_status_json( + receipts, + storage_ledger, + integrations.federated_quota_ledger_exchange, + ), + "top_principal_limit": AVAILABILITY_DASHBOARD_REMOTE_ROW_LIMIT, + "top_principals_truncated": top_principals_truncated, + "top_principals_by_content_bytes": top_principals, + "market_policy": storage_ledger + .get("market_policy") + .cloned() + .unwrap_or_else(default_content_storage_market_json), + "settlement_policy": storage_ledger + .get("market_policy") + .map(storage_settlement_policy_from_market_json) + .unwrap_or_else(default_storage_settlement_policy_json), + "market_admission_policy": storage_ledger + .get("market_policy") + .map(storage_market_admission_policy_from_market_json) + .unwrap_or_else(default_storage_market_admission_policy_json), + }, + "fleet_history": { + "tracked_tasks": tasks.len(), + "by_status": by_task_status, + "due": due, + "next_due_after": next_due_after, + "total_attempts": total_attempts, + "tasks_with_failures": tasks_with_failures, + "recent_limit": AVAILABILITY_DASHBOARD_REMOTE_ROW_LIMIT, + "recent": recent_fleet_history, + "external_repair_fleet_policy": external_repair_fleet_policy_json( + tasks, + now, + integrations.external_repair_fleet, + ), + }, + "proof_summary": { + "signed_receipts": receipts.len(), + "live_multi_peer_proofs": live_multi_peer_proofs, + "peer_attestation_exchange_policy": peer_attestation_exchange_policy_status_json( + receipts, + ), + }, + "federated_operator_alerting_policy": federated_operator_alerting_policy_json( + receipts, + tasks, + storage_entries, + storage_ledger, + now, + integrations.operator_alert_sink, + integrations.federated_operator_alert_exchange, + ), + "production_federation": { + "configured": integrations.federated_operator_alert_exchange.is_some(), + "external_repair_fleets": false, + "federated_storage_pressure": false, + "operator_alerting": if integrations.federated_operator_alert_exchange.is_some() { + "configured_federated_alert_exchange" + } else if integrations.operator_alert_sink.is_some() { + "provider_local_webhook" + } else { + "provider_status_only" + }, + "reason": if integrations.federated_operator_alert_exchange.is_some() { + "this branch can exchange provider alert receipts with one configured operator-owned federated endpoint; production dashboards still need subscribed provider fleets, peer-health feeds, and operator UI" + } else if integrations.operator_alert_sink.is_some() { + "this branch can deliver provider-local alert receipts to one configured operator sink; production dashboards still need federated ledgers, repair fleets, and alert exchange across independent providers" + } else { + "this branch exposes provider-local storage pressure and fleet history; production dashboards need federated ledgers, repair fleets, and alerting across independent providers" + }, + }, + }) +} + +fn operator_alert_payload(dashboard: &Value, emitted_at: u64) -> Value { + let alerting_policy = dashboard + .get("federated_operator_alerting_policy") + .cloned() + .unwrap_or(Value::Null); + let local_signals = alerting_policy + .get("local_signals") + .cloned() + .unwrap_or(Value::Null); + let federation = alerting_policy + .get("federation") + .cloned() + .unwrap_or(Value::Null); + let operator_dashboard = dashboard.get("operator_dashboard").unwrap_or(&Value::Null); + json!({ + "schema": CONTENT_OPERATOR_ALERT_SCHEMA, + "provider": "content-provider", + "scope": "content-availability", + "emitted_at": emitted_at, + "dashboard_schema": dashboard.get("schema").cloned().unwrap_or(Value::Null), + "policy": alerting_policy + .get("policy") + .cloned() + .unwrap_or(Value::Null), + "status": alerting_policy + .get("status") + .cloned() + .unwrap_or(Value::Null), + "local_signals": local_signals, + "storage_pressure": operator_dashboard + .get("storage_pressure") + .cloned() + .unwrap_or(Value::Null), + "repair_pressure": operator_dashboard + .get("fleet_history") + .cloned() + .unwrap_or(Value::Null), + "authority": { + "runtime_invocation_required": true, + "provider_owned_sink": true, + "credential_exposed": false, + "raw_backend_access": false, + }, + "production_federation": { + "configured": federation + .get("configured") + .cloned() + .unwrap_or(Value::Bool(false)), + "cross_provider_dashboard": federation + .get("cross_provider_dashboard") + .cloned() + .unwrap_or(Value::Bool(false)), + "fleet_alert_exchange": federation + .get("fleet_alert_exchange") + .cloned() + .unwrap_or(Value::Bool(false)), + "federated_alert_receipts": federation + .get("federated_alert_receipts") + .cloned() + .unwrap_or(Value::Bool(false)), + "reason": federation + .get("reason") + .cloned() + .unwrap_or_else(|| Value::String("provider alert payload follows the current provider-owned alerting policy".to_string())), + }, + }) +} + +fn federated_operator_alerting_policy_json( + receipts: &[SignedAvailabilityReceipt], + tasks: &[ContentRepairTask], + storage_entries: &[ContentStorageAccountingEntry], + storage_ledger: &Value, + now: u64, + operator_alert_sink: Option<&ContentOperatorAlertSink>, + federated_operator_alert_exchange: Option<&ContentFederatedOperatorAlertExchangeClient>, +) -> Value { + let active_objects = storage_ledger + .get("active_objects") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let content_bytes = storage_ledger + .get("content_bytes") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let replica_bytes_estimate = storage_ledger + .get("replica_bytes_estimate") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let quota_enforced = storage_ledger + .get("quota_enforced") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let quota_exceeded_records = storage_entries + .iter() + .filter(|entry| { + entry + .storage_quota + .get("status") + .or_else(|| entry.quota.get("status")) + .and_then(|value| value.as_str()) + == Some("quota_exceeded") + }) + .count(); + let storage_pressure_status = if quota_exceeded_records > 0 { + "quota_exceeded" + } else if quota_enforced > 0 { + "quota_enforced" + } else if active_objects > 0 { + "accounting_observed" + } else { + "idle" + }; + + let queued_tasks = tasks.iter().filter(|task| task.status == "queued").count(); + let due_tasks = tasks + .iter() + .filter(|task| task.is_repair_candidate(true) && task.is_due(now)) + .count(); + let failed_tasks = tasks + .iter() + .filter(|task| { + task.attempts > 0 && !matches!(task.status.as_str(), "healthy" | "local_only") + }) + .count(); + let repair_pressure_status = if due_tasks > 0 { + "repair_due" + } else if queued_tasks > 0 { + "repair_queued" + } else if failed_tasks > 0 { + "repair_failures_observed" + } else { + "idle" + }; + + let live_multi_peer_proofs = receipts + .iter() + .filter(|receipt| { + receipt + .payload + .peer_selection + .get("live_multi_peer_proof") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + }) + .count(); + let mut remote_replicas = 0_u32; + let mut verified_remote_receipts = 0_u32; + for receipt in receipts { + for replica in receipt + .payload + .peer_selection + .get("replicas") + .and_then(|value| value.as_array()) + .into_iter() + .flatten() + { + if replica.get("role").and_then(|value| value.as_str()) == Some("remote") { + remote_replicas = remote_replicas.saturating_add(1); + } + if replica + .get("remote_receipt") + .and_then(|remote| remote.get("verified")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + verified_remote_receipts = verified_remote_receipts.saturating_add(1); + } + } + } + + json!({ + "schema": CONTENT_FEDERATED_OPERATOR_ALERTING_POLICY_SCHEMA, + "policy": if federated_operator_alert_exchange.is_some() { + "provider_local_dashboard_with_federated_alert_exchange" + } else if operator_alert_sink.is_some() { + "provider_local_dashboard_with_operator_alert_sink" + } else { + "provider_local_dashboard_no_federated_alerting" + }, + "scope": "content-availability", + "status": if federated_operator_alert_exchange.is_some() { + "federated_alert_exchange_configured" + } else if operator_alert_sink.is_some() { + "provider_local_alert_sink_configured" + } else if receipts.is_empty() && tasks.is_empty() && active_objects == 0 { + "idle_no_federated_dashboard" + } else { + "provider_local_dashboard_only" + }, + "authority": { + "provider": "content-provider", + "runtime_invocation_required": true, + "app_visible": false, + "raw_backend_access": false, + }, + "local_dashboard": { + "available": true, + "schema": CONTENT_OPERATOR_DASHBOARD_SCHEMA, + "provider_wide_status": true, + "per_cid_status": true, + "repair_worker_runs": true, + "status_json_only": true, + "operator_ui": "not_configured", + }, + "operator_alert_sink": operator_alert_sink + .map(ContentOperatorAlertSink::redacted_status_json) + .unwrap_or_else(|| { + json!({ + "configured": false, + "delivery": "not_configured", + "authorization_configured": false, + }) + }), + "federated_alert_exchange": federated_operator_alert_exchange + .map(ContentFederatedOperatorAlertExchangeClient::redacted_status_json) + .unwrap_or_else(|| { + json!({ + "configured": false, + "delivery": "not_configured", + "authorization_configured": false, + }) + }), + "local_signals": { + "signed_receipts": receipts.len(), + "tracked_tasks": tasks.len(), + "queued_tasks": queued_tasks, + "due_tasks": due_tasks, + "failed_tasks": failed_tasks, + "storage_pressure_status": storage_pressure_status, + "active_objects": active_objects, + "content_bytes": content_bytes, + "replica_bytes_estimate": replica_bytes_estimate, + "quota_enforced": quota_enforced, + "quota_exceeded_records": quota_exceeded_records, + "live_multi_peer_proofs": live_multi_peer_proofs, + "remote_replicas": remote_replicas, + "verified_remote_receipts": verified_remote_receipts, + "repair_pressure_status": repair_pressure_status, + }, + "federation": { + "configured": federated_operator_alert_exchange.is_some(), + "cross_provider_dashboard": false, + "alert_delivery": operator_alert_sink.is_some() || federated_operator_alert_exchange.is_some(), + "fleet_alert_exchange": federated_operator_alert_exchange.is_some(), + "federated_alert_receipts": federated_operator_alert_exchange.is_some(), + "peer_health_subscription": false, + "storage_pressure_alerts": if federated_operator_alert_exchange.is_some() { + "federated_alert_exchange" + } else if operator_alert_sink.is_some() { + "provider_local_webhook" + } else { + "provider_status_only" + }, + "repair_pressure_alerts": if federated_operator_alert_exchange.is_some() { + "federated_alert_exchange" + } else if operator_alert_sink.is_some() { + "provider_local_webhook" + } else { + "provider_status_only" + }, + "reason": if federated_operator_alert_exchange.is_some() { + "provider alert receipts can be delivered to one configured operator-owned federated exchange; cross-provider dashboards, peer-health subscriptions, operator UI, and fleet-wide SLA policy remain production-network work" + } else if operator_alert_sink.is_some() { + "provider-local alert delivery can post signed local signals to one configured operator sink; configure a federated operator alert exchange for exchange receipts, while production dashboards, peer-health subscriptions, operator UI, and fleet-wide SLA policy remain production-network work" + } else { + "this branch exposes provider-local status JSON only; federated dashboards and alert delivery require independent provider networks, signed operator subscriptions, and cross-provider alert receipts" + }, + }, + }) +} + +fn repair_fleet_status_json(tasks: &[ContentRepairTask], now: u64) -> Value { + let mut by_status: BTreeMap = BTreeMap::new(); + let mut queued = 0_u32; + let mut due = 0_u32; + let mut healthy = 0_u32; + let mut eligible_now = 0_u32; + let mut next_due_after: Option = None; + + for task in tasks { + *by_status.entry(task.status.clone()).or_insert(0) += 1; + if task.status == "queued" { + queued = queued.saturating_add(1); + } + if task.status == "healthy" { + healthy = healthy.saturating_add(1); + } + if task.is_repair_candidate(false) && task.is_due(now) { + eligible_now = eligible_now.saturating_add(1); + } + if matches!(task.status.as_str(), "queued" | "healthy") { + if task.is_due(now) { + due = due.saturating_add(1); + } else if task.next_check_after > 0 { + next_due_after = Some( + next_due_after + .map(|current| current.min(task.next_check_after)) + .unwrap_or(task.next_check_after), + ); + } + } + } + + json!({ + "schema": REPAIR_FLEET_SCHEMA, + "policy": "single_runtime_provider_repair_fleet", + "scope": "content-availability", + "status": if due > 0 { + "work_due" + } else if queued > 0 { + "scheduled" + } else if healthy > 0 { + "healthy_monitoring" + } else { + "idle" + }, + "coordinator": { + "provider": "content-provider", + "authority": "provider-owned-repair-ledger", + "app_visible": false, + }, + "workers": [{ + "provider": "content-provider", + "role": "local_repair_worker", + "manual_trigger": "elastos content repair-worker", + "scheduler_env": "ELASTOS_CONTENT_REPAIR_SCHEDULER", + "enabled_by_default": false, + "runtime_invocation_required": true, + }], + "scheduling": { + "source": "content_repair_task_ledger", + "retry_delay_secs": REPAIR_RETRY_DELAY_SECS, + "healthy_check_delay_secs": REPAIR_HEALTH_CHECK_DELAY_SECS, + "default_limit": REPAIR_WORKER_DEFAULT_LIMIT, + "max_limit": REPAIR_WORKER_MAX_LIMIT, + "default_max_attempts": REPAIR_WORKER_DEFAULT_MAX_ATTEMPTS, + "default_failure_budget": REPAIR_WORKER_DEFAULT_FAILURE_BUDGET, + "eligible_now": eligible_now, + "next_due_after": next_due_after, + }, + "task_pressure": { + "tracked": tasks.len(), + "queued": queued, + "due": due, + "healthy": healthy, + "by_status": by_status, + }, + "production_federation": { + "configured": false, + "external_workers": false, + "fleet_settlement": "not_configured", + "storage_market_admission": "not_configured", + "reason": "this branch exposes the provider-owned repair-fleet policy; external repair fleets and storage markets remain production-network work", + }, + }) +} + +fn external_repair_fleet_policy_json( + tasks: &[ContentRepairTask], + now: u64, + external_repair_fleet: Option<&ContentExternalRepairFleetClient>, +) -> Value { + let queued = tasks.iter().filter(|task| task.status == "queued").count(); + let due = tasks + .iter() + .filter(|task| task.is_repair_candidate(false) && task.is_due(now)) + .count(); + let healthy = tasks.iter().filter(|task| task.status == "healthy").count(); + json!({ + "schema": EXTERNAL_REPAIR_FLEET_POLICY_SCHEMA, + "policy": if external_repair_fleet.is_some() { + "provider_owned_repair_with_external_dispatch" + } else { + "single_runtime_provider_owned_repair" + }, + "scope": "content-availability", + "status": if external_repair_fleet.is_some() { + "external_repair_fleet_dispatch_configured" + } else { + "external_repair_fleet_not_configured" + }, + "local_runtime": { + "coordinator": "content-provider", + "worker": "content-provider", + "task_ledger": REPAIR_TASK_SCHEMA, + "manual_trigger": "elastos content repair-worker", + "scheduler_env": "ELASTOS_CONTENT_REPAIR_SCHEDULER", + "tracked_tasks": tasks.len(), + "queued": queued, + "due": due, + "healthy": healthy, + }, + "external_fleet": { + "configured": external_repair_fleet.is_some(), + "coordinator": if external_repair_fleet.is_some() { + "operator_configured_dispatch_endpoint_quorum" + } else { + "not_configured" + }, + "dispatch": external_repair_fleet + .map(ContentExternalRepairFleetClient::redacted_status_json) + .unwrap_or_else(|| { + json!({ + "configured": false, + "delivery": "not_configured", + "authorization_configured": false, + }) + }), + "workers": external_repair_fleet + .map(ContentExternalRepairFleetClient::endpoint_count) + .unwrap_or(0), + "volunteer_workers": false, + "supernode_workers": external_repair_fleet.is_some(), + "cross_provider_repair_queue": external_repair_fleet.is_some(), + "fleet_settlement": "not_configured", + "storage_market_admission": "not_configured", + }, + "federation": { + "configured": external_repair_fleet.is_some(), + "repair_task_exchange": external_repair_fleet.is_some(), + "worker_attestation_receipts": false, + "cross_provider_repair_sla": false, + "reason": if external_repair_fleet.is_some() { + "content repair-worker can dispatch due tasks to a configured external repair-fleet endpoint quorum; worker attestations, settlement, and cross-provider repair SLAs remain production work" + } else { + "this branch exposes a provider-owned local repair worker and scheduler posture only; production external repair fleets require cross-provider task exchange, worker attestations, and SLA policy" + }, + }, + }) +} + +struct ExternalRepairFleetRunSummary { + checked: u32, + repaired: u32, + failed: u32, + skipped: u32, + exhausted_attempts_skipped: u32, + throttled: bool, + external_dispatches: u32, + external_dispatch_accepted: u32, + external_dispatch_failed: u32, +} + +fn external_repair_fleet_run_policy_json( + run: ExternalRepairFleetRunSummary, + external_repair_fleet: Option<&ContentExternalRepairFleetClient>, +) -> Value { + json!({ + "schema": EXTERNAL_REPAIR_FLEET_POLICY_SCHEMA, + "policy": if external_repair_fleet.is_some() { + "provider_owned_repair_with_external_dispatch" + } else { + "single_runtime_provider_owned_repair" + }, + "scope": "content-availability", + "status": if external_repair_fleet.is_some() { + "external_repair_fleet_dispatch_configured" + } else { + "external_repair_fleet_not_configured" + }, + "run": { + "worker": "content-provider", + "checked": run.checked, + "repaired": run.repaired, + "failed": run.failed, + "skipped": run.skipped, + "exhausted_attempts_skipped": run.exhausted_attempts_skipped, + "throttled": run.throttled, + "external_dispatches": run.external_dispatches, + "external_dispatch_accepted": run.external_dispatch_accepted, + "external_dispatch_failed": run.external_dispatch_failed, + }, + "external_fleet": { + "configured": external_repair_fleet.is_some(), + "coordinator": if external_repair_fleet.is_some() { + "operator_configured_dispatch_endpoint_quorum" + } else { + "not_configured" + }, + "dispatch": external_repair_fleet + .map(ContentExternalRepairFleetClient::redacted_status_json) + .unwrap_or_else(|| { + json!({ + "configured": false, + "delivery": "not_configured", + "authorization_configured": false, + }) + }), + "workers": external_repair_fleet + .map(ContentExternalRepairFleetClient::endpoint_count) + .unwrap_or(0), + "cross_provider_repair_queue": external_repair_fleet.is_some(), + "fleet_settlement": "not_configured", + }, + }) +} + +fn repair_fleet_run_json( + checked: u32, + repaired: u32, + failed: u32, + skipped: u32, + exhausted_attempts_skipped: u32, + throttled: bool, +) -> Value { + json!({ + "schema": REPAIR_FLEET_SCHEMA, + "policy": "single_runtime_provider_repair_fleet", + "scope": "content-availability", + "run_mode": "provider_owned_worker", + "coordinator": "content-provider", + "worker": "content-provider", + "checked": checked, + "repaired": repaired, + "failed": failed, + "skipped": skipped, + "exhausted_attempts_skipped": exhausted_attempts_skipped, + "throttled": throttled, + "production_federation": { + "configured": false, + "external_workers": false, + "fleet_settlement": "not_configured", + }, + }) +} + +fn local_peer_selection_json() -> Value { + json!({ + "mode": "single_local", + "live_multi_peer_proof": false, + }) +} + +fn federated_quota_ledger_policy_json( + mode: &str, + quota_status: &str, + local_principal_ledger: bool, + remote_admission_preflight: bool, + enforced: bool, +) -> Value { + json!({ + "schema": CONTENT_FEDERATED_QUOTA_LEDGER_POLICY_SCHEMA, + "policy": "local_principal_ledger_plus_remote_admission_preflight", + "scope": "content-availability", + "status": "federated_quota_ledger_not_configured", + "quota": { + "mode": mode, + "status": quota_status, + "enforced": enforced, + }, + "local": { + "principal_storage_ledger": local_principal_ledger, + "ledger_schema": CONTENT_STORAGE_ACCOUNTING_LEDGER_SCHEMA, + "storage_quota_schema": CONTENT_STORAGE_QUOTA_SCHEMA, + }, + "remote": { + "admission_preflight": remote_admission_preflight, + "signed_admission_receipts": remote_admission_preflight, + "admission_schema": CONTENT_ADMISSION_SCHEMA, + "admission_receipt_domain": CONTENT_ADMISSION_DOMAIN, + }, + "federation": { + "configured": false, + "cross_provider_quota_ledger": false, + "storage_admission_network": false, + "signed_admission_receipt_exchange": remote_admission_preflight, + "quota_receipt_exchange": false, + "production_quota_receipt_exchange": false, + "reason": if remote_admission_preflight { + "signed remote content/admission receipt exchange exists for the proof path; federated quota ledgers and production storage-admission networks remain unconfigured" + } else { + "local per-principal quota exists, but remote signed admission and federated quota ledgers are not configured for this path" + }, + }, + }) +} + +fn default_federated_quota_ledger_policy_json() -> Value { + federated_quota_ledger_policy_json("not_reported", "not_reported", false, false, false) +} + +fn federated_quota_ledger_policy_from_quota_json( + quota: &Value, + remote_admission_preflight: bool, +) -> Value { + if let Some(policy) = quota.get("federated_quota_ledger_policy") { + return policy.clone(); + } + let quota_policy = quota + .get("policy") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported"); + let local_principal_ledger = matches!( + quota_policy, + "principal_storage_quota" | "carrier_provider_quota" + ); + federated_quota_ledger_policy_json( + quota_policy, + quota + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported"), + local_principal_ledger, + remote_admission_preflight, + quota + .get("enforced") + .and_then(|value| value.as_bool()) + .unwrap_or(false), + ) +} + +fn federated_quota_ledger_policy_from_exchange(quota: &Value, exchange: &Value) -> Value { + let quota_policy = quota + .get("policy") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported"); + let quota_status = quota + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported"); + let local_principal_ledger = matches!( + quota_policy, + "principal_storage_quota" | "carrier_provider_quota" + ); + let enforced = quota + .get("enforced") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let exchange_accepted = exchange + .get("accepted") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let exchange_status = exchange + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported"); + json!({ + "schema": CONTENT_FEDERATED_QUOTA_LEDGER_POLICY_SCHEMA, + "policy": "configured_federated_quota_ledger_exchange", + "scope": "content-availability", + "status": if exchange_accepted { + "federated_quota_ledger_accepted" + } else { + "federated_quota_ledger_rejected" + }, + "quota": { + "mode": quota_policy, + "status": quota_status, + "enforced": enforced, + }, + "local": { + "principal_storage_ledger": local_principal_ledger, + "ledger_schema": CONTENT_STORAGE_ACCOUNTING_LEDGER_SCHEMA, + "storage_quota_schema": CONTENT_STORAGE_QUOTA_SCHEMA, + }, + "remote": { + "admission_preflight": true, + "signed_admission_receipts": true, + "admission_schema": CONTENT_ADMISSION_SCHEMA, + "admission_receipt_domain": CONTENT_ADMISSION_DOMAIN, + }, + "federation": { + "configured": true, + "cross_provider_quota_ledger": true, + "storage_admission_network": false, + "signed_admission_receipt_exchange": true, + "quota_receipt_exchange": true, + "production_quota_receipt_exchange": false, + "exchange_schema": CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_SCHEMA, + "exchange_receipt_schema": CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_SCHEMA, + "exchange_status": exchange_status, + "signed_exchange_receipt_verified": exchange + .get("signed_receipt") + .and_then(|value| value.get("verified")) + .and_then(|value| value.as_bool()) + .unwrap_or(false), + "reason": if exchange_accepted { + "configured federated quota-ledger exchange accepted this admission preflight" + } else { + "configured federated quota-ledger exchange rejected or could not verify this admission preflight" + }, + }, + }) +} + +fn federated_quota_ledger_policy_status_json( + receipts: &[SignedAvailabilityReceipt], + storage_ledger: &Value, + federated_quota_ledger_exchange: Option<&ContentFederatedQuotaLedgerExchangeClient>, +) -> Value { + let mut by_status: BTreeMap = BTreeMap::new(); + let mut local_quota_receipts = 0_u32; + let mut remote_admission_receipts = 0_u32; + let mut federated_policy_receipts = 0_u32; + + for receipt in receipts { + let mut receipt_remote_admission = false; + for replica in receipt + .payload + .peer_selection + .get("replicas") + .and_then(|value| value.as_array()) + .into_iter() + .flatten() + { + if replica.get("admission").is_some() { + receipt_remote_admission = true; + remote_admission_receipts = remote_admission_receipts.saturating_add(1); + } + } + let policy = federated_quota_ledger_policy_from_quota_json( + &receipt.payload.quota, + receipt_remote_admission, + ); + let status = policy + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported") + .to_string(); + *by_status.entry(status).or_insert(0) += 1; + if policy + .get("local") + .and_then(|value| value.get("principal_storage_ledger")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + local_quota_receipts = local_quota_receipts.saturating_add(1); + } + if policy + .get("federation") + .and_then(|value| value.get("configured")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + federated_policy_receipts = federated_policy_receipts.saturating_add(1); + } + } + + json!({ + "schema": CONTENT_FEDERATED_QUOTA_LEDGER_POLICY_SCHEMA, + "policy": "provider_receipt_quota_ledger_status", + "scope": "content-availability", + "status": if federated_policy_receipts > 0 || federated_quota_ledger_exchange.is_some() { + "federated_quota_ledger_observed" + } else { + "federated_quota_ledger_not_configured" + }, + "receipt_count": receipts.len(), + "by_status": by_status, + "local": { + "principal_storage_ledger": true, + "ledger_schema": CONTENT_STORAGE_ACCOUNTING_LEDGER_SCHEMA, + "tracked_objects": storage_ledger + .get("tracked_objects") + .cloned() + .unwrap_or(Value::from(0)), + "quota_enforced": storage_ledger + .get("quota_enforced") + .cloned() + .unwrap_or(Value::from(0)), + "quota_receipts": local_quota_receipts, + }, + "remote": { + "admission_preflight": remote_admission_receipts > 0, + "admission_receipts": remote_admission_receipts, + "signed_admission_receipts": remote_admission_receipts, + "admission_schema": CONTENT_ADMISSION_SCHEMA, + "admission_receipt_domain": CONTENT_ADMISSION_DOMAIN, + }, + "federation": { + "configured": federated_policy_receipts > 0 || federated_quota_ledger_exchange.is_some(), + "cross_provider_quota_ledger": federated_policy_receipts > 0 || federated_quota_ledger_exchange.is_some(), + "storage_admission_network": false, + "signed_admission_receipt_exchange": remote_admission_receipts > 0, + "quota_receipt_exchange": federated_policy_receipts > 0 || federated_quota_ledger_exchange.is_some(), + "production_quota_receipt_exchange": false, + "exchange_client": federated_quota_ledger_exchange + .map(ContentFederatedQuotaLedgerExchangeClient::redacted_status_json) + .unwrap_or_else(|| { + json!({ + "configured": false, + "delivery": "not_configured", + "authorization_configured": false, + }) + }), + "reason": if federated_policy_receipts > 0 { + "one or more receipts reported federated quota-ledger policy" + } else if federated_quota_ledger_exchange.is_some() { + "configured federated quota-ledger exchange is enforced by content/admission before remote bytes or DAG repair data move" + } else if remote_admission_receipts > 0 { + "signed remote content/admission receipts were observed, but no federated quota ledger or production storage-admission network is configured" + } else { + "no federated quota ledger or cross-provider quota-receipt exchange is configured" + }, + }, + }) +} + +fn local_quota_json() -> Value { + json!({ + "policy": "not_enforced", + "scope": "local_content_backend", + "federated_quota_ledger_policy": default_federated_quota_ledger_policy_json(), + }) +} + +fn storage_market_admission_policy_json( + mode: &str, + market_status: &str, + quota_enforced: bool, + live_multi_peer_proof: bool, + remote_admission_preflight: bool, +) -> Value { + json!({ + "schema": CONTENT_STORAGE_MARKET_ADMISSION_POLICY_SCHEMA, + "policy": "proof_path_admission_no_production_market", + "scope": "content-availability", + "status": if remote_admission_preflight { + "remote_admission_preflight_no_market_admission" + } else if quota_enforced { + "local_quota_admission_no_market_admission" + } else { + "production_storage_market_admission_not_configured" + }, + "market": { + "mode": mode, + "status": market_status, + "quota_enforced": quota_enforced, + "live_multi_peer_proof": live_multi_peer_proof, + }, + "current_admission": { + "local_principal_quota_ledger": quota_enforced, + "remote_content_admission_preflight": remote_admission_preflight, + "signed_admission_receipts": remote_admission_preflight, + "content_admission_schema": CONTENT_ADMISSION_SCHEMA, + "content_admission_receipt_domain": CONTENT_ADMISSION_DOMAIN, + "provider_invocation_required": true, + "signed_availability_receipts": true, + }, + "production_market": { + "configured": false, + "provider_admission_network": false, + "provider_offer_receipts": false, + "price_discovery": false, + "sla_admission": false, + "abuse_economic_controls": false, + "reason": "this branch admits storage through local quota and signed bounded remote content/admission receipts; production storage-market admission needs provider offers, pricing, SLA, and trust policy receipts", + }, + }) +} + +fn default_storage_market_admission_policy_json() -> Value { + storage_market_admission_policy_json("not_reported", "not_reported", false, false, false) +} + +fn storage_market_admission_policy_from_market_json(storage_market: &Value) -> Value { + if let Some(policy) = storage_market.get("admission_policy") { + return policy.clone(); + } + storage_market_admission_policy_json( + storage_market + .get("mode") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported"), + storage_market + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported"), + storage_market + .get("quota_enforced") + .and_then(|value| value.as_bool()) + .unwrap_or(false), + storage_market + .get("live_multi_peer_proof") + .and_then(|value| value.as_bool()) + .unwrap_or(false), + storage_market + .get("remote_admission_preflight") + .and_then(|value| value.as_bool()) + .unwrap_or(false), + ) +} + +fn storage_market_admission_policy_status_json( + receipts: &[SignedAvailabilityReceipt], + storage_ledger: &Value, + storage_market_admission: Option<&ContentStorageMarketAdmissionClient>, +) -> Value { + let mut by_status: BTreeMap = BTreeMap::new(); + let mut policy_receipts = 0_u32; + let mut production_configured = 0_u32; + let mut local_quota_admission = 0_u32; + let mut remote_admission_preflight = 0_u32; + + for receipt in receipts { + let policy = + storage_market_admission_policy_from_market_json(&receipt.payload.storage_market); + policy_receipts = policy_receipts.saturating_add(1); + let status = policy + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported") + .to_string(); + *by_status.entry(status).or_insert(0) += 1; + if policy + .get("production_market") + .and_then(|value| value.get("configured")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + production_configured = production_configured.saturating_add(1); + } + if policy + .get("current_admission") + .and_then(|value| value.get("local_principal_quota_ledger")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + local_quota_admission = local_quota_admission.saturating_add(1); + } + if policy + .get("current_admission") + .and_then(|value| value.get("remote_content_admission_preflight")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + remote_admission_preflight = remote_admission_preflight.saturating_add(1); + } + } + + let ledger_policy = storage_ledger + .get("market_policy") + .map(storage_market_admission_policy_from_market_json) + .unwrap_or_else(default_storage_market_admission_policy_json); + json!({ + "schema": CONTENT_STORAGE_MARKET_ADMISSION_POLICY_SCHEMA, + "policy": "provider_receipt_storage_market_admission_status", + "scope": "content-availability", + "status": if production_configured > 0 || storage_market_admission.is_some() { + "production_storage_market_admission_observed" + } else if remote_admission_preflight > 0 { + "remote_admission_preflight_no_market_admission" + } else if local_quota_admission > 0 { + "local_quota_admission_no_market_admission" + } else { + "production_storage_market_admission_not_configured" + }, + "receipt_count": receipts.len(), + "policy_receipts": policy_receipts, + "by_status": by_status, + "ledger_policy": ledger_policy, + "current_admission": { + "local_quota_receipts": local_quota_admission, + "remote_admission_preflight_receipts": remote_admission_preflight, + "signed_admission_receipts": remote_admission_preflight, + "content_admission_schema": CONTENT_ADMISSION_SCHEMA, + "content_admission_receipt_domain": CONTENT_ADMISSION_DOMAIN, + "provider_invocation_required": true, + }, + "external_admission_client": storage_market_admission + .map(ContentStorageMarketAdmissionClient::redacted_status_json) + .unwrap_or_else(|| { + json!({ + "configured": false, + "delivery": "not_configured", + "authorization_configured": false, + }) + }), + "production_market": { + "configured": production_configured > 0 || storage_market_admission.is_some(), + "admission_policy_receipts": production_configured, + "provider_admission_network": production_configured > 0 || storage_market_admission.is_some(), + "provider_offer_receipts": storage_market_admission.is_some(), + "price_discovery": false, + "sla_admission": false, + "reason": if production_configured > 0 { + "one or more receipts reported production storage-market admission" + } else if storage_market_admission.is_some() { + "external storage-market admission is configured and enforced by content/admission before remote bytes or DAG repair data move" + } else { + "no production storage-market admission, offer, pricing, or SLA policy is configured" + }, + }, + }) +} + +fn storage_settlement_policy_json(mode: &str, market_status: &str, quota_enforced: bool) -> Value { + json!({ + "schema": CONTENT_STORAGE_SETTLEMENT_POLICY_SCHEMA, + "policy": "no_settlement_receipt_policy", + "scope": "content-availability", + "status": "settlement_not_configured", + "market": { + "mode": mode, + "status": market_status, + "quota_enforced": quota_enforced, + }, + "authority": { + "provider": "content-provider", + "runtime_invocation_required": true, + "app_visible": false, + }, + "settlement": { + "pricing": "not_configured", + "escrow": "not_configured", + "payment_settlement": "not_configured", + "sla_enforcement": "not_configured", + }, + "production_federation": { + "configured": false, + "storage_market_admission": false, + "cross_provider_escrow": false, + "settlement_receipts": false, + "reason": "this branch records availability/accounting/quota posture only; pricing, escrow, settlement, and SLA policy require production storage-market providers", + }, + }) +} + +fn default_storage_settlement_policy_json() -> Value { + storage_settlement_policy_json("not_reported", "not_reported", false) +} + +fn storage_settlement_policy_from_market_json(storage_market: &Value) -> Value { + if let Some(policy) = storage_market.get("settlement_policy") { + return policy.clone(); + } + storage_settlement_policy_json( + storage_market + .get("mode") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported"), + storage_market + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported"), + storage_market + .get("quota_enforced") + .and_then(|value| value.as_bool()) + .unwrap_or(false), + ) +} + +fn storage_settlement_policy_status_json( + receipts: &[SignedAvailabilityReceipt], + storage_ledger: &Value, +) -> Value { + let mut by_status: BTreeMap = BTreeMap::new(); + let mut settlement_configured = 0_u32; + for receipt in receipts { + let policy = storage_settlement_policy_from_market_json(&receipt.payload.storage_market); + let status = policy + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("not_reported") + .to_string(); + *by_status.entry(status).or_insert(0) += 1; + if policy + .get("production_federation") + .and_then(|value| value.get("configured")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + settlement_configured = settlement_configured.saturating_add(1); + } + } + let ledger_policy = storage_ledger + .get("market_policy") + .map(storage_settlement_policy_from_market_json) + .unwrap_or_else(default_storage_settlement_policy_json); + json!({ + "schema": CONTENT_STORAGE_SETTLEMENT_POLICY_SCHEMA, + "policy": "provider_receipt_settlement_status", + "scope": "content-availability", + "status": if settlement_configured > 0 { + "settlement_policy_observed" + } else { + "settlement_not_configured" + }, + "receipt_count": receipts.len(), + "by_status": by_status, + "ledger_policy": ledger_policy, + "production_federation": { + "configured": settlement_configured > 0, + "settlement_policy_receipts": settlement_configured, + "storage_market_admission": false, + "cross_provider_escrow": false, + "reason": if settlement_configured > 0 { + "one or more receipts reported settlement policy" + } else { + "no production storage-market settlement, escrow, or pricing policy is configured" + }, + }, + }) +} + +fn local_storage_market_json() -> Value { + json!({ + "schema": "elastos.content.storage-market/v1", + "mode": "local_content_backend", + "status": "local_no_market_settlement", + "settlement": "not_configured", + "escrow": "not_configured", + "quota_enforced": false, + "admission_policy": storage_market_admission_policy_json( + "local_content_backend", + "local_no_market_settlement", + false, + false, + false, + ), + "settlement_policy": storage_settlement_policy_json( + "local_content_backend", + "local_no_market_settlement", + false, + ), + }) +} + +fn default_content_storage_market_json() -> Value { + json!({ + "schema": "elastos.content.storage-market/v1", + "mode": "not_reported", + "status": "not_reported", + "settlement": "not_configured", + "escrow": "not_configured", + "quota_enforced": false, + "admission_policy": default_storage_market_admission_policy_json(), + "settlement_policy": default_storage_settlement_policy_json(), + }) +} + +fn is_default_content_storage_market_json(value: &Value) -> bool { + value == &default_content_storage_market_json() +} + +fn local_repair_graph_json() -> Value { + json!({ + "schema": "elastos.content.repair-graph/v1", + "policy": "content_provider_local_backend", + "requested_kind": "auto", + "status": "local_backend_only", + "supported_import_fallbacks": ["object_manifest", "exact_bytes"], + "refuses_exact_fallback_for_arbitrary_dag": true, + }) +} + +fn default_content_repair_graph_json() -> Value { + json!({ + "schema": "elastos.content.repair-graph/v1", + "policy": "not_reported", + "requested_kind": "auto", + "status": "not_reported", + "supported_import_fallbacks": ["object_manifest", "exact_bytes"], + "refuses_exact_fallback_for_arbitrary_dag": true, + }) +} + +fn is_default_content_repair_graph_json(value: &Value) -> bool { + value == &default_content_repair_graph_json() +} + +fn local_abuse_controls_json() -> Value { + json!({ + "schema": CONTENT_ABUSE_CONTROLS_SCHEMA, + "policy": "local_content_backend", + "scope": "content-availability", + "enforced": false, + "throttled": false, + "attempted_operations": 0, + "failed_operations": 0, + }) +} + +fn provider_abuse_controls_json() -> Value { + json!({ + "schema": CONTENT_ABUSE_CONTROLS_SCHEMA, + "policy": "availability_provider_not_reported", + "scope": "content-availability", + "enforced": false, + "throttled": false, + "attempted_operations": 0, + "failed_operations": 0, + }) +} + +fn default_content_abuse_controls_json() -> Value { + provider_abuse_controls_json() +} + +#[derive(Debug, Clone, Copy, Default)] +struct ContentAccountingObservation { + files: Option, + bytes: Option, +} + +fn content_accounting_observation_from_publish_request( + request: &Value, +) -> ContentAccountingObservation { + match request.get("kind").and_then(|kind| kind.as_str()) { + Some("file") => ContentAccountingObservation { + files: Some(1), + bytes: request.get("data").and_then(decoded_base64_len), + }, + Some("directory") => content_accounting_observation_from_files( + request.get("files").unwrap_or(&Value::Array(Vec::new())), + ), + _ => ContentAccountingObservation::default(), + } +} + +fn content_accounting_observation_from_files(files: &Value) -> ContentAccountingObservation { + let Some(files) = files.as_array() else { + return ContentAccountingObservation::default(); + }; + let mut bytes = 0_u64; + for file in files { + let Some(file_bytes) = file.get("data").and_then(decoded_base64_len) else { + return ContentAccountingObservation { + files: Some(files.len() as u64), + bytes: None, + }; + }; + bytes = bytes.saturating_add(file_bytes); + } + ContentAccountingObservation { + files: Some(files.len() as u64), + bytes: Some(bytes), + } +} + +fn content_accounting_observation_from_value(accounting: &Value) -> ContentAccountingObservation { + ContentAccountingObservation { + files: accounting.get("files").and_then(|value| value.as_u64()), + bytes: accounting + .get("content_bytes") + .and_then(|value| value.as_u64()), + } +} + +fn admission_content_bytes_from_request(request: &Value) -> Option { + ["estimated_content_bytes", "incoming_content_bytes"] + .into_iter() + .find_map(|field| request.get(field).and_then(|value| value.as_u64())) + .or_else(|| { + request + .get("accounting") + .and_then(|accounting| accounting.get("content_bytes")) + .and_then(|value| value.as_u64()) + }) + .or_else(|| { + request + .get("local") + .and_then(|local| local.get("accounting")) + .and_then(|accounting| accounting.get("content_bytes")) + .and_then(|value| value.as_u64()) + }) +} + +fn decoded_base64_len(value: &Value) -> Option { + base64::engine::general_purpose::STANDARD + .decode(value.as_str()?) + .ok() + .map(|bytes| bytes.len() as u64) +} + +fn content_accounting_json( + source: &str, + observation: ContentAccountingObservation, + replicas: u32, +) -> Value { + content_accounting_json_with_storage_quota( + source, + observation, + replicas, + default_storage_quota_json(), + ) +} + +fn content_accounting_json_with_storage_quota( + source: &str, + observation: ContentAccountingObservation, + replicas: u32, + storage_quota: Value, +) -> Value { + let replica_bytes = observation + .bytes + .map(|bytes| bytes.saturating_mul(replicas as u64)); + json!({ + "schema": CONTENT_ACCOUNTING_SCHEMA, + "policy": "content_provider_local_accounting", + "scope": "content-availability", + "source": source, + "observed": observation.files.is_some() || observation.bytes.is_some(), + "files": observation.files, + "content_bytes": observation.bytes, + "replicas": replicas, + "replica_bytes_estimate": replica_bytes, + "storage_quota": storage_quota, + }) +} + +fn default_storage_quota_json() -> Value { + json!({ + "schema": CONTENT_STORAGE_QUOTA_SCHEMA, + "policy": "observed_not_enforced", + "scope": "content-availability", + "enforced": false, + "status": "observed_not_enforced", + "reason": "content-provider records local storage accounting; no principal storage quota was requested", + "federated_quota_ledger_policy": federated_quota_ledger_policy_json( + "observed_not_enforced", + "observed_not_enforced", + true, + false, + false, + ), + }) +} + +fn content_storage_accounting_entry_from_receipt( + receipt: &AvailabilityReceipt, +) -> ContentStorageAccountingEntry { + let observation = content_accounting_observation_from_value(&receipt.accounting); + let replica_bytes_estimate = receipt + .accounting + .get("replica_bytes_estimate") + .and_then(|value| value.as_u64()) + .or_else(|| { + observation + .bytes + .map(|bytes| bytes.saturating_mul(receipt.replicas as u64)) + }); + let storage_quota = receipt + .accounting + .get("storage_quota") + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(|| { + json!({ + "schema": CONTENT_STORAGE_QUOTA_SCHEMA, + "policy": "not_reported", + "scope": "content-availability", + "enforced": false, + "status": "not_reported", + }) + }); + + ContentStorageAccountingEntry { + schema: CONTENT_STORAGE_ACCOUNTING_ENTRY_SCHEMA.to_string(), + cid: receipt.cid.clone(), + uri: receipt.uri.clone(), + object_did: receipt.object_did.clone(), + principal_did: receipt.publisher_did.clone(), + provider: receipt.provider.clone(), + policy: receipt.policy.clone(), + status: receipt.status.clone(), + source: receipt + .accounting + .get("source") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + .to_string(), + files: observation.files, + content_bytes: observation.bytes, + replicas: receipt.replicas, + replica_bytes_estimate, + quota: receipt.quota.clone(), + storage_quota, + recorded_at: receipt.checked_at, + } +} + +fn default_content_accounting_json() -> Value { + content_accounting_json("legacy_receipt", ContentAccountingObservation::default(), 0) +} + +fn availability_quota_status(quota: &Value) -> String { + quota + .get("status") + .and_then(|value| value.as_str()) + .or_else(|| { + quota + .get("policy") + .and_then(|value| value.as_str()) + .filter(|policy| *policy == "not_enforced") + }) + .unwrap_or("unknown") + .to_string() +} + +fn repair_worker_json(scheduled: bool) -> Value { + json!({ + "scheduled": scheduled, + "status": if scheduled { "needed" } else { "not_scheduled" }, + }) +} + +fn provider_ok(data: Value) -> Value { + json!({ + "status": "ok", + "data": data, + }) +} + +fn provider_error(code: &str, message: &str) -> Value { + json!({ + "status": "error", + "code": code, + "message": message, + }) +} + +fn validate_import_exact_invocation(request: &Value) -> Result<(), ProviderError> { + let runtime = runtime_invocation_object( + request, + "content import_exact requires Runtime provider invocation metadata", + )?; + validate_runtime_invocation_fields( + runtime, + "content import_exact", + &[ + ("schema", "elastos.provider.invocation/v1"), + ("source", "carrier-availability"), + ("target", "content"), + ("op", "import_exact"), + ("transport", "carrier-provider-plane"), + ], + )?; + if !matches!( + runtime.get("transfer").and_then(|value| value.as_str()), + Some("json" | "bytes" | "stream") + ) { + return Err(ProviderError::Provider( + "content import_exact transfer must be json, bytes, or stream".into(), + )); + } + Ok(()) +} + +fn validate_import_object_invocation(request: &Value) -> Result<(), ProviderError> { + let runtime = runtime_invocation_object( + request, + "content import_object requires Runtime provider invocation metadata", + )?; + validate_runtime_invocation_fields( + runtime, + "content import_object", + &[ + ("schema", "elastos.provider.invocation/v1"), + ("source", "carrier-availability"), + ("target", "content"), + ("op", "import_object"), + ("transport", "carrier-provider-plane"), + ("transfer", "json"), + ], + ) +} + +fn validate_repair_worker_invocation(request: &Value) -> Result<(), ProviderError> { + let runtime = runtime_invocation_object( + request, + "content repair_worker requires Runtime provider invocation metadata", + )?; + validate_runtime_invocation_fields( + runtime, + "content repair_worker", + &[ + ("schema", "elastos.provider.invocation/v1"), + ("source", "content-provider"), + ("target", "content"), + ("op", "repair_worker"), + ("transport", "runtime-local-provider-plane"), + ("transfer", "json"), + ], + ) +} + +fn runtime_invocation_object<'a>( + request: &'a Value, + missing_message: &str, +) -> Result<&'a serde_json::Map, ProviderError> { + request + .get("_runtime_invocation") + .and_then(|value| value.as_object()) + .ok_or_else(|| ProviderError::Provider(missing_message.to_string())) +} + +fn validate_runtime_invocation_fields( + runtime: &serde_json::Map, + label: &str, + fields: &[(&str, &str)], +) -> Result<(), ProviderError> { + for (field, expected) in fields { + let actual = runtime + .get(*field) + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if actual != *expected { + return Err(ProviderError::Provider(format!( + "{label} runtime field {field} mismatch: expected {expected}, got {actual}" + ))); + } + } + Ok(()) +} + +fn import_exact_payload_bytes(request: &Value) -> Result, ProviderError> { + let bytes = if let Some(data) = request.get("data").and_then(|value| value.as_str()) { + base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|err| { + ProviderError::Provider(format!("content import_exact data must be base64: {err}")) + })? + } else if let Some(stream) = request.get("stream") { + provider_stream_payload_bytes(stream)? + } else { + return Err(ProviderError::Provider( + "content import_exact requires data or stream".into(), + )); + }; + if bytes.len() > IMPORT_EXACT_MAX_BYTES { + return Err(ProviderError::Provider(format!( + "content import_exact payload exceeds {} bytes", + IMPORT_EXACT_MAX_BYTES + ))); + } + Ok(bytes) +} + +fn validate_import_object_payload_bounds(files: &Value) -> Result<(usize, usize), ProviderError> { + let files = files.as_array().ok_or_else(|| { + ProviderError::Provider("content import_object files must be an array".into()) + })?; + if files.is_empty() { + return Err(ProviderError::Provider( + "content import_object requires at least one file".into(), + )); + } + if files.len() > IMPORT_OBJECT_MAX_FILES { + return Err(ProviderError::Provider(format!( + "content import_object file count exceeds {IMPORT_OBJECT_MAX_FILES}" + ))); + } + let mut total_bytes = 0_usize; + for file in files { + let path = file + .get("path") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + let data = file + .get("data") + .and_then(|value| value.as_str()) + .ok_or_else(|| { + ProviderError::Provider(format!( + "content import_object file {path} is missing base64 data" + )) + })?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|err| { + ProviderError::Provider(format!( + "content import_object file {path} has invalid base64 data: {err}" + )) + })?; + total_bytes = total_bytes.saturating_add(decoded.len()); + if total_bytes > IMPORT_EXACT_MAX_BYTES { + return Err(ProviderError::Provider(format!( + "content import_object payload exceeds {} bytes", + IMPORT_EXACT_MAX_BYTES + ))); + } + } + Ok((files.len(), total_bytes)) +} + +fn provider_stream_payload_bytes(stream: &Value) -> Result, ProviderError> { + let object = stream.as_object().ok_or_else(|| { + ProviderError::Provider("content import_exact stream must be an object".into()) + })?; + let schema = object + .get("schema") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if schema != "elastos.provider.stream/v1" { + return Err(ProviderError::Provider(format!( + "content import_exact stream schema mismatch: expected elastos.provider.stream/v1, got {schema}" + ))); + } + let encoding = object + .get("encoding") + .and_then(|value| value.as_str()) + .unwrap_or_default(); + if encoding != "base64-chunks" { + return Err(ProviderError::Provider(format!( + "content import_exact stream encoding mismatch: expected base64-chunks, got {encoding}" + ))); + } + let chunks = object + .get("chunks") + .and_then(|value| value.as_array()) + .ok_or_else(|| { + ProviderError::Provider("content import_exact stream missing chunks".into()) + })?; + let mut bytes = Vec::new(); + for (expected_index, chunk) in chunks.iter().enumerate() { + let chunk = chunk.as_object().ok_or_else(|| { + ProviderError::Provider("content import_exact stream chunk must be an object".into()) + })?; + let index = chunk + .get("index") + .and_then(|value| value.as_u64()) + .ok_or_else(|| { + ProviderError::Provider("content import_exact stream chunk missing index".into()) + })?; + if index != expected_index as u64 { + return Err(ProviderError::Provider(format!( + "content import_exact stream chunk index mismatch: expected {expected_index}, got {index}" + ))); + } + let offset = chunk + .get("offset") + .and_then(|value| value.as_u64()) + .ok_or_else(|| { + ProviderError::Provider("content import_exact stream chunk missing offset".into()) + })?; + if offset != bytes.len() as u64 { + return Err(ProviderError::Provider(format!( + "content import_exact stream chunk {index} offset mismatch: expected {}, got {offset}", + bytes.len() + ))); + } + let encoded = chunk + .get("data") + .and_then(|value| value.as_str()) + .ok_or_else(|| { + ProviderError::Provider("content import_exact stream chunk missing data".into()) + })?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|err| { + ProviderError::Provider(format!( + "content import_exact stream chunk has invalid base64: {err}" + )) + })?; + if bytes.len().saturating_add(decoded.len()) > IMPORT_EXACT_MAX_BYTES { + return Err(ProviderError::Provider(format!( + "content import_exact payload exceeds {} bytes", + IMPORT_EXACT_MAX_BYTES + ))); + } + if let Some(length) = chunk.get("length").and_then(|value| value.as_u64()) { + if length != decoded.len() as u64 { + return Err(ProviderError::Provider(format!( + "content import_exact stream chunk {index} length {length} does not match decoded length {}", + decoded.len() + ))); + } + } + bytes.extend_from_slice(&decoded); + } + if let Some(total_bytes) = object.get("total_bytes").and_then(|value| value.as_u64()) { + if total_bytes != bytes.len() as u64 { + return Err(ProviderError::Provider(format!( + "content import_exact stream total_bytes {total_bytes} does not match decoded length {}", + bytes.len() + ))); + } + } + Ok(bytes) +} + +fn parse_availability_provider_response( + response: &Value, + requested_policy: &str, + local: &AvailabilityOutcome, + requirements: &AvailabilityRequirements, +) -> AvailabilityOutcome { + if response.get("status").and_then(|status| status.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|message| message.as_str()) + .unwrap_or("availability provider returned an error") + .to_string(); + return AvailabilityOutcome::repair_needed( + "availability-provider", + requested_policy, + local.replicas, + message, + ); + } + + let data = response.get("data").unwrap_or(response); + let availability = data.get("availability").unwrap_or(data); + let provider = availability + .get("provider") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("availability-provider"); + let policy = availability + .get("policy") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(requested_policy); + let replicas = availability + .get("replicas") + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(local.replicas); + let status = availability + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or(""); + + match status { + "network_available" if replicas > 0 => { + match validate_network_availability_claim(status, replicas, availability, requirements) + { + Ok(policy_metadata) => AvailabilityOutcome { + provider: provider.to_string(), + policy: policy.to_string(), + status: status.to_string(), + replicas, + reason: None, + peer_selection: policy_metadata.0, + quota: policy_metadata.1, + repair_worker: policy_metadata.2, + storage_market: policy_metadata.3, + repair_graph: policy_metadata.4, + abuse_controls: policy_metadata.5, + }, + Err(reason) => { + AvailabilityOutcome::repair_needed(provider, policy, local.replicas, reason) + } + } + } + "carrier_announced" if replicas > 0 => { + match validate_network_availability_claim(status, replicas, availability, requirements) + { + Ok(policy_metadata) => AvailabilityOutcome { + provider: provider.to_string(), + policy: policy.to_string(), + status: status.to_string(), + replicas, + reason: availability + .get("reason") + .and_then(|value| value.as_str()) + .map(str::to_string), + peer_selection: policy_metadata.0, + quota: policy_metadata.1, + repair_worker: policy_metadata.2, + storage_market: policy_metadata.3, + repair_graph: policy_metadata.4, + abuse_controls: policy_metadata.5, + }, + Err(reason) => { + AvailabilityOutcome::repair_needed(provider, policy, local.replicas, reason) + } + } + } + "repair_needed" => AvailabilityOutcome::repair_needed( + provider, + policy, + replicas, + availability + .get("reason") + .and_then(|value| value.as_str()) + .unwrap_or("availability provider reported repair_needed") + .to_string(), + ), + "network_available" => AvailabilityOutcome::repair_needed( + provider, + policy, + local.replicas, + "availability provider reported network_available without replicas".to_string(), + ), + "carrier_announced" => AvailabilityOutcome::repair_needed( + provider, + policy, + local.replicas, + "Carrier availability announcement did not include a local replica".to_string(), + ), + _ => AvailabilityOutcome::repair_needed( + provider, + policy, + local.replicas, + "availability provider returned an unsupported status".to_string(), + ), + } +} + +fn validate_network_availability_claim( + status: &str, + replicas: u32, + availability: &Value, + requirements: &AvailabilityRequirements, +) -> Result<(Value, Value, Value, Value, Value, Value), String> { + if replicas < requirements.min_replicas { + return Err(format!( + "availability provider reported {replicas} replicas below required {}", + requirements.min_replicas + )); + } + if let Some(max_replicas) = requirements.max_replicas { + if replicas > max_replicas { + return Err(format!( + "availability provider reported {replicas} replicas over quota {max_replicas}" + )); + } + } + + let peer_selection = availability + .get("peer_selection") + .filter(|value| value.is_object()) + .cloned() + .ok_or_else(|| "network availability requires peer_selection metadata".to_string())?; + let quota = availability + .get("quota") + .filter(|value| value.is_object()) + .cloned() + .ok_or_else(|| "network availability requires quota metadata".to_string())?; + let repair_worker = availability + .get("repair_worker") + .filter(|value| value.is_object()) + .cloned() + .ok_or_else(|| "network availability requires repair_worker metadata".to_string())?; + let abuse_controls = availability + .get("abuse_controls") + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(provider_abuse_controls_json); + let storage_market = availability + .get("storage_market") + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(default_content_storage_market_json); + let repair_graph = availability + .get("repair_graph") + .filter(|value| value.is_object()) + .cloned() + .unwrap_or_else(default_content_repair_graph_json); + + let peer_selection_mode = peer_selection + .get("mode") + .or_else(|| peer_selection.get("strategy")) + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()); + if peer_selection_mode.is_none() { + return Err("network availability peer_selection requires mode or strategy".to_string()); + } + + let quota_policy = quota + .get("policy") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()); + if quota_policy.is_none() { + return Err("network availability quota requires policy".to_string()); + } + if let Some(max_replicas) = quota + .get("max_replicas") + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + { + if replicas > max_replicas { + return Err(format!( + "availability provider reported {replicas} replicas above quota max_replicas {max_replicas}" + )); + } + } + + let repair_status = repair_worker + .get("status") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()); + if repair_status.is_none() { + return Err("network availability repair_worker requires status".to_string()); + } + + let live_proof = peer_selection + .get("live_multi_peer_proof") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + if replicas > 1 && !live_proof { + return Err("multi-peer availability requires live_multi_peer_proof=true".to_string()); + } + if requirements.require_live_multi_peer_proof && !live_proof { + return Err("availability requirements demand live_multi_peer_proof=true".to_string()); + } + if status == "carrier_announced" { + let topic = peer_selection + .get("topic") + .or_else(|| availability.get("topic")) + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()); + if topic.is_none() { + return Err("Carrier availability announcement requires a topic".to_string()); + } + } + + Ok(( + peer_selection, + quota, + repair_worker, + storage_market, + repair_graph, + abuse_controls, + )) +} + +fn provider_response_cid(response: &Value) -> Result { + provider_response_ok(response, "content publish")?; + response + .get("data") + .and_then(|data| data.get("cid")) + .and_then(|cid| cid.as_str()) + .map(str::to_string) + .ok_or_else(|| ProviderError::Provider("content backend response missing cid".into())) +} + +fn content_response_cid(response: &Value) -> anyhow::Result { + if response.get("status").and_then(|status| status.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|message| message.as_str()) + .unwrap_or("unknown error"); + anyhow::bail!("content publish failed: {message}"); + } + response + .get("data") + .and_then(|data| data.get("cid")) + .and_then(|cid| cid.as_str()) + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("No CID in content provider response")) +} + +fn provider_response_ok(response: &Value, operation: &str) -> Result<(), ProviderError> { + if response.get("status").and_then(|status| status.as_str()) == Some("error") { + let message = response + .get("message") + .and_then(|message| message.as_str()) + .unwrap_or("unknown error"); + return Err(ProviderError::Provider(format!( + "{operation} failed: {message}" + ))); + } + Ok(()) +} + +fn provider_response_data(response: &Value, provider_name: &str) -> Result { + response + .get("data") + .and_then(|data| data.get("data")) + .and_then(|data| data.as_str()) + .map(str::to_string) + .ok_or_else(|| { + ProviderError::Provider(format!("{provider_name} response missing base64 data")) + }) +} + +fn provider_response_stream(response: &Value, provider_name: &str) -> Result { + response + .get("data") + .and_then(|data| data.get("stream")) + .cloned() + .ok_or_else(|| { + ProviderError::Provider(format!("{provider_name} response missing stream payload")) + }) +} + +fn provider_response_payload( + response: &Value, + provider_name: &str, + transfer: &ContentFetchTransfer, +) -> Result { + match transfer.transfer { + ProviderTransfer::Stream => Ok(ContentFetchPayload::Stream(provider_response_stream( + response, + provider_name, + )?)), + _ => Ok(ContentFetchPayload::Bytes(provider_response_data( + response, + provider_name, + )?)), + } +} + +fn provider_transfer_value(response: &Value) -> Option { + response.get("_runtime_transfer").cloned() +} + +fn is_valid_cid(value: &str) -> bool { + cid::Cid::try_from(value).is_ok() +} + +fn validate_content_path(path: &str) -> Result<(), String> { + if path.is_empty() { + return Ok(()); + } + if path.starts_with('/') || path.starts_with('\\') { + return Err("content fetch path must be relative".to_string()); + } + if path.contains('\\') || path.contains('\0') { + return Err("content fetch path contains invalid characters".to_string()); + } + for segment in path.split('/') { + if segment.is_empty() || segment == "." || segment == ".." { + return Err("content fetch path contains an invalid segment".to_string()); + } + } + Ok(()) +} + +fn with_directory_object_manifest( + files: Value, + kind: &str, + object_did: Option<&str>, + publisher_did: Option<&str>, + links: Option<&Value>, +) -> Result { + let mut files = files + .as_array() + .cloned() + .ok_or_else(|| ProviderError::Provider("files must be an array".into()))?; + let manifest = directory_object_manifest(&files, kind, object_did, publisher_did, links)?; + let manifest_bytes = serde_json::to_vec_pretty(&manifest).map_err(|err| { + ProviderError::Provider(format!("content object manifest encode failed: {err}")) + })?; + files.push(json!({ + "path": OBJECT_MANIFEST_PATH, + "data": base64::engine::general_purpose::STANDARD.encode(manifest_bytes), + })); + sort_directory_entries(&mut files)?; + Ok(Value::Array(files)) +} + +fn directory_object_manifest( + files: &[Value], + kind: &str, + object_did: Option<&str>, + publisher_did: Option<&str>, + links: Option<&Value>, +) -> Result { + let kind = validate_content_object_kind(kind)?; + let links = parse_content_object_links(links)?; + let mut seen_paths = BTreeSet::new(); + let mut object_files = Vec::with_capacity(files.len()); + let mut sealed_object = None; + for file in files { + let path = file + .get("path") + .and_then(|path| path.as_str()) + .filter(|path| !path.trim().is_empty()) + .ok_or_else(|| { + ProviderError::Provider("directory publish file is missing path".into()) + })?; + if path == OBJECT_MANIFEST_PATH { + return Err(ProviderError::Provider(format!( + "{OBJECT_MANIFEST_PATH} is reserved for the content object manifest" + ))); + } + validate_content_path(path).map_err(ProviderError::Provider)?; + if !seen_paths.insert(path.to_string()) { + return Err(ProviderError::Provider(format!( + "duplicate directory publish path: {path}" + ))); + } + let data = file + .get("data") + .and_then(|data| data.as_str()) + .ok_or_else(|| { + ProviderError::Provider(format!( + "directory publish file {path} is missing base64 data" + )) + })?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|err| { + ProviderError::Provider(format!( + "directory publish file {path} has invalid base64 data: {err}" + )) + })?; + if kind == "sealed" && path == SEALED_OBJECT_PATH { + let sealed: SealedObjectV1 = serde_json::from_slice(&bytes).map_err(|err| { + ProviderError::Provider(format!( + "sealed content object has invalid {SEALED_OBJECT_PATH}: {err}" + )) + })?; + validate_sealed_object_descriptor(&sealed)?; + sealed_object = Some(sealed); + } + object_files.push(ContentObjectFile { + path: path.to_string(), + sha256: format!("{:x}", sha2::Sha256::digest(&bytes)), + size: bytes.len() as u64, + }); + } + object_files.sort_by(|a, b| a.path.cmp(&b.path)); + if kind == "sealed" { + let sealed_object = sealed_object.ok_or_else(|| { + ProviderError::Provider(format!( + "sealed content object requires {SEALED_OBJECT_PATH}" + )) + })?; + validate_sealed_content_links(&sealed_object, &links)?; + } + + let mut hasher = sha2::Sha256::new(); + for file in &object_files { + hasher.update(file.path.as_bytes()); + hasher.update(b"\0"); + hasher.update(file.sha256.as_bytes()); + hasher.update(b"\0"); + hasher.update(file.size.to_string().as_bytes()); + hasher.update(b"\0"); + } + + Ok(ContentObjectManifest { + schema: OBJECT_MANIFEST_SCHEMA.to_string(), + kind, + content_digest: format!("sha256:{:x}", hasher.finalize()), + files: object_files, + links, + object_did: object_did.map(str::to_string), + publisher_did: publisher_did.map(str::to_string), + }) +} + +fn validate_content_object_kind(kind: &str) -> Result { + match kind { + "capsule" | "directory" | "document" | "release" | "sealed" | "share" | "site" => { + Ok(kind.to_string()) + } + _ => Err(ProviderError::Provider(format!( + "unsupported content object kind: {kind}" + ))), + } +} + +fn parse_content_object_links( + links: Option<&Value>, +) -> Result, ProviderError> { + let Some(links) = links else { + return Ok(Vec::new()); + }; + let links = links + .as_array() + .ok_or_else(|| ProviderError::Provider("content object links must be an array".into()))?; + let mut parsed = Vec::with_capacity(links.len()); + let mut seen = BTreeSet::new(); + for link in links { + let rel = link + .get("rel") + .and_then(|rel| rel.as_str()) + .filter(|rel| !rel.trim().is_empty()) + .ok_or_else(|| ProviderError::Provider("content object link is missing rel".into()))?; + validate_content_object_link_rel(rel)?; + let cid = link + .get("cid") + .and_then(|cid| cid.as_str()) + .filter(|cid| !cid.trim().is_empty()) + .ok_or_else(|| ProviderError::Provider("content object link is missing cid".into()))?; + cid::Cid::try_from(cid).map_err(|err| { + ProviderError::Provider(format!("invalid content object link cid: {err}")) + })?; + if !seen.insert((rel.to_string(), cid.to_string())) { + return Err(ProviderError::Provider(format!( + "duplicate content object link: {rel} {cid}" + ))); + } + parsed.push(ContentObjectLink { + rel: rel.to_string(), + cid: cid.to_string(), + }); + } + parsed.sort_by(|a, b| a.rel.cmp(&b.rel).then_with(|| a.cid.cmp(&b.cid))); + Ok(parsed) +} + +fn validate_sealed_object_descriptor(object: &SealedObjectV1) -> Result<(), ProviderError> { + if object.schema != SEALED_OBJECT_SCHEMA { + return Err(ProviderError::Provider( + "sealed content object schema is unsupported".to_string(), + )); + } + validate_linked_cid(&object.payload_cid, "payload_cid")?; + validate_linked_cid(&object.rights_policy_cid, "rights_policy_cid")?; + validate_linked_cid(&object.availability_receipt_cid, "availability_receipt_cid")?; + require_field(&object.key_envelope.scheme, "key_envelope.scheme")?; + require_field(&object.key_envelope.kid, "key_envelope.kid")?; + require_field(&object.key_envelope.wrapped_cek, "key_envelope.wrapped_cek")?; + require_field(&object.key_envelope.policy_hash, "key_envelope.policy_hash")?; + validate_protected_content_key_envelope_algorithms(&object.key_envelope.algorithms) + .map_err(|err| ProviderError::Provider(format!("sealed content object {err}")))?; + require_field( + &object.viewer.required_interface, + "viewer.required_interface", + ) +} + +fn validate_sealed_content_links( + object: &SealedObjectV1, + links: &[ContentObjectLink], +) -> Result<(), ProviderError> { + require_link(links, "payload", &object.payload_cid)?; + require_link(links, "rights.policy", &object.rights_policy_cid)?; + require_link( + links, + "availability.receipt", + &object.availability_receipt_cid, + )?; + if !links.iter().any(|link| link.rel == "provenance") { + return Err(ProviderError::Provider( + "sealed content object requires provenance link".to_string(), + )); + } + Ok(()) +} + +fn require_link(links: &[ContentObjectLink], rel: &str, cid: &str) -> Result<(), ProviderError> { + if links.iter().any(|link| link.rel == rel && link.cid == cid) { + Ok(()) + } else { + Err(ProviderError::Provider(format!( + "sealed content object requires {rel} link to {cid}" + ))) + } +} + +fn validate_linked_cid(value: &str, field: &str) -> Result<(), ProviderError> { + require_field(value, field)?; + cid::Cid::try_from(value) + .map(|_| ()) + .map_err(|err| ProviderError::Provider(format!("invalid sealed object {field}: {err}"))) +} + +fn require_field(value: &str, field: &str) -> Result<(), ProviderError> { + if value.trim().is_empty() { + Err(ProviderError::Provider(format!( + "sealed content object {field} is required" + ))) + } else { + Ok(()) + } +} + +fn validate_content_object_link_rel(rel: &str) -> Result<(), ProviderError> { + if rel.len() > 64 { + return Err(ProviderError::Provider( + "content object link rel is too long".into(), + )); + } + if !rel.bytes().all(|b| { + b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_' || b == b'.' + }) { + return Err(ProviderError::Provider( + "content object link rel must use lowercase ASCII, digits, '-', '_', or '.'".into(), + )); + } + Ok(()) +} + +fn sort_directory_entries(files: &mut [Value]) -> Result<(), ProviderError> { + for file in files.iter() { + file.get("path") + .and_then(|path| path.as_str()) + .filter(|path| !path.trim().is_empty()) + .ok_or_else(|| { + ProviderError::Provider("directory publish file is missing path".into()) + })?; + } + files.sort_by(|a, b| { + let a = a.get("path").and_then(|path| path.as_str()).unwrap_or(""); + let b = b.get("path").and_then(|path| path.as_str()).unwrap_or(""); + a.cmp(b) + }); + Ok(()) +} + +async fn materialize_data_capsule( + registry: &ProviderRegistry, + cid: &str, + manifest: &elastos_common::CapsuleManifest, + manifest_bytes: &[u8], + capsule_dir: &Path, +) -> anyhow::Result<()> { + let object_manifest_bytes = + fetch_bytes_via_provider(registry, cid, Some(OBJECT_MANIFEST_PATH)) + .await + .map_err(|err| { + anyhow::anyhow!( + "published data capsule {cid} is missing {OBJECT_MANIFEST_PATH}; republish it through content availability: {err}" + ) + })?; + let object_manifest = parse_content_object_manifest(cid, &object_manifest_bytes)?; + + write_materialized_file(capsule_dir, OBJECT_MANIFEST_PATH, &object_manifest_bytes).await?; + + let mut saw_capsule_manifest = false; + for file in &object_manifest.files { + validate_content_path(&file.path).map_err(|err| anyhow::anyhow!("{err}"))?; + if file.path == OBJECT_MANIFEST_PATH { + anyhow::bail!("{OBJECT_MANIFEST_PATH} cannot appear inside its own file list"); + } + + let bytes = if file.path == "capsule.json" { + saw_capsule_manifest = true; + manifest_bytes.to_vec() + } else { + fetch_bytes_via_provider(registry, cid, Some(&file.path)).await? + }; + verify_content_object_file(cid, file, &bytes)?; + write_materialized_file(capsule_dir, &file.path, &bytes).await?; + } + + if !saw_capsule_manifest { + anyhow::bail!("published data capsule {cid} object manifest is missing capsule.json"); + } + + let entrypoint_path = capsule_dir.join(&manifest.entrypoint); + if !entrypoint_path.is_file() { anyhow::bail!( "Data capsule entrypoint '{}' missing after content materialization from CID {}", manifest.entrypoint, @@ -1395,253 +8401,3317 @@ async fn materialize_data_capsule( ); } - Ok(()) -} + Ok(()) +} + +pub fn verify_content_object_file( + cid: &str, + file: &ContentObjectFile, + bytes: &[u8], +) -> anyhow::Result<()> { + if file.size != bytes.len() as u64 { + anyhow::bail!( + "content object file size mismatch for {}/{}: expected {}, got {}", + cid, + file.path, + file.size, + bytes.len() + ); + } + let actual_hash = format!("{:x}", sha2::Sha256::digest(bytes)); + if file.sha256 != actual_hash { + anyhow::bail!( + "content object file hash mismatch for {}/{}", + cid, + file.path + ); + } + Ok(()) +} + +async fn write_materialized_file(base: &Path, rel_path: &str, bytes: &[u8]) -> anyhow::Result<()> { + validate_content_path(rel_path).map_err(|err| anyhow::anyhow!("{err}"))?; + let path = base.join(rel_path); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(path, bytes).await?; + Ok(()) +} + +fn append_jsonl(path: &Path, entry: &T) -> Result<(), ProviderError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut file = OpenOptions::new().create(true).append(true).open(path)?; + serde_json::to_writer(&mut file, entry) + .map_err(|err| ProviderError::Provider(format!("content receipt write failed: {err}")))?; + file.write_all(b"\n")?; + Ok(()) +} + +fn verify_signed_receipt(receipt: &SignedAvailabilityReceipt) -> Result<(), ProviderError> { + let envelope = serde_json::to_vec(receipt) + .map_err(|err| ProviderError::Provider(format!("content receipt encode failed: {err}")))?; + crate::crypto::verify_signed_json_envelope_against_dids( + &envelope, + AVAILABILITY_RECEIPT_DOMAIN, + std::slice::from_ref(&receipt.signer_did), + ) + .map_err(|err| { + ProviderError::Provider(format!("content receipt verification failed: {err}")) + })?; + Ok(()) +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use tokio::sync::Mutex; + + const TEST_CID: &str = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + + struct MockIpfsProvider { + add_count: Mutex, + added_files: Mutex>, + added_directories: Mutex>>, + requests: Mutex>, + cat_files: Mutex>>, + missing_paths: Mutex>, + pinned: Mutex>, + pin_error: Mutex>, + unpinned: Mutex>, + } + + struct MockAvailabilityProvider { + requests: Mutex>, + response: Mutex, + } + + #[async_trait] + impl Provider for MockIpfsProvider { + async fn handle( + &self, + _request: ResourceRequest, + ) -> Result { + Err(ProviderError::Provider( + "mock ipfs provider only supports raw operations".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + Vec::new() + } + + fn name(&self) -> &'static str { + "mock-ipfs-provider" + } + + async fn send_raw(&self, request: &Value) -> Result { + self.requests.lock().await.push(request.clone()); + match request.get("op").and_then(|op| op.as_str()) { + Some("add_directory") => { + *self.add_count.lock().await += 1; + self.added_directories + .lock() + .await + .push(request["files"].as_array().cloned().unwrap_or_default()); + Ok(provider_ok(json!({ "cid": TEST_CID }))) + } + Some("add_bytes") => { + let filename = request + .get("filename") + .and_then(|filename| filename.as_str()) + .unwrap_or_default() + .to_string(); + self.added_files.lock().await.push(filename); + Ok(provider_ok(json!({ "cid": TEST_CID }))) + } + Some("cat") => { + let path = request + .get("path") + .and_then(|path| path.as_str()) + .unwrap_or("") + .to_string(); + if self + .missing_paths + .lock() + .await + .iter() + .any(|item| item == &path) + { + return Ok(provider_error("not_found", "mock content path missing")); + } + let bytes = self + .cat_files + .lock() + .await + .get(&path) + .cloned() + .unwrap_or_else(|| b"hello content".to_vec()); + Ok(provider_ok(json!({ + "data": base64::engine::general_purpose::STANDARD.encode(bytes) + }))) + } + Some("pin") => { + if let Some(message) = self.pin_error.lock().await.clone() { + return Ok(provider_error("pin_failed", &message)); + } + let cid = request + .get("cid") + .and_then(|cid| cid.as_str()) + .unwrap_or_default() + .to_string(); + self.pinned.lock().await.push(cid); + Ok(provider_ok(json!({}))) + } + Some("unpin") => { + let cid = request + .get("cid") + .and_then(|cid| cid.as_str()) + .unwrap_or_default() + .to_string(); + self.unpinned.lock().await.push(cid); + Ok(provider_ok(json!({}))) + } + _ => Ok(provider_error("unsupported", "unsupported mock ipfs op")), + } + } + } + + #[async_trait] + impl Provider for MockAvailabilityProvider { + async fn handle( + &self, + _request: ResourceRequest, + ) -> Result { + Err(ProviderError::Provider( + "mock availability provider only supports raw operations".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["availability"] + } + + fn name(&self) -> &'static str { + "mock-availability-provider" + } + + async fn send_raw(&self, request: &Value) -> Result { + self.requests.lock().await.push(request.clone()); + Ok(self.response.lock().await.clone()) + } + } + + fn decode_test_stream_payload(stream: &Value) -> Vec { + let chunks = stream["chunks"].as_array().unwrap(); + let mut bytes = Vec::new(); + for chunk in chunks { + let decoded = base64::engine::general_purpose::STANDARD + .decode(chunk["data"].as_str().unwrap()) + .unwrap(); + bytes.extend_from_slice(&decoded); + } + bytes + } + + fn test_stream_payload(bytes: &[u8]) -> Value { + json!({ + "schema": "elastos.provider.stream/v1", + "encoding": "base64-chunks", + "total_bytes": bytes.len(), + "completed": true, + "chunks": [{ + "index": 0, + "offset": 0, + "length": bytes.len(), + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + }], + }) + } + + fn carrier_import_exact_invocation() -> Value { + json!({ + "schema": "elastos.provider.invocation/v1", + "source": "carrier-availability", + "target": "content", + "op": "import_exact", + "capability": "provider:carrier-availability->content:import_exact", + "transport": "carrier-provider-plane", + "carrier": { + "route": "connect_ticket", + "peer_did": "did:key:zRemote", + "timeout_ms": 5000 + }, + "transfer": "stream", + "stream": { + "schema": "elastos.provider.stream/v1", + "encoding": "base64-chunks", + "chunk_size": 65536 + }, + "range": null, + "progress": null + }) + } + + fn carrier_import_object_invocation() -> Value { + json!({ + "schema": "elastos.provider.invocation/v1", + "source": "carrier-availability", + "target": "content", + "op": "import_object", + "capability": "provider:carrier-availability->content:import_object", + "transport": "carrier-provider-plane", + "carrier": { + "route": "connect_ticket", + "peer_did": "did:key:zRemote", + "timeout_ms": 5000 + }, + "transfer": "json", + "range": null, + "progress": null + }) + } + + async fn registry_with_content_and_ipfs() -> ( + tempfile::TempDir, + Arc, + Arc, + Arc, + ) { + registry_with_content_and_ipfs_with_alert_config(None).await + } + + async fn registry_with_content_and_ipfs_with_alert_config( + operator_alert_sink_config: Option, + ) -> ( + tempfile::TempDir, + Arc, + Arc, + Arc, + ) { + registry_with_content_and_ipfs_with_configs(operator_alert_sink_config, None, None, None) + .await + } + + async fn registry_with_content_and_ipfs_with_federated_alert_exchange_config( + federated_operator_alert_exchange_config: Option, + ) -> ( + tempfile::TempDir, + Arc, + Arc, + Arc, + ) { + registry_with_content_and_ipfs_with_configs( + None, + None, + None, + federated_operator_alert_exchange_config, + ) + .await + } + + async fn registry_with_content_and_ipfs_with_configs( + operator_alert_sink_config: Option, + storage_market_admission_config: Option, + external_repair_fleet_config: Option, + federated_operator_alert_exchange_config: Option, + ) -> ( + tempfile::TempDir, + Arc, + Arc, + Arc, + ) { + registry_with_content_and_ipfs_with_all_configs( + operator_alert_sink_config, + storage_market_admission_config, + external_repair_fleet_config, + federated_operator_alert_exchange_config, + None, + None, + ) + .await + } + + async fn registry_with_content_and_ipfs_with_quota_ledger_exchange_config( + federated_quota_ledger_exchange_config: Option, + ) -> ( + tempfile::TempDir, + Arc, + Arc, + Arc, + ) { + registry_with_content_and_ipfs_with_all_configs( + None, + None, + None, + None, + federated_quota_ledger_exchange_config, + None, + ) + .await + } + + async fn registry_with_content_and_ipfs_with_abuse_control_exchange_config( + federated_abuse_control_exchange_config: Option, + ) -> ( + tempfile::TempDir, + Arc, + Arc, + Arc, + ) { + registry_with_content_and_ipfs_with_all_configs( + None, + None, + None, + None, + None, + federated_abuse_control_exchange_config, + ) + .await + } + + async fn registry_with_content_and_ipfs_with_all_configs( + operator_alert_sink_config: Option, + storage_market_admission_config: Option, + external_repair_fleet_config: Option, + federated_operator_alert_exchange_config: Option, + federated_quota_ledger_exchange_config: Option, + federated_abuse_control_exchange_config: Option, + ) -> ( + tempfile::TempDir, + Arc, + Arc, + Arc, + ) { + let data_dir = tempfile::tempdir().unwrap(); + let registry = Arc::new(ProviderRegistry::new()); + let ipfs = Arc::new(MockIpfsProvider { + add_count: Mutex::new(0), + added_files: Mutex::new(Vec::new()), + added_directories: Mutex::new(Vec::new()), + requests: Mutex::new(Vec::new()), + cat_files: Mutex::new(HashMap::new()), + missing_paths: Mutex::new(Vec::new()), + pinned: Mutex::new(Vec::new()), + pin_error: Mutex::new(None), + unpinned: Mutex::new(Vec::new()), + }); + registry + .register_sub_provider("ipfs", ipfs.clone()) + .await + .unwrap(); + let content = Arc::new(ContentProvider::new_with_external_configs( + data_dir.path().to_path_buf(), + Arc::downgrade(®istry), + ContentProviderExternalConfigs { + operator_alert_sink: operator_alert_sink_config, + storage_market_admission: storage_market_admission_config, + external_repair_fleet: external_repair_fleet_config, + federated_operator_alert_exchange: federated_operator_alert_exchange_config, + federated_quota_ledger_exchange: federated_quota_ledger_exchange_config, + federated_abuse_control_exchange: federated_abuse_control_exchange_config, + }, + )); + registry.register(content.clone()).await; + registry + .register_sub_provider("content", content.clone()) + .await + .unwrap(); + (data_dir, registry, ipfs, content) + } + + fn spawn_operator_alert_sink() -> (String, std::thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = std::io::Read::read(&mut stream, &mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if http_request_complete(&request) { + break; + } + } + std::io::Write::write_all( + &mut stream, + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n", + ) + .unwrap(); + String::from_utf8_lossy(&request).into_owned() + }); + (format!("http://{addr}/content-alerts"), handle) + } + + fn spawn_federated_operator_alert_exchange( + response: Value, + ) -> (String, std::thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body = response.to_string(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = std::io::Read::read(&mut stream, &mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if http_request_complete(&request) { + break; + } + } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + std::io::Write::write_all(&mut stream, response.as_bytes()).unwrap(); + String::from_utf8_lossy(&request).into_owned() + }); + (format!("http://{addr}/alerts/exchange"), handle) + } + + fn signed_federated_quota_ledger_exchange_receipt( + accepted: bool, + reason: Option<&str>, + ) -> Value { + let (signing_key, _) = elastos_identity::derive_did(&[41_u8; 32]); + let payload = json!({ + "schema": CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_SCHEMA, + "provider": "test-quota-ledger", + "scope": "content-availability", + "exchange_id": "quota-exchange:test", + "receipt_id": if accepted { "quota-receipt:accepted" } else { "quota-receipt:rejected" }, + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "reason": reason, + "checked_at": now_unix_secs(), + }); + let canonical = serde_json::to_string(&payload).unwrap(); + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &signing_key, + CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_DOMAIN, + canonical.as_bytes(), + ); + json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + }) + } + + fn spawn_federated_quota_ledger_exchange_endpoint( + response: Value, + ) -> (String, std::thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body = response.to_string(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = std::io::Read::read(&mut stream, &mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if http_request_complete(&request) { + break; + } + } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + std::io::Write::write_all(&mut stream, response.as_bytes()).unwrap(); + String::from_utf8_lossy(&request).into_owned() + }); + (format!("http://{addr}/quota/exchange"), handle) + } + + fn signed_federated_abuse_control_exchange_receipt( + accepted: bool, + reason: Option<&str>, + ) -> Value { + let (signing_key, _) = elastos_identity::derive_did(&[43_u8; 32]); + let payload = json!({ + "schema": CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_SCHEMA, + "provider": "test-abuse-control", + "scope": "content-availability", + "exchange_id": "abuse-control-exchange:test", + "receipt_id": if accepted { + "abuse-control-receipt:accepted" + } else { + "abuse-control-receipt:rejected" + }, + "abuse_ledger_id": "abuse-ledger:test", + "accepted": accepted, + "status": if accepted { "accepted" } else { "rejected" }, + "reason": reason, + "checked_at": now_unix_secs(), + }); + let canonical = serde_json::to_string(&payload).unwrap(); + let (signature, signer_did) = crate::crypto::domain_separated_sign( + &signing_key, + CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_DOMAIN, + canonical.as_bytes(), + ); + json!({ + "payload": payload, + "signature": signature, + "signer_did": signer_did, + }) + } + + fn spawn_federated_abuse_control_exchange_endpoint( + response: Value, + ) -> (String, std::thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body = response.to_string(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = std::io::Read::read(&mut stream, &mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if http_request_complete(&request) { + break; + } + } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + std::io::Write::write_all(&mut stream, response.as_bytes()).unwrap(); + String::from_utf8_lossy(&request).into_owned() + }); + (format!("http://{addr}/abuse/exchange"), handle) + } + + fn spawn_storage_market_admission_endpoint( + response: Value, + ) -> (String, std::thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body = response.to_string(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = std::io::Read::read(&mut stream, &mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if http_request_complete(&request) { + break; + } + } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + std::io::Write::write_all(&mut stream, response.as_bytes()).unwrap(); + String::from_utf8_lossy(&request).into_owned() + }); + (format!("http://{addr}/market/admission"), handle) + } + + fn spawn_external_repair_fleet_endpoint( + response: Value, + ) -> (String, std::thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body = response.to_string(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = std::io::Read::read(&mut stream, &mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if http_request_complete(&request) { + break; + } + } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + body + ); + std::io::Write::write_all(&mut stream, response.as_bytes()).unwrap(); + String::from_utf8_lossy(&request).into_owned() + }); + (format!("http://{addr}/repair/dispatch"), handle) + } + + fn http_request_complete(request: &[u8]) -> bool { + let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") else { + return false; + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.eq_ignore_ascii_case("content-length") { + value.trim().parse::().ok() + } else { + None + } + }) + .unwrap_or(0); + request.len() >= header_end + 4 + content_length + } + + async fn invoke_content_repair_worker( + registry: &Arc, + request: Value, + ) -> Result { + registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "content".to_string(), + op: "repair_worker".to_string(), + request, + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + } + + #[tokio::test] + async fn content_publish_wraps_ipfs_with_availability_status() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["cid"], TEST_CID); + assert_eq!(response["data"]["uri"], format!("elastos://{TEST_CID}")); + assert_eq!(response["data"]["availability"]["status"], "local_pinned"); + assert_eq!( + response["data"]["availability"]["peer_selection"]["mode"], + "single_local" + ); + assert_eq!( + response["data"]["availability"]["peer_selection"]["live_multi_peer_proof"], + false + ); + assert_eq!( + response["data"]["availability"]["quota"]["policy"], + "not_enforced" + ); + assert_eq!( + response["data"]["availability"]["repair_worker"]["scheduled"], + false + ); + assert_eq!( + response["data"]["receipt"]["payload"]["schema"], + AVAILABILITY_RECEIPT_SCHEMA + ); + assert_eq!(response["data"]["receipt"]["payload"]["cid"], TEST_CID); + assert_eq!( + response["data"]["receipt"]["payload"]["status"], + "local_pinned" + ); + assert!(response["data"]["receipt"]["signature"] + .as_str() + .is_some_and(|sig| !sig.is_empty())); + assert!(response["data"]["receipt"]["signer_did"] + .as_str() + .is_some_and(|did| did.starts_with("did:key:z6Mk"))); + let signer_did = response["data"]["receipt"]["signer_did"] + .as_str() + .unwrap() + .to_string(); + let signed_receipt = serde_json::to_vec(&response["data"]["receipt"]).unwrap(); + crate::crypto::verify_signed_json_envelope_against_dids( + &signed_receipt, + AVAILABILITY_RECEIPT_DOMAIN, + &[signer_did], + ) + .unwrap(); + assert_eq!(*ipfs.add_count.lock().await, 1); + } + + #[tokio::test] + async fn content_publish_records_local_only_repair_task() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!( + response["data"]["repair_task"]["schema"], + REPAIR_TASK_SCHEMA + ); + assert_eq!(response["data"]["repair_task"]["cid"], TEST_CID); + assert_eq!(response["data"]["repair_task"]["status"], "local_only"); + assert_eq!( + response["data"]["repair_task"]["repair_worker"]["scheduled"], + false + ); + + let status = content + .send_raw(&json!({ + "op": "status", + "cid": TEST_CID, + })) + .await + .unwrap(); + assert_eq!( + status["data"]["availability"]["repair_task"]["status"], + "local_only" + ); + } + + #[tokio::test] + async fn content_import_exact_requires_runtime_provider_invocation() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let err = content + .send_raw(&json!({ + "op": "import_exact", + "cid": TEST_CID, + "stream": test_stream_payload(b"hello content"), + })) + .await + .unwrap_err(); + + assert!(err + .to_string() + .contains("requires Runtime provider invocation metadata")); + } + + #[tokio::test] + async fn content_import_exact_accepts_matching_cid_stream() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "import_exact", + "cid": TEST_CID, + "stream": test_stream_payload(b"hello content"), + "_runtime_invocation": carrier_import_exact_invocation(), + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["cid"], TEST_CID); + assert_eq!(response["data"]["availability"]["status"], "local_pinned"); + assert_eq!( + response["data"]["availability"]["policy"], + "carrier_exact_import" + ); + assert_eq!(response["data"]["import"]["verified_cid"], true); + assert_eq!(response["data"]["import"]["bytes"], 13); + assert_eq!( + ipfs.added_files.lock().await.as_slice(), + ["content.bin".to_string()] + ); + } + + #[tokio::test] + async fn content_import_exact_rejects_cid_mismatch_and_unpins_import() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "import_exact", + "cid": "QmRSEtAyq7Xgr5YCFVWuYsBdqbR5X9fJDsdpNQuvm9yaic", + "stream": test_stream_payload(b"hello content"), + "_runtime_invocation": carrier_import_exact_invocation(), + })) + .await + .unwrap(); + + assert_eq!(response["status"], "error"); + assert_eq!(response["code"], "cid_mismatch"); + assert_eq!( + ipfs.unpinned.lock().await.as_slice(), + [TEST_CID.to_string()] + ); + } + + #[tokio::test] + async fn content_import_object_requires_runtime_provider_invocation() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let err = content + .send_raw(&json!({ + "op": "import_object", + "cid": TEST_CID, + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + })) + .await + .unwrap_err(); + + assert!(err + .to_string() + .contains("requires Runtime provider invocation metadata")); + } + + #[tokio::test] + async fn content_import_object_reconstructs_manifest_directory() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "import_object", + "cid": TEST_CID, + "object_kind": "document", + "object_did": "did:key:zObject", + "publisher_did": "did:key:zPublisher", + "links": [{"rel": "provenance", "cid": TEST_CID}], + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "_runtime_invocation": carrier_import_object_invocation(), + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["cid"], TEST_CID); + assert_eq!( + response["data"]["availability"]["policy"], + "carrier_object_import" + ); + assert_eq!( + response["data"]["import"]["schema"], + "elastos.content.import-object/v1" + ); + assert_eq!(response["data"]["import"]["files"], 1); + assert_eq!(response["data"]["import"]["verified_cid"], true); + assert_eq!( + response["data"]["receipt"]["payload"]["accounting"]["source"], + "carrier_object_import" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["accounting"]["files"], + 1 + ); + assert_eq!( + response["data"]["receipt"]["payload"]["accounting"]["content_bytes"], + 7 + ); + + let directories = ipfs.added_directories.lock().await; + assert_eq!(directories.len(), 1); + let manifest_entry = directories[0] + .iter() + .find(|entry| entry["path"].as_str() == Some(OBJECT_MANIFEST_PATH)) + .expect("object manifest should be injected"); + let manifest_bytes = base64::engine::general_purpose::STANDARD + .decode(manifest_entry["data"].as_str().unwrap()) + .unwrap(); + let manifest: ContentObjectManifest = serde_json::from_slice(&manifest_bytes).unwrap(); + assert_eq!(manifest.kind, "document"); + assert_eq!(manifest.object_did.as_deref(), Some("did:key:zObject")); + assert_eq!( + manifest.publisher_did.as_deref(), + Some("did:key:zPublisher") + ); + assert_eq!(manifest.files.len(), 1); + assert_eq!(manifest.links[0].rel, "provenance"); + } + + #[tokio::test] + async fn content_publish_uses_registered_availability_provider() { + let (_data_dir, registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "availability": { + "status": "network_available", + "provider": "elacity-supernode", + "policy": "smartweb_default", + "replicas": 3, + "peer_selection": { + "mode": "carrier_topic", + "strategy": "closest_peer", + "live_multi_peer_proof": true, + "peer_reputation_policy": { + "schema": "elastos.carrier.peer-reputation/v1", + "policy": "local_runtime_reputation", + "status": "local_history_applied", + "federation": { + "configured": false + } + }, + "replicas": [{ + "role": "remote", + "node_did": "did:key:zRemote", + "score": 94, + "selection_reason": "signed_announcement+endpoint_advertised+fresh+local_reputation_positive", + "local_reputation": { + "scope": "local_runtime", + "score_delta": 4, + "reason": "local_runtime_successes:1;failures:0" + }, + "remote_receipt": { + "schema": "elastos.content.availability.receipt/v1", + "status": "local_pinned", + "verified": true, + "signer_did": "did:key:zRemoteContentProvider", + "quota": { + "status": "within_quota" + }, + "accounting": { + "content_bytes": 7 + }, + "abuse_controls": { + "policy": "carrier_provider_invocation_guardrail", + "enforced": true, + "attempted_operations": 1, + "failed_operations": 0, + "throttled": false + } + } + }] + }, + "quota": { + "policy": "operator_default", + "status": "within_quota", + "enforced": true, + "max_replicas": 3 + }, + "repair_worker": { + "scheduled": false, + "status": "healthy" + } + } + }))), + }); + registry.register(availability.clone()).await; + + let response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "object_did": "did:key:z6Mkobject", + "publisher_did": "did:key:z6Mkpublisher", + "pin": true, + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!( + response["data"]["availability"]["status"], + "network_available" + ); + assert_eq!( + response["data"]["availability"]["provider"], + "elacity-supernode" + ); + assert_eq!(response["data"]["availability"]["replicas"], 3); + assert_eq!( + response["data"]["availability"]["peer_selection"]["mode"], + "carrier_topic" + ); + assert_eq!( + response["data"]["availability"]["peer_selection"]["live_multi_peer_proof"], + true + ); + assert_eq!( + response["data"]["availability"]["quota"]["policy"], + "operator_default" + ); + assert_eq!( + response["data"]["availability"]["repair_worker"]["status"], + "healthy" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["status"], + "network_available" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["provider"], + "elacity-supernode" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["policy"], + "smartweb_default" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["peer_selection"]["strategy"], + "closest_peer" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["quota"]["max_replicas"], + 3 + ); + + let requests = availability.requests.lock().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0]["op"], "ensure"); + assert_eq!(requests[0]["cid"], TEST_CID); + assert_eq!(requests[0]["uri"], format!("elastos://{TEST_CID}")); + assert_eq!(requests[0]["local"]["status"], "local_pinned"); + assert_eq!( + requests[0]["local"]["peer_selection"]["mode"], + "single_local" + ); + assert_eq!(requests[0]["object_did"], "did:key:z6Mkobject"); + assert_eq!(requests[0]["publisher_did"], "did:key:z6Mkpublisher"); + drop(requests); + + let dashboard = content + .send_raw(&json!({ + "op": "status", + })) + .await + .unwrap(); + + assert_eq!(dashboard["data"]["quota"]["by_status"]["within_quota"], 1); + assert_eq!(dashboard["data"]["quota"]["enforced"], 1); + assert_eq!(dashboard["data"]["proofs"]["live_multi_peer"], 1); + assert_eq!(dashboard["data"]["proofs"]["remote_replicas"], 1); + assert_eq!(dashboard["data"]["proofs"]["remote_receipts"], 1); + assert_eq!(dashboard["data"]["proofs"]["verified_remote_receipts"], 1); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replica_limit"].as_u64(), + Some(AVAILABILITY_DASHBOARD_REMOTE_ROW_LIMIT as u64) + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas_truncated"], + false + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas"][0]["node_did"], + "did:key:zRemote" + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas"][0]["score"], + 94 + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas"][0]["local_reputation"]["scope"], + "local_runtime" + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas"][0]["peer_reputation_policy"] + ["status"], + "local_history_applied" + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas"][0]["local_reputation"] + ["score_delta"], + 4 + ); + assert_eq!( + dashboard["data"]["proofs"]["peer_reputation_policy"]["by_status"] + ["local_history_applied"], + 1 + ); + assert_eq!( + dashboard["data"]["proofs"]["peer_reputation_policy"]["local_history_applied"], + 1 + ); + assert_eq!( + dashboard["data"]["proofs"]["peer_reputation_policy"]["federation"]["configured"], + false + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas"][0]["remote_receipt"]["verified"], + true + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas"][0]["remote_receipt"] + ["quota_status"], + "within_quota" + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas"][0]["remote_receipt"] + ["content_bytes"], + 7 + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas"][0]["remote_receipt"] + ["abuse_controls"]["policy"], + "carrier_provider_invocation_guardrail" + ); + assert_eq!( + dashboard["data"]["proofs"]["recent_remote_replicas"][0]["remote_receipt"] + ["abuse_controls"]["attempted_operations"], + 1 + ); + assert!(!dashboard.to_string().contains("connect_ticket")); + } + + #[tokio::test] + async fn content_repair_worker_requires_runtime_provider_invocation() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let err = content + .send_raw(&json!({ + "op": "repair_worker", + "force": true, + })) + .await + .unwrap_err(); + + assert!(err + .to_string() + .contains("repair_worker requires Runtime provider invocation metadata")); + } + + #[tokio::test] + async fn content_repair_worker_retries_queued_availability_task() { + let (_data_dir, registry, ipfs, content) = registry_with_content_and_ipfs().await; + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "availability": { + "status": "network_available", + "provider": "elacity-supernode", + "policy": "smartweb_default", + "replicas": 2, + "peer_selection": { + "mode": "carrier_topic", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "operator_default", + "max_replicas": 2 + }, + "repair_worker": { + "scheduled": true, + "status": "healthy" + } + } + }))), + }); + registry.register(availability.clone()).await; + + let publish = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "object_did": "did:key:z6Mkobject", + "publisher_did": "did:key:z6Mkpublisher", + "pin": true, + })) + .await + .unwrap(); + + assert_eq!(publish["status"], "ok"); + assert_eq!(publish["data"]["availability"]["status"], "repair_needed"); + assert_eq!(publish["data"]["repair_task"]["status"], "queued"); + assert_eq!(publish["data"]["repair_task"]["attempts"], 0); + assert_eq!( + publish["data"]["repair_task"]["repair_worker"]["scheduled"], + true + ); + + *availability.response.lock().await = provider_ok(json!({ + "availability": { + "status": "network_available", + "provider": "elacity-supernode", + "policy": "smartweb_default", + "replicas": 2, + "peer_selection": { + "mode": "carrier_topic", + "live_multi_peer_proof": true + }, + "quota": { + "policy": "operator_default", + "max_replicas": 2 + }, + "repair_worker": { + "scheduled": true, + "status": "healthy" + } + } + })); + + let worker = invoke_content_repair_worker( + ®istry, + json!({ + "op": "repair_worker", + "force": true, + }), + ) + .await + .unwrap(); + + assert_eq!(worker["status"], "ok"); + assert_eq!(worker["data"]["schema"], REPAIR_WORKER_RUN_SCHEMA); + assert_eq!(worker["data"]["checked"], 1); + assert_eq!(worker["data"]["repaired"], 1); + assert_eq!(worker["data"]["failed"], 0); + assert_eq!( + worker["data"]["quota"]["policy"], + "content_repair_worker_guardrail" + ); + assert_eq!( + worker["data"]["abuse_controls"]["schema"], + REPAIR_WORKER_ABUSE_CONTROLS_SCHEMA + ); + assert_eq!( + worker["data"]["abuse_controls"]["runtime_invocation_required"], + true + ); + assert_eq!( + worker["data"]["network_abuse_policy"]["schema"], + CONTENT_NETWORK_ABUSE_POLICY_SCHEMA + ); + assert_eq!( + worker["data"]["network_abuse_policy"]["local_guardrails"] + ["repair_worker_attempt_budget"], + true + ); + assert_eq!( + worker["data"]["network_abuse_policy"]["network_federation"]["configured"], + false + ); + assert_eq!( + worker["data"]["repair_fleet"]["schema"], + REPAIR_FLEET_SCHEMA + ); + assert_eq!( + worker["data"]["repair_fleet"]["policy"], + "single_runtime_provider_repair_fleet" + ); + assert_eq!(worker["data"]["repair_fleet"]["checked"], 1); + assert_eq!( + worker["data"]["repair_fleet"]["production_federation"]["configured"], + false + ); + assert_eq!( + worker["data"]["external_repair_fleet_policy"]["schema"], + EXTERNAL_REPAIR_FLEET_POLICY_SCHEMA + ); + assert_eq!( + worker["data"]["external_repair_fleet_policy"]["external_fleet"]["configured"], + false + ); + assert_eq!(worker["data"]["results"][0]["cid"], TEST_CID); + assert_eq!(worker["data"]["results"][0]["status"], "network_available"); + assert_eq!(ipfs.pinned.lock().await.as_slice(), &[TEST_CID.to_string()]); + + let status = content + .send_raw(&json!({ + "op": "status", + "cid": TEST_CID, + })) + .await + .unwrap(); + assert_eq!( + status["data"]["availability"]["status"], + "network_available" + ); + assert_eq!( + status["data"]["availability"]["repair_task"]["status"], + "healthy" + ); + assert_eq!( + status["data"]["availability"]["network_abuse_policy"]["schema"], + CONTENT_NETWORK_ABUSE_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["availability"]["network_abuse_policy"]["network_federation"] + ["cross_peer_rate_limit"], + false + ); + assert_eq!(status["data"]["availability"]["repair_task"]["attempts"], 1); + + let requests = availability.requests.lock().await; + assert_eq!(requests.len(), 2); + assert_eq!(requests[0]["op"], "ensure"); + assert_eq!(requests[1]["op"], "ensure"); + assert_eq!(requests[1]["requirements"]["min_replicas"], 1); + } + + #[tokio::test] + async fn content_repair_worker_dispatches_configured_external_repair_fleet() { + let (url, handle) = spawn_external_repair_fleet_endpoint(json!({ + "accepted": true, + "status": "accepted", + "fleet_id": "fleet:supernode", + "job_id": "repair:123", + "receipt": { + "schema": "elastos.test.external-repair-fleet.receipt/v1", + "job_id": "repair:123" + } + })); + let (_data_dir, registry, _ipfs, content) = registry_with_content_and_ipfs_with_configs( + None, + None, + Some(json!({ + "url": url, + "authorization": "Bearer fleet-test", + "timeout_secs": 5, + })), + None, + ) + .await; + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "availability": { + "status": "repair_needed", + "provider": "carrier-availability", + "policy": "network_default", + "replicas": 1, + "reason": "remote peer could not pin content yet", + "peer_selection": { + "mode": "carrier_provider_replication", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "carrier_provider_quota", + "max_replicas": 2 + }, + "repair_worker": { + "scheduled": true, + "status": "queued" + } + } + }))), + }); + registry.register(availability.clone()).await; + + let publish = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + })) + .await + .unwrap(); + assert_eq!(publish["data"]["repair_task"]["status"], "queued"); + + *availability.response.lock().await = provider_ok(json!({ + "availability": { + "status": "network_available", + "provider": "elacity-supernode", + "policy": "smartweb_default", + "replicas": 2, + "peer_selection": { + "mode": "carrier_topic", + "live_multi_peer_proof": true + }, + "quota": { + "policy": "operator_default", + "max_replicas": 2 + }, + "repair_worker": { + "scheduled": true, + "status": "healthy" + } + } + })); + + let worker = invoke_content_repair_worker( + ®istry, + json!({ + "op": "repair_worker", + "force": true, + }), + ) + .await + .unwrap(); + + assert_eq!(worker["status"], "ok"); + assert_eq!(worker["data"]["checked"], 1); + assert_eq!(worker["data"]["repaired"], 1); + assert_eq!( + worker["data"]["external_repair_fleet_policy"]["status"], + "external_repair_fleet_dispatch_configured" + ); + assert_eq!( + worker["data"]["external_repair_fleet_policy"]["run"]["external_dispatches"], + 1 + ); + assert_eq!( + worker["data"]["external_repair_fleet_policy"]["run"]["external_dispatch_accepted"], + 1 + ); + assert_eq!( + worker["data"]["external_repair_fleet_policy"]["external_fleet"]["configured"], + true + ); + assert_eq!( + worker["data"]["results"][0]["external_repair_fleet_dispatch"]["schema"], + EXTERNAL_REPAIR_FLEET_DISPATCH_RECEIPT_SCHEMA + ); + assert_eq!( + worker["data"]["results"][0]["external_repair_fleet_dispatch"]["job_id"], + "repair:123" + ); + assert_eq!( + worker["data"]["results"][0]["external_repair_fleet_dispatch"]["client"] + ["credential_exposed"], + false + ); + assert!(!worker.to_string().contains("fleet-test")); + + let status = content + .send_raw(&json!({ + "op": "status", + })) + .await + .unwrap(); + assert_eq!( + status["data"]["external_repair_fleet_policy"]["external_fleet"]["configured"], + true + ); + assert_eq!( + status["data"]["operator_dashboard"]["fleet_history"]["external_repair_fleet_policy"] + ["external_fleet"]["configured"], + true + ); + assert!(!status.to_string().contains("fleet-test")); + + let request = handle.join().unwrap(); + assert!(request.contains(EXTERNAL_REPAIR_FLEET_DISPATCH_REQUEST_SCHEMA)); + assert!(request + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer fleet-test"))); + assert!(!request + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("fleet-test")); + } + + #[tokio::test] + async fn content_external_repair_fleet_dispatch_accepts_endpoint_quorum() { + let (url_a, handle_a) = spawn_external_repair_fleet_endpoint(json!({ + "accepted": true, + "status": "accepted", + "fleet_id": "fleet:a", + "job_id": "repair:a", + "receipt": { + "schema": "elastos.test.external-repair-fleet.receipt/v1", + "job_id": "repair:a" + } + })); + let (url_b, handle_b) = spawn_external_repair_fleet_endpoint(json!({ + "accepted": true, + "status": "accepted", + "fleet_id": "fleet:b", + "job_id": "repair:b", + "receipt": { + "schema": "elastos.test.external-repair-fleet.receipt/v1", + "job_id": "repair:b" + } + })); + let client = ContentExternalRepairFleetClient::from_config(json!({ + "quorum": 2, + "endpoints": [ + { + "id": "repair-a", + "url": url_a, + "authorization": "Bearer repair-secret-a", + "timeout_secs": 5 + }, + { + "id": "repair-b", + "url": url_b, + "authorization": "Bearer repair-secret-b", + "timeout_secs": 5 + } + ] + })) + .unwrap(); + + let receipt = client + .dispatch(&json!({ + "schema": EXTERNAL_REPAIR_FLEET_DISPATCH_REQUEST_SCHEMA, + "cid": TEST_CID, + "requested_at": 1_700_000_000, + })) + .await + .unwrap(); + + assert_eq!( + receipt["schema"], + EXTERNAL_REPAIR_FLEET_DISPATCH_RECEIPT_SCHEMA + ); + assert_eq!(receipt["accepted"], true); + assert_eq!(receipt["status"], "accepted"); + assert_eq!(receipt["job_id"], "repair:a"); + assert_eq!(receipt["quorum"]["required"], 2); + assert_eq!(receipt["quorum"]["endpoint_count"], 2); + assert_eq!(receipt["quorum"]["accepted"], 2); + assert_eq!(receipt["client"]["multi_endpoint"], true); + assert_eq!(receipt["client"]["endpoint_count"], 2); + assert!(!receipt.to_string().contains("repair-secret-a")); + assert!(!receipt.to_string().contains("repair-secret-b")); + + let request_a = handle_a.join().unwrap(); + let request_b = handle_b.join().unwrap(); + assert!(request_a + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer repair-secret-a"))); + assert!(request_b + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer repair-secret-b"))); + assert!(request_a.contains(EXTERNAL_REPAIR_FLEET_DISPATCH_REQUEST_SCHEMA)); + assert!(request_b.contains(EXTERNAL_REPAIR_FLEET_DISPATCH_REQUEST_SCHEMA)); + assert!(!request_a + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("repair-secret-a")); + assert!(!request_b + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("repair-secret-b")); + } + + #[tokio::test] + async fn content_external_repair_fleet_dispatch_rejects_endpoint_quorum_failure() { + let (accepted_url, accepted_handle) = spawn_external_repair_fleet_endpoint(json!({ + "accepted": true, + "status": "accepted", + "fleet_id": "fleet:a", + "job_id": "repair:a", + })); + let (rejected_url, rejected_handle) = spawn_external_repair_fleet_endpoint(json!({ + "accepted": false, + "status": "rejected", + "reason": "fleet capacity exhausted", + })); + let client = ContentExternalRepairFleetClient::from_config(json!({ + "quorum": 2, + "endpoints": [ + {"id": "repair-a", "url": accepted_url, "timeout_secs": 5}, + {"id": "repair-b", "url": rejected_url, "timeout_secs": 5} + ] + })) + .unwrap(); + + let receipt = client + .dispatch(&json!({ + "schema": EXTERNAL_REPAIR_FLEET_DISPATCH_REQUEST_SCHEMA, + "cid": TEST_CID, + "requested_at": 1_700_000_000, + })) + .await + .unwrap(); + + assert_eq!(receipt["accepted"], false); + assert_eq!(receipt["status"], "dispatch_failed"); + assert_eq!(receipt["quorum"]["required"], 2); + assert_eq!(receipt["quorum"]["accepted"], 1); + assert_eq!(receipt["quorum"]["rejected"], 1); + assert!(receipt["reason"] + .as_str() + .unwrap() + .contains("fleet capacity exhausted")); + + let accepted_request = accepted_handle.join().unwrap(); + let rejected_request = rejected_handle.join().unwrap(); + assert!(accepted_request.contains(EXTERNAL_REPAIR_FLEET_DISPATCH_REQUEST_SCHEMA)); + assert!(rejected_request.contains(EXTERNAL_REPAIR_FLEET_DISPATCH_REQUEST_SCHEMA)); + } + + #[tokio::test] + async fn content_repair_worker_enforces_attempt_budget() { + let (_data_dir, registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "availability": { + "status": "repair_needed", + "provider": "carrier-availability", + "policy": "network_default", + "replicas": 1, + "reason": "remote peer could not pin content yet", + "peer_selection": { + "mode": "carrier_provider_replication", + "live_multi_peer_proof": false + }, + "quota": { + "policy": "carrier_provider_quota", + "max_replicas": 2 + }, + "repair_worker": { + "scheduled": true, + "status": "queued" + } + } + }))), + }); + registry.register(availability.clone()).await; + + let publish = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + })) + .await + .unwrap(); + assert_eq!(publish["data"]["repair_task"]["status"], "queued"); + assert_eq!(publish["data"]["repair_task"]["attempts"], 0); + + let first = invoke_content_repair_worker( + ®istry, + json!({ + "op": "repair_worker", + "force": true, + "max_attempts": 1, + }), + ) + .await + .unwrap(); + assert_eq!(first["data"]["checked"], 1); + assert_eq!(first["data"]["failed"], 1); + assert_eq!(first["data"]["results"][0]["status"], "repair_needed"); + + let second = invoke_content_repair_worker( + ®istry, + json!({ + "op": "repair_worker", + "force": true, + "max_attempts": 1, + }), + ) + .await + .unwrap(); + assert_eq!(second["data"]["checked"], 0); + assert_eq!( + second["data"]["abuse_controls"]["exhausted_attempts_skipped"], + 1 + ); + assert_eq!( + second["data"]["network_abuse_policy"]["schema"], + CONTENT_NETWORK_ABUSE_POLICY_SCHEMA + ); + assert_eq!( + second["data"]["network_abuse_policy"]["status"], + "local_worker_throttled" + ); + assert_eq!( + second["data"]["repair_fleet"]["exhausted_attempts_skipped"], + 1 + ); + assert_eq!( + second["data"]["repair_fleet"]["production_federation"]["external_workers"], + false + ); + assert_eq!( + second["data"]["external_repair_fleet_policy"]["run"]["exhausted_attempts_skipped"], + 1 + ); + assert_eq!( + second["data"]["external_repair_fleet_policy"]["external_fleet"]["configured"], + false + ); + + let requests = availability.requests.lock().await; + assert_eq!(requests.len(), 2); + } + + #[tokio::test] + async fn content_publish_accepts_carrier_announced_availability() { + let (_data_dir, registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "availability": { + "status": "carrier_announced", + "provider": "carrier-availability", + "policy": "network_default", + "replicas": 1, + "transport": "carrier-gossip", + "topic": "elastos://carrier/content/test/availability", + "peer_selection": { + "mode": "carrier_topic", + "topic": "elastos://carrier/content/test/availability", + "live_multi_peer_proof": true + }, + "quota": { + "policy": "carrier_policy", + "max_replicas": 1 + }, + "repair_worker": { + "scheduled": false, + "status": "carrier_announced" + } + } + }))), + }); + registry.register(availability.clone()).await; + + let response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBDYXJyaWVyCg=="}], + "object_did": "did:key:z6Mkobject", + "publisher_did": "did:key:z6Mkpublisher", + "pin": true, + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!( + response["data"]["availability"]["status"], + "carrier_announced" + ); + assert_eq!( + response["data"]["availability"]["provider"], + "carrier-availability" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["status"], + "carrier_announced" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["provider"], + "carrier-availability" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["policy"], + "network_default" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["peer_selection"]["topic"], + "elastos://carrier/content/test/availability" + ); + assert_eq!( + response["data"]["receipt"]["payload"]["quota"]["policy"], + "carrier_policy" + ); + + let requests = availability.requests.lock().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0]["op"], "ensure"); + assert_eq!(requests[0]["local"]["replicas"], 1); + assert_eq!(requests[0]["policy"], "network_default"); + } + + #[tokio::test] + async fn content_publish_rejects_unproven_multi_peer_availability_claim() { + let (_data_dir, registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "availability": { + "status": "network_available", + "provider": "thin-availability-provider", + "policy": "network_default", + "replicas": 2 + } + }))), + }); + registry.register(availability.clone()).await; + + let response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBVbnByb3Zlbgo="}], + "pin": true, + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["availability"]["status"], "repair_needed"); + assert_eq!( + response["data"]["availability"]["provider"], + "thin-availability-provider" + ); + assert!(response["data"]["availability"]["reason"] + .as_str() + .unwrap() + .contains("peer_selection")); + assert_eq!( + response["data"]["receipt"]["payload"]["status"], + "repair_needed" + ); + } + + #[tokio::test] + async fn content_publish_enforces_availability_requirements() { + let (_data_dir, registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "availability": { + "status": "network_available", + "provider": "elacity-supernode", + "policy": "smartweb_default", + "replicas": 2, + "peer_selection": { + "mode": "carrier_topic", + "live_multi_peer_proof": true + }, + "quota": { + "policy": "operator_default", + "max_replicas": 2 + }, + "repair_worker": { + "scheduled": false, + "status": "healthy" + } + } + }))), + }); + registry.register(availability.clone()).await; + + let response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBSZXF1aXJlbWVudAo="}], + "pin": true, + "availability_requirements": { + "min_replicas": 3, + "max_replicas": 3, + "require_live_multi_peer_proof": true + } + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["availability"]["status"], "repair_needed"); + assert!(response["data"]["availability"]["reason"] + .as_str() + .unwrap() + .contains("below required 3")); + assert_eq!( + response["data"]["receipt"]["payload"]["status"], + "repair_needed" + ); + + let requests = availability.requests.lock().await; + assert_eq!(requests[0]["requirements"]["min_replicas"], 3); + assert_eq!(requests[0]["requirements"]["max_replicas"], 3); + assert_eq!( + requests[0]["requirements"]["require_live_multi_peer_proof"], + true + ); + } + + #[tokio::test] + async fn content_publish_requires_peer_selection_policy_metadata() { + let (_data_dir, registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "availability": { + "status": "network_available", + "provider": "policyless-availability", + "policy": "network_default", + "replicas": 1, + "peer_selection": { + "live_multi_peer_proof": false + }, + "quota": { + "policy": "operator_default", + "max_replicas": 1 + }, + "repair_worker": { + "scheduled": false, + "status": "healthy" + } + } + }))), + }); + registry.register(availability.clone()).await; + + let response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBQb2xpY3kK"}], + "pin": true, + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["availability"]["status"], "repair_needed"); + assert!(response["data"]["availability"]["reason"] + .as_str() + .unwrap() + .contains("peer_selection requires mode or strategy")); + assert_eq!( + response["data"]["receipt"]["payload"]["status"], + "repair_needed" + ); + } + + #[tokio::test] + async fn content_publish_directory_injects_object_manifest() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "document", + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "object_did": "did:key:z6Mkobject", + "publisher_did": "did:key:z6Mkpublisher", + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + let directories = ipfs.added_directories.lock().await; + let manifest_entry = directories[0] + .iter() + .find(|entry| entry["path"].as_str() == Some(OBJECT_MANIFEST_PATH)) + .expect("object manifest should be injected"); + let manifest_bytes = base64::engine::general_purpose::STANDARD + .decode(manifest_entry["data"].as_str().unwrap()) + .unwrap(); + let manifest: ContentObjectManifest = serde_json::from_slice(&manifest_bytes).unwrap(); + + assert_eq!(manifest.schema, OBJECT_MANIFEST_SCHEMA); + assert_eq!(manifest.kind, "document"); + assert_eq!(manifest.object_did.as_deref(), Some("did:key:z6Mkobject")); + assert_eq!( + manifest.publisher_did.as_deref(), + Some("did:key:z6Mkpublisher") + ); + assert_eq!(manifest.files.len(), 1); + assert_eq!(manifest.files[0].path, "index.md"); + assert!(manifest.content_digest.starts_with("sha256:")); + } + + fn sealed_object_value() -> Value { + json!({ + "schema": "elastos.sealed.object/v1", + "payload_cid": TEST_CID, + "rights_policy_cid": TEST_CID, + "availability_receipt_cid": TEST_CID, + "key_envelope": { + "scheme": "elastos-pq-hybrid-threshold-v0", + "kid": "kid:test", + "wrapped_cek": "wrapped", + "policy_hash": "sha256:test", + "algorithms": { + "cipher": "aes-256-gcm", + "signature": ["ed25519", "ml-dsa-65"], + "kem": ["x25519", "ml-kem-768"], + "share_scheme": "shamir-t-of-n" + } + }, + "viewer": { + "required_interface": "elastos.viewer/document@1" + } + }) + } + + fn sealed_object_data() -> String { + base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&sealed_object_value()).unwrap()) + } + + fn sealed_object_data_from(value: &Value) -> String { + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(value).unwrap()) + } + + fn sealed_object_links() -> Vec { + vec![ + json!({"rel": "availability.receipt", "cid": TEST_CID}), + json!({"rel": "payload", "cid": TEST_CID}), + json!({"rel": "provenance", "cid": TEST_CID}), + json!({"rel": "rights.policy", "cid": TEST_CID}), + ] + } + + #[tokio::test] + async fn content_publish_directory_accepts_linked_release_and_sealed_manifests() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "sealed", + "links": sealed_object_links(), + "files": [{"path": "sealed.json", "data": sealed_object_data()}], + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + let directories = ipfs.added_directories.lock().await; + let manifest_entry = directories[0] + .iter() + .find(|entry| entry["path"].as_str() == Some(OBJECT_MANIFEST_PATH)) + .expect("object manifest should be injected"); + let manifest_bytes = base64::engine::general_purpose::STANDARD + .decode(manifest_entry["data"].as_str().unwrap()) + .unwrap(); + let manifest: ContentObjectManifest = serde_json::from_slice(&manifest_bytes).unwrap(); + + assert_eq!(manifest.kind, "sealed"); + assert_eq!(manifest.links.len(), 4); + assert_eq!(manifest.links[0].rel, "availability.receipt"); + assert_eq!(manifest.links[0].cid, TEST_CID); + assert_eq!(manifest.links[1].rel, "payload"); + assert_eq!(manifest.links[1].cid, TEST_CID); + assert_eq!(manifest.links[2].rel, "provenance"); + assert_eq!(manifest.links[2].cid, TEST_CID); + assert_eq!(manifest.links[3].rel, "rights.policy"); + assert_eq!(manifest.links[3].cid, TEST_CID); + drop(directories); + + let release_response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "release", + "links": [{"rel": "sealed", "cid": TEST_CID}], + "files": [{"path": "release.json", "data": "e30="}], + })) + .await + .unwrap(); + assert_eq!(release_response["status"], "ok"); + } + + #[tokio::test] + async fn content_publish_directory_rejects_incomplete_sealed_objects() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let missing_descriptor = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "sealed", + "links": sealed_object_links(), + "files": [{"path": "payload.bin", "data": "c2VhbGVkCg=="}], + })) + .await + .unwrap_err(); + assert!(missing_descriptor + .to_string() + .contains("sealed content object requires sealed.json")); + + let missing_provenance = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "sealed", + "links": [ + {"rel": "availability.receipt", "cid": TEST_CID}, + {"rel": "payload", "cid": TEST_CID}, + {"rel": "rights.policy", "cid": TEST_CID} + ], + "files": [{"path": "sealed.json", "data": sealed_object_data()}], + })) + .await + .unwrap_err(); + assert!(missing_provenance + .to_string() + .contains("sealed content object requires provenance link")); + + let mut weak_envelope = sealed_object_value(); + weak_envelope["key_envelope"]["algorithms"]["cipher"] = Value::String("aes-128-gcm".into()); + let weak_cipher = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "sealed", + "links": sealed_object_links(), + "files": [{"path": "sealed.json", "data": sealed_object_data_from(&weak_envelope)}], + })) + .await + .unwrap_err(); + assert!(weak_cipher + .to_string() + .contains("key_envelope.algorithms.cipher uses unsupported algorithm")); + } + + #[tokio::test] + async fn content_publish_directory_sorts_entries_for_stable_cids() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "share", + "files": [ + {"path": "z.md", "data": "eg=="}, + {"path": "a.md", "data": "YQ=="} + ], + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + let directories = ipfs.added_directories.lock().await; + let paths = directories[0] + .iter() + .map(|entry| entry["path"].as_str().unwrap().to_string()) + .collect::>(); + assert_eq!(paths, vec![OBJECT_MANIFEST_PATH, "a.md", "z.md"]); + } + + #[tokio::test] + async fn content_publish_directory_rejects_ambiguous_object_shape() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let duplicate_path = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "share", + "files": [ + {"path": "index.md", "data": "YQ=="}, + {"path": "index.md", "data": "Yg=="} + ], + })) + .await + .unwrap_err(); + assert!(duplicate_path + .to_string() + .contains("duplicate directory publish path")); + + let unknown_kind = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "random", + "files": [{"path": "index.md", "data": "YQ=="}], + })) + .await + .unwrap_err(); + assert!(unknown_kind + .to_string() + .contains("unsupported content object kind")); + + let invalid_link = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "release", + "links": [{"rel": "Bad Rel", "cid": TEST_CID}], + "files": [{"path": "release.json", "data": "e30="}], + })) + .await + .unwrap_err(); + assert!(invalid_link.to_string().contains("content object link rel")); + + let invalid_link_cid = content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "object_kind": "release", + "links": [{"rel": "release", "cid": "not-a-cid"}], + "files": [{"path": "release.json", "data": "e30="}], + })) + .await + .unwrap_err(); + assert!(invalid_link_cid + .to_string() + .contains("invalid content object link cid")); + } + + #[tokio::test] + async fn content_unpublish_wraps_ipfs_unpin() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "unpublish", + "cid": TEST_CID, + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["cid"], TEST_CID); + assert_eq!( + response["data"]["receipt"]["payload"]["status"], + "local_unpinned" + ); + assert_eq!( + ipfs.unpinned.lock().await.as_slice(), + [TEST_CID.to_string()] + ); + } + + #[tokio::test] + async fn content_repair_pins_cid_and_records_receipt() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "repair", + "cid": TEST_CID, + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["availability"]["status"], "local_pinned"); + assert_eq!( + response["data"]["receipt"]["payload"]["policy"], + "local_repair_pin" + ); + assert_eq!(ipfs.pinned.lock().await.as_slice(), [TEST_CID.to_string()]); + } + + #[tokio::test] + async fn content_ensure_pins_cid_and_records_policy() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "ensure", + "cid": TEST_CID, + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["availability"]["status"], "local_pinned"); + assert_eq!( + response["data"]["receipt"]["payload"]["policy"], + "local_ensure_pin" + ); + assert_eq!(ipfs.pinned.lock().await.as_slice(), [TEST_CID.to_string()]); + } + + #[tokio::test] + async fn content_repair_records_repair_needed_when_pin_fails() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + *ipfs.pin_error.lock().await = Some("not available".to_string()); + + let response = content + .send_raw(&json!({ + "op": "repair", + "cid": TEST_CID, + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!(response["data"]["availability"]["status"], "repair_needed"); + assert_eq!(response["data"]["availability"]["reason"], "not available"); + assert_eq!( + response["data"]["availability"]["repair_worker"]["scheduled"], + true + ); + assert_eq!( + response["data"]["receipt"]["payload"]["status"], + "repair_needed" + ); + } + + #[tokio::test] + async fn content_publish_file_wraps_ipfs_bytes_with_receipt() { + let (_data_dir, registry, ipfs, content) = registry_with_content_and_ipfs().await; + let cid = publish_bytes_via_provider( + ®istry, + "provenance.json", + br#"{"ok":true}"#, + Some("did:key:z6Mkobject"), + Some("did:key:z6Mkpublisher"), + ) + .await + .unwrap(); + + assert_eq!(cid, TEST_CID); + assert_eq!( + ipfs.added_files.lock().await.as_slice(), + ["provenance.json".to_string()] + ); + let status = content + .send_raw(&json!({ + "op": "status", + "cid": TEST_CID, + })) + .await + .unwrap(); + assert_eq!( + status["data"]["receipt"]["payload"]["accounting"]["source"], + "publish_request" + ); + assert_eq!( + status["data"]["receipt"]["payload"]["accounting"]["files"], + 1 + ); + assert_eq!( + status["data"]["receipt"]["payload"]["accounting"]["content_bytes"], + br#"{"ok":true}"#.len() as u64 + ); + assert_eq!( + status["data"]["availability"]["accounting"]["storage_quota"]["enforced"], + false + ); + } + + #[tokio::test] + async fn content_fetch_wraps_ipfs_cat() { + let (_data_dir, registry, ipfs, _content) = registry_with_content_and_ipfs().await; + let bytes = fetch_bytes_via_provider(®istry, TEST_CID, Some("capsule.json")) + .await + .unwrap(); + + assert_eq!(bytes, b"hello content"); + let requests = ipfs.requests.lock().await; + let cat = requests + .iter() + .find(|request| request["op"] == "cat") + .expect("content helper should fetch through ipfs cat"); + assert_eq!(cat["_runtime_invocation"]["transfer"], "stream"); + assert_eq!( + cat["_runtime_invocation"]["stream"]["mode"], + "runtime_stream_session" + ); + assert_eq!( + cat["_runtime_invocation"]["abi"]["backpressure"], + "read_next" + ); + assert_eq!(cat["_runtime_invocation"]["abi"]["cancel_supported"], true); + } + + #[tokio::test] + async fn content_fetch_propagates_range_progress_transfer_receipt() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "fetch", + "cid": TEST_CID, + "path": "capsule.json", + "range": { + "start": 0, + "end": 4 + }, + "progress": { + "request_id": "content-fetch:test", + "expected_bytes": 5 + } + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!( + response["data"]["data"], + base64::engine::general_purpose::STANDARD.encode(b"hello") + ); + assert_eq!( + response["data"]["transfer"]["schema"], + "elastos.provider.transfer/v1" + ); + assert_eq!(response["data"]["transfer"]["source"], "content-provider"); + assert_eq!(response["data"]["transfer"]["target"], "ipfs"); + assert_eq!(response["data"]["transfer"]["op"], "cat"); + assert_eq!( + response["data"]["transfer"]["transport"], + "runtime-local-provider-plane" + ); + assert_eq!(response["data"]["transfer"]["range"]["start"], 0); + assert_eq!(response["data"]["transfer"]["range"]["end"], 4); + assert_eq!( + response["data"]["transfer"]["progress"]["request_id"], + "content-fetch:test" + ); + assert_eq!( + response["data"]["transfer"]["progress"]["expected_bytes"], + 5 + ); + } + + #[tokio::test] + async fn content_fetch_stream_returns_provider_stream_payload() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let response = content + .send_raw(&json!({ + "op": "fetch", + "cid": TEST_CID, + "path": "capsule.json", + "transfer": "stream", + "range": { + "start": 0, + "end": 4 + }, + "progress": { + "request_id": "content-stream:test", + "expected_bytes": 5 + } + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert!(response["data"].get("data").is_none()); + assert_eq!( + response["data"]["stream"]["schema"], + "elastos.provider.stream/v1" + ); + assert_eq!( + decode_test_stream_payload(&response["data"]["stream"]), + b"hello" + ); + assert_eq!(response["data"]["transfer"]["transfer"], "stream"); + assert_eq!( + response["data"]["transfer"]["stream"]["schema"], + "elastos.provider.stream/v1" + ); + assert_eq!(response["data"]["transfer"]["range"]["start"], 0); + assert_eq!(response["data"]["transfer"]["range"]["end"], 4); + assert_eq!( + response["data"]["transfer"]["progress"]["request_id"], + "content-stream:test" + ); + assert_eq!( + response["data"]["transfer"]["progress"]["expected_bytes"], + 5 + ); + } + + #[tokio::test] + async fn content_fetch_uses_availability_provider_when_local_backend_misses() { + let (_data_dir, registry, ipfs, content) = registry_with_content_and_ipfs().await; + ipfs.missing_paths + .lock() + .await + .push("remote.md".to_string()); + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "data": base64::engine::general_purpose::STANDARD.encode(b"remote content"), + "availability": { + "status": "network_available", + "provider": "mock-availability", + "policy": "network_default", + "replicas": 2 + } + }))), + }); + registry.register(availability.clone()).await; -pub fn verify_content_object_file( - cid: &str, - file: &ContentObjectFile, - bytes: &[u8], -) -> anyhow::Result<()> { - if file.size != bytes.len() as u64 { - anyhow::bail!( - "content object file size mismatch for {}/{}: expected {}, got {}", - cid, - file.path, - file.size, - bytes.len() + let response = content + .send_raw(&json!({ + "op": "fetch", + "cid": TEST_CID, + "path": "remote.md", + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!( + response["data"]["data"], + base64::engine::general_purpose::STANDARD.encode(b"remote content") + ); + assert_eq!( + response["data"]["availability"]["provider"], + "mock-availability" + ); + assert_eq!( + response["data"]["availability"]["status"], + "network_available" ); + + let requests = availability.requests.lock().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0]["op"], "fetch"); + assert_eq!(requests[0]["cid"], TEST_CID); + assert_eq!(requests[0]["uri"], format!("elastos://{TEST_CID}")); + assert_eq!(requests[0]["path"], "remote.md"); } - let actual_hash = format!("{:x}", sha2::Sha256::digest(bytes)); - if file.sha256 != actual_hash { - anyhow::bail!( - "content object file hash mismatch for {}/{}", - cid, - file.path + + #[tokio::test] + async fn content_fetch_ranges_availability_provider_when_local_backend_misses() { + let (_data_dir, registry, ipfs, content) = registry_with_content_and_ipfs().await; + ipfs.missing_paths + .lock() + .await + .push("remote.md".to_string()); + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "data": base64::engine::general_purpose::STANDARD.encode(b"remote content"), + "availability": { + "status": "network_available", + "provider": "mock-availability", + "policy": "network_default", + "replicas": 2 + } + }))), + }); + registry.register(availability.clone()).await; + + let response = content + .send_raw(&json!({ + "op": "fetch", + "cid": TEST_CID, + "path": "remote.md", + "range": { + "start": 7, + "end": 13 + }, + "progress": { + "request_id": "availability-fetch:test", + "expected_bytes": 7 + } + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert_eq!( + response["data"]["data"], + base64::engine::general_purpose::STANDARD.encode(b"content") + ); + assert_eq!(response["data"]["transfer"]["target"], "availability"); + assert_eq!( + response["data"]["transfer"]["transport"], + "runtime-local-provider-plane" + ); + assert_eq!(response["data"]["transfer"]["range"]["start"], 7); + assert_eq!(response["data"]["transfer"]["range"]["end"], 13); + assert_eq!( + response["data"]["transfer"]["progress"]["request_id"], + "availability-fetch:test" + ); + + let requests = availability.requests.lock().await; + assert_eq!( + requests[0]["_runtime_invocation"]["source"], + "content-provider" ); + assert_eq!(requests[0]["_runtime_invocation"]["target"], "availability"); + assert_eq!(requests[0]["_runtime_invocation"]["range"]["start"], 7); } - Ok(()) -} -async fn write_materialized_file(base: &Path, rel_path: &str, bytes: &[u8]) -> anyhow::Result<()> { - validate_content_path(rel_path).map_err(|err| anyhow::anyhow!("{err}"))?; - let path = base.join(rel_path); - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; + #[tokio::test] + async fn content_fetch_stream_ranges_availability_provider_when_local_backend_misses() { + let (_data_dir, registry, ipfs, content) = registry_with_content_and_ipfs().await; + ipfs.missing_paths + .lock() + .await + .push("remote.md".to_string()); + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "data": base64::engine::general_purpose::STANDARD.encode(b"remote content"), + "availability": { + "status": "network_available", + "provider": "mock-availability", + "policy": "network_default", + "replicas": 2 + } + }))), + }); + registry.register(availability.clone()).await; + + let response = content + .send_raw(&json!({ + "op": "fetch", + "cid": TEST_CID, + "path": "remote.md", + "transfer": "stream", + "range": { + "start": 7, + "end": 13 + }, + "progress": { + "request_id": "availability-stream:test", + "expected_bytes": 7 + } + })) + .await + .unwrap(); + + assert_eq!(response["status"], "ok"); + assert!(response["data"].get("data").is_none()); + assert_eq!( + decode_test_stream_payload(&response["data"]["stream"]), + b"content" + ); + assert_eq!( + response["data"]["availability"]["provider"], + "mock-availability" + ); + assert_eq!(response["data"]["transfer"]["target"], "availability"); + assert_eq!(response["data"]["transfer"]["transfer"], "stream"); + assert_eq!( + response["data"]["transfer"]["progress"]["request_id"], + "availability-stream:test" + ); + + let requests = availability.requests.lock().await; + assert_eq!( + requests[0]["_runtime_invocation"]["source"], + "content-provider" + ); + assert_eq!(requests[0]["_runtime_invocation"]["target"], "availability"); + assert_eq!(requests[0]["_runtime_invocation"]["transfer"], "stream"); + assert_eq!( + requests[0]["_runtime_invocation"]["stream"]["schema"], + "elastos.provider.stream/v1" + ); + assert_eq!(requests[0]["_runtime_invocation"]["range"]["start"], 7); } - tokio::fs::write(path, bytes).await?; - Ok(()) -} -fn append_jsonl(path: &Path, entry: &T) -> Result<(), ProviderError> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; + #[tokio::test] + async fn content_fetch_local_only_skips_availability_provider() { + let (_data_dir, registry, ipfs, content) = registry_with_content_and_ipfs().await; + ipfs.missing_paths + .lock() + .await + .push("remote.md".to_string()); + let availability = Arc::new(MockAvailabilityProvider { + requests: Mutex::new(Vec::new()), + response: Mutex::new(provider_ok(json!({ + "data": base64::engine::general_purpose::STANDARD.encode(b"remote content"), + "availability": { + "status": "network_available", + "provider": "mock-availability" + } + }))), + }); + registry.register(availability.clone()).await; + + let err = content + .send_raw(&json!({ + "op": "fetch", + "cid": TEST_CID, + "path": "remote.md", + "local_only": true, + })) + .await + .expect_err("local_only fetch must fail on local backend miss"); + + assert!(err.to_string().contains("content fetch")); + assert!(availability.requests.lock().await.is_empty()); + } + + #[tokio::test] + async fn content_prepare_data_capsule_materializes_verified_manifest_files() { + let (_data_dir, registry, ipfs, _content) = registry_with_content_and_ipfs().await; + let capsule_json = serde_json::json!({ + "schema": elastos_common::SCHEMA_V1, + "version": "0.1.0", + "name": "shared-doc", + "role": "content", + "type": "data", + "entrypoint": "index.html" + }); + let capsule_bytes = serde_json::to_vec(&capsule_json).unwrap(); + let index_bytes = b"viewer".to_vec(); + let markdown_bytes = b"# Hello\n".to_vec(); + let object_manifest = ContentObjectManifest { + schema: OBJECT_MANIFEST_SCHEMA.to_string(), + kind: "share".to_string(), + content_digest: "sha256:test".to_string(), + files: vec![ + object_file("capsule.json", &capsule_bytes), + object_file("docs/readme.md", &markdown_bytes), + object_file("index.html", &index_bytes), + ], + links: Vec::new(), + object_did: None, + publisher_did: None, + }; + let object_manifest_bytes = serde_json::to_vec(&object_manifest).unwrap(); + + { + let mut cat_files = ipfs.cat_files.lock().await; + cat_files.insert("capsule.json".to_string(), capsule_bytes); + cat_files.insert(OBJECT_MANIFEST_PATH.to_string(), object_manifest_bytes); + cat_files.insert("index.html".to_string(), index_bytes.clone()); + cat_files.insert("docs/readme.md".to_string(), markdown_bytes.clone()); + } + + let capsule_dir = prepare_capsule_from_content_provider(®istry, TEST_CID) + .await + .unwrap(); + assert_eq!( + std::fs::read(capsule_dir.join("index.html")).unwrap(), + index_bytes + ); + assert_eq!( + std::fs::read(capsule_dir.join("docs/readme.md")).unwrap(), + markdown_bytes + ); + assert!(capsule_dir.join(OBJECT_MANIFEST_PATH).is_file()); + std::fs::remove_dir_all(capsule_dir).unwrap(); + } + + #[tokio::test] + async fn content_prepare_data_capsule_rejects_object_hash_mismatch() { + let (_data_dir, registry, ipfs, _content) = registry_with_content_and_ipfs().await; + let capsule_json = serde_json::json!({ + "schema": elastos_common::SCHEMA_V1, + "version": "0.1.0", + "name": "shared-doc", + "role": "content", + "type": "data", + "entrypoint": "index.html" + }); + let capsule_bytes = serde_json::to_vec(&capsule_json).unwrap(); + let original_index = b"viewer".to_vec(); + let tampered_index = b"viewed".to_vec(); + let object_manifest = ContentObjectManifest { + schema: OBJECT_MANIFEST_SCHEMA.to_string(), + kind: "share".to_string(), + content_digest: "sha256:test".to_string(), + files: vec![ + object_file("capsule.json", &capsule_bytes), + object_file("index.html", &original_index), + ], + links: Vec::new(), + object_did: None, + publisher_did: None, + }; + let object_manifest_bytes = serde_json::to_vec(&object_manifest).unwrap(); + + { + let mut cat_files = ipfs.cat_files.lock().await; + cat_files.insert("capsule.json".to_string(), capsule_bytes); + cat_files.insert(OBJECT_MANIFEST_PATH.to_string(), object_manifest_bytes); + cat_files.insert("index.html".to_string(), tampered_index); + } + + let err = prepare_capsule_from_content_provider(®istry, TEST_CID) + .await + .unwrap_err(); + assert!(err + .to_string() + .contains("content object file hash mismatch")); } - let mut file = OpenOptions::new().create(true).append(true).open(path)?; - serde_json::to_writer(&mut file, entry) - .map_err(|err| ProviderError::Provider(format!("content receipt write failed: {err}")))?; - file.write_all(b"\n")?; - Ok(()) -} -fn verify_signed_receipt(receipt: &SignedAvailabilityReceipt) -> Result<(), ProviderError> { - let envelope = serde_json::to_vec(receipt) - .map_err(|err| ProviderError::Provider(format!("content receipt encode failed: {err}")))?; - crate::crypto::verify_signed_json_envelope_against_dids( - &envelope, - AVAILABILITY_RECEIPT_DOMAIN, - std::slice::from_ref(&receipt.signer_did), - ) - .map_err(|err| { - ProviderError::Provider(format!("content receipt verification failed: {err}")) - })?; - Ok(()) -} + #[tokio::test] + async fn content_prepare_capsule_rejects_release_object_as_not_launchable() { + let (_data_dir, registry, ipfs, _content) = registry_with_content_and_ipfs().await; + let release_bytes = br#"{"payload":{},"signature":"00","signer_did":"did:key:z6Mk"}"#; + let object_manifest = ContentObjectManifest { + schema: OBJECT_MANIFEST_SCHEMA.to_string(), + kind: "release".to_string(), + content_digest: "sha256:test".to_string(), + files: vec![object_file("release.json", release_bytes)], + links: Vec::new(), + object_did: Some("elastos://release/stable/0.2.0".to_string()), + publisher_did: Some("did:key:z6Mkpublisher".to_string()), + }; + let object_manifest_bytes = serde_json::to_vec(&object_manifest).unwrap(); -fn now_unix_secs() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} + { + let mut cat_files = ipfs.cat_files.lock().await; + cat_files.insert(OBJECT_MANIFEST_PATH.to_string(), object_manifest_bytes); + } + ipfs.missing_paths + .lock() + .await + .push("capsule.json".to_string()); -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashMap; - use tokio::sync::Mutex; + let err = prepare_capsule_from_content_provider(®istry, TEST_CID) + .await + .unwrap_err(); + assert!(err.to_string().contains("kind 'release'")); + assert!(err.to_string().contains("not a launchable capsule")); + } - const TEST_CID: &str = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; + #[tokio::test] + async fn content_fetch_rejects_invalid_cid_and_path() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let invalid_cid = content + .send_raw(&json!({ + "op": "fetch", + "cid": "not-a-cid", + })) + .await + .unwrap(); + assert_eq!(invalid_cid["status"], "error"); + assert_eq!(invalid_cid["code"], "invalid_cid"); - struct MockIpfsProvider { - add_count: Mutex, - added_files: Mutex>, - added_directories: Mutex>>, - cat_files: Mutex>>, - missing_paths: Mutex>, - pinned: Mutex>, - pin_error: Mutex>, - unpinned: Mutex>, + let invalid_path = content + .send_raw(&json!({ + "op": "fetch", + "cid": TEST_CID, + "path": "../secret", + })) + .await + .unwrap(); + assert_eq!(invalid_path["status"], "error"); + assert_eq!(invalid_path["code"], "invalid_path"); } - struct MockAvailabilityProvider { - requests: Mutex>, - response: Mutex, + #[tokio::test] + async fn content_status_rejects_invalid_cid() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + let invalid_cid = content + .send_raw(&json!({ + "op": "status", + "cid": "not-a-cid", + })) + .await + .unwrap(); + + assert_eq!(invalid_cid["status"], "error"); + assert_eq!(invalid_cid["code"], "invalid_cid"); } - #[async_trait] - impl Provider for MockIpfsProvider { - async fn handle( - &self, - _request: ResourceRequest, - ) -> Result { - Err(ProviderError::Provider( - "mock ipfs provider only supports raw operations".into(), - )) + fn object_file(path: &str, bytes: &[u8]) -> ContentObjectFile { + ContentObjectFile { + path: path.to_string(), + sha256: format!("{:x}", sha2::Sha256::digest(bytes)), + size: bytes.len() as u64, } + } - fn schemes(&self) -> Vec<&'static str> { - Vec::new() - } + #[tokio::test] + async fn content_status_reads_latest_availability_receipt() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + "object_did": "did:key:z6Mkobject", + "publisher_did": "did:key:z6Mkpublisher", + })) + .await + .unwrap(); + content + .send_raw(&json!({ + "op": "unpublish", + "cid": TEST_CID, + })) + .await + .unwrap(); - fn name(&self) -> &'static str { - "mock-ipfs-provider" - } + let status = content + .send_raw(&json!({ + "op": "status", + "cid": TEST_CID, + })) + .await + .unwrap(); - async fn send_raw(&self, request: &Value) -> Result { - match request.get("op").and_then(|op| op.as_str()) { - Some("add_directory") => { - *self.add_count.lock().await += 1; - self.added_directories - .lock() - .await - .push(request["files"].as_array().cloned().unwrap_or_default()); - Ok(provider_ok(json!({ "cid": TEST_CID }))) - } - Some("add_bytes") => { - let filename = request - .get("filename") - .and_then(|filename| filename.as_str()) - .unwrap_or_default() - .to_string(); - self.added_files.lock().await.push(filename); - Ok(provider_ok(json!({ "cid": TEST_CID }))) - } - Some("cat") => { - let path = request - .get("path") - .and_then(|path| path.as_str()) - .unwrap_or("") - .to_string(); - if self - .missing_paths - .lock() - .await - .iter() - .any(|item| item == &path) - { - return Ok(provider_error("not_found", "mock content path missing")); - } - let bytes = self - .cat_files - .lock() - .await - .get(&path) - .cloned() - .unwrap_or_else(|| b"hello content".to_vec()); - Ok(provider_ok(json!({ - "data": base64::engine::general_purpose::STANDARD.encode(bytes) - }))) - } - Some("pin") => { - if let Some(message) = self.pin_error.lock().await.clone() { - return Ok(provider_error("pin_failed", &message)); - } - let cid = request - .get("cid") - .and_then(|cid| cid.as_str()) - .unwrap_or_default() - .to_string(); - self.pinned.lock().await.push(cid); - Ok(provider_ok(json!({}))) - } - Some("unpin") => { - let cid = request - .get("cid") - .and_then(|cid| cid.as_str()) - .unwrap_or_default() - .to_string(); - self.unpinned.lock().await.push(cid); - Ok(provider_ok(json!({}))) - } - _ => Ok(provider_error("unsupported", "unsupported mock ipfs op")), - } - } + assert_eq!(status["status"], "ok"); + assert_eq!(status["data"]["cid"], TEST_CID); + assert_eq!(status["data"]["availability"]["status"], "local_unpinned"); + assert_eq!(status["data"]["availability"]["policy"], "local_unpublish"); + assert_eq!( + status["data"]["availability"]["peer_selection"]["mode"], + "single_local" + ); + assert_eq!( + status["data"]["availability"]["quota"]["policy"], + "not_enforced" + ); + assert_eq!( + status["data"]["availability"]["repair_worker"]["scheduled"], + false + ); + assert_eq!( + status["data"]["availability"]["abuse_controls"]["policy"], + "local_content_backend" + ); + assert_eq!( + status["data"]["availability"]["repair_task"]["status"], + "retired" + ); + assert_eq!( + status["data"]["receipt"]["payload"]["schema"], + AVAILABILITY_RECEIPT_SCHEMA + ); } - #[async_trait] - impl Provider for MockAvailabilityProvider { - async fn handle( - &self, - _request: ResourceRequest, - ) -> Result { - Err(ProviderError::Provider( - "mock availability provider only supports raw operations".into(), - )) - } - - fn schemes(&self) -> Vec<&'static str> { - vec!["availability"] - } + #[tokio::test] + async fn content_status_without_cid_returns_availability_dashboard() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + })) + .await + .unwrap(); - fn name(&self) -> &'static str { - "mock-availability-provider" - } + let status = content + .send_raw(&json!({ + "op": "status", + })) + .await + .unwrap(); - async fn send_raw(&self, request: &Value) -> Result { - self.requests.lock().await.push(request.clone()); - Ok(self.response.lock().await.clone()) - } + assert_eq!(status["status"], "ok"); + assert_eq!(status["data"]["schema"], AVAILABILITY_DASHBOARD_SCHEMA); + assert_eq!(status["data"]["objects"]["tracked"], 1); + assert_eq!(status["data"]["objects"]["by_status"]["local_pinned"], 1); + assert_eq!(status["data"]["objects"]["by_provider"]["ipfs-provider"], 1); + assert_eq!(status["data"]["quota"]["by_status"]["not_enforced"], 1); + assert_eq!(status["data"]["quota"]["enforced"], 0); + assert_eq!( + status["data"]["federated_quota_ledger_policy"]["schema"], + CONTENT_FEDERATED_QUOTA_LEDGER_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["federated_quota_ledger_policy"]["status"], + "federated_quota_ledger_not_configured" + ); + assert_eq!( + status["data"]["federated_quota_ledger_policy"]["federation"]["configured"], + false + ); + assert_eq!( + status["data"]["federated_quota_ledger_policy"]["remote"]["signed_admission_receipts"], + 0 + ); + assert_eq!( + status["data"]["federated_quota_ledger_policy"]["federation"] + ["signed_admission_receipt_exchange"], + false + ); + assert_eq!(status["data"]["proofs"]["live_multi_peer"], 0); + assert_eq!(status["data"]["proofs"]["remote_receipts"], 0); + assert_eq!( + status["data"]["proofs"]["peer_attestation_exchange_policy"]["schema"], + CARRIER_PEER_ATTESTATION_EXCHANGE_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["proofs"]["peer_attestation_exchange_policy"]["status"], + "attestation_exchange_not_configured" + ); + assert_eq!( + status["data"]["proofs"]["peer_attestation_exchange_policy"]["attestation_exchange"] + ["configured"], + false + ); + assert_eq!(status["data"]["accounting"]["accounted_objects"], 1); + assert_eq!(status["data"]["accounting"]["accounted_files"], 1); + assert_eq!(status["data"]["accounting"]["content_bytes"], 7); + assert_eq!(status["data"]["accounting"]["replica_bytes_estimate"], 7); + assert_eq!( + status["data"]["accounting"]["by_source"]["publish_request"], + 1 + ); + assert_eq!( + status["data"]["accounting"]["storage_quota_policy"], + "principal_ledger" + ); + assert_eq!( + status["data"]["accounting"]["ledger"]["schema"], + CONTENT_STORAGE_ACCOUNTING_LEDGER_SCHEMA + ); + assert_eq!(status["data"]["accounting"]["ledger"]["durable"], true); + assert_eq!(status["data"]["accounting"]["ledger"]["tracked_objects"], 1); + assert_eq!(status["data"]["accounting"]["ledger"]["active_objects"], 1); + assert_eq!( + status["data"]["accounting"]["ledger"]["tracked_principals"], + 1 + ); + assert_eq!(status["data"]["accounting"]["ledger"]["content_bytes"], 7); + assert_eq!( + status["data"]["accounting"]["ledger"]["replica_bytes_estimate"], + 7 + ); + assert_eq!( + status["data"]["accounting"]["ledger"]["market_policy"]["settlement"], + "not_configured" + ); + assert_eq!( + status["data"]["accounting"]["ledger"]["market_policy"]["admission_policy"]["schema"], + CONTENT_STORAGE_MARKET_ADMISSION_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["accounting"]["ledger"]["market_policy"]["settlement_policy"]["schema"], + CONTENT_STORAGE_SETTLEMENT_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["storage_settlement_policy"]["schema"], + CONTENT_STORAGE_SETTLEMENT_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["storage_settlement_policy"]["status"], + "settlement_not_configured" + ); + assert_eq!( + status["data"]["storage_settlement_policy"]["production_federation"]["configured"], + false + ); + assert_eq!( + status["data"]["storage_market_admission_policy"]["schema"], + CONTENT_STORAGE_MARKET_ADMISSION_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["storage_market_admission_policy"]["status"], + "production_storage_market_admission_not_configured" + ); + assert_eq!( + status["data"]["storage_market_admission_policy"]["production_market"]["configured"], + false + ); + assert_eq!( + status["data"]["storage_market_admission_policy"]["current_admission"] + ["signed_admission_receipts"], + 0 + ); + let principals = status["data"]["accounting"]["ledger"]["by_principal"] + .as_object() + .expect("ledger should group by principal"); + assert_eq!(principals.len(), 1); + let principal = principals.values().next().unwrap(); + assert_eq!(principal["active_objects"], 1); + assert_eq!(principal["content_bytes"], 7); + assert_eq!(status["data"]["abuse_controls"]["enforced"], 0); + assert_eq!(status["data"]["abuse_controls"]["throttled"], 0); + assert_eq!( + status["data"]["abuse_controls"]["by_policy"]["local_content_backend"], + 1 + ); + assert_eq!( + status["data"]["network_abuse_policy"]["schema"], + CONTENT_NETWORK_ABUSE_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["network_abuse_policy"]["local_guardrails"] + ["provider_invocation_required"], + true + ); + assert_eq!( + status["data"]["network_abuse_policy"]["network_federation"]["configured"], + false + ); + assert_eq!( + status["data"]["operator_dashboard"]["schema"], + CONTENT_OPERATOR_DASHBOARD_SCHEMA + ); + assert_eq!( + status["data"]["operator_dashboard"]["storage_pressure"]["status"], + "accounting_observed" + ); + assert_eq!( + status["data"]["operator_dashboard"]["storage_pressure"]["content_bytes"], + 7 + ); + assert_eq!( + status["data"]["operator_dashboard"]["storage_pressure"]["settlement_policy"]["status"], + "settlement_not_configured" + ); + assert_eq!( + status["data"]["operator_dashboard"]["storage_pressure"]["market_admission_policy"] + ["schema"], + CONTENT_STORAGE_MARKET_ADMISSION_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["operator_dashboard"]["storage_pressure"]["quota_ledger_policy"] + ["schema"], + CONTENT_FEDERATED_QUOTA_LEDGER_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["operator_dashboard"]["storage_pressure"] + ["top_principals_by_content_bytes"][0]["content_bytes"], + 7 + ); + assert_eq!( + status["data"]["operator_dashboard"]["fleet_history"]["tracked_tasks"], + 1 + ); + assert_eq!( + status["data"]["operator_dashboard"]["fleet_history"]["recent"][0]["status"], + "local_only" + ); + assert_eq!( + status["data"]["operator_dashboard"]["fleet_history"]["external_repair_fleet_policy"] + ["schema"], + EXTERNAL_REPAIR_FLEET_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["operator_dashboard"]["production_federation"]["configured"], + false + ); + assert_eq!( + status["data"]["operator_dashboard"]["proof_summary"] + ["peer_attestation_exchange_policy"]["schema"], + CARRIER_PEER_ATTESTATION_EXCHANGE_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["operator_dashboard"]["proof_summary"] + ["peer_attestation_exchange_policy"]["status"], + "attestation_exchange_not_configured" + ); + assert_eq!(status["data"]["repair"]["tracked_tasks"], 1); + assert_eq!(status["data"]["repair"]["by_status"]["local_only"], 1); + assert_eq!( + status["data"]["scheduler"]["manual_trigger"], + "elastos content repair-worker" + ); + assert_eq!( + status["data"]["scheduler"]["provider_invocation_required"], + true + ); + assert_eq!( + status["data"]["repair_fleet"]["schema"], + REPAIR_FLEET_SCHEMA + ); + assert_eq!( + status["data"]["repair_fleet"]["policy"], + "single_runtime_provider_repair_fleet" + ); + assert_eq!( + status["data"]["repair_fleet"]["coordinator"]["provider"], + "content-provider" + ); + assert_eq!( + status["data"]["repair_fleet"]["workers"][0]["runtime_invocation_required"], + true + ); + assert_eq!( + status["data"]["repair_fleet"]["task_pressure"]["tracked"], + 1 + ); + assert_eq!( + status["data"]["repair_fleet"]["production_federation"]["configured"], + false + ); + assert_eq!( + status["data"]["external_repair_fleet_policy"]["schema"], + EXTERNAL_REPAIR_FLEET_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["external_repair_fleet_policy"]["external_fleet"]["configured"], + false + ); + assert_eq!( + status["data"]["federated_operator_alerting_policy"]["schema"], + CONTENT_FEDERATED_OPERATOR_ALERTING_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["federated_operator_alerting_policy"]["status"], + "provider_local_dashboard_only" + ); + assert_eq!( + status["data"]["federated_operator_alerting_policy"]["local_signals"] + ["storage_pressure_status"], + "accounting_observed" + ); + assert_eq!( + status["data"]["federated_operator_alerting_policy"]["local_signals"]["content_bytes"], + 7 + ); + assert_eq!( + status["data"]["federated_operator_alerting_policy"]["federation"]["configured"], + false + ); + assert_eq!( + status["data"]["operator_dashboard"]["federated_operator_alerting_policy"]["schema"], + CONTENT_FEDERATED_OPERATOR_ALERTING_POLICY_SCHEMA + ); + assert_eq!( + status["data"]["operator_dashboard"]["federated_operator_alerting_policy"] + ["local_dashboard"]["schema"], + CONTENT_OPERATOR_DASHBOARD_SCHEMA + ); + assert_eq!( + status["data"]["federated_operator_alerting_policy"]["operator_alert_sink"] + ["configured"], + false + ); + assert_eq!( + status["data"]["federated_operator_alerting_policy"]["federation"]["alert_delivery"], + false + ); } - async fn registry_with_content_and_ipfs() -> ( - tempfile::TempDir, - Arc, - Arc, - Arc, - ) { - let data_dir = tempfile::tempdir().unwrap(); - let registry = Arc::new(ProviderRegistry::new()); - let ipfs = Arc::new(MockIpfsProvider { - add_count: Mutex::new(0), - added_files: Mutex::new(Vec::new()), - added_directories: Mutex::new(Vec::new()), - cat_files: Mutex::new(HashMap::new()), - missing_paths: Mutex::new(Vec::new()), - pinned: Mutex::new(Vec::new()), - pin_error: Mutex::new(None), - unpinned: Mutex::new(Vec::new()), - }); - registry - .register_sub_provider("ipfs", ipfs.clone()) + #[tokio::test] + async fn content_status_can_emit_operator_alert_receipt_without_sink() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + content + .send_raw(&json!({ + "op": "publish", + "kind": "directory", + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + })) .await .unwrap(); - let content = Arc::new(ContentProvider::new( - data_dir.path().to_path_buf(), - Arc::downgrade(®istry), - )); - registry.register(content.clone()).await; - registry - .register_sub_provider("content", content.clone()) + + let status = content + .send_raw(&json!({ + "op": "status", + "emit_operator_alert": true, + })) .await .unwrap(); - (data_dir, registry, ipfs, content) + + assert_eq!( + status["data"]["operator_alert_delivery"]["schema"], + CONTENT_OPERATOR_ALERT_RECEIPT_SCHEMA + ); + assert_eq!( + status["data"]["operator_alert_delivery"]["delivery"]["status"], + "not_configured" + ); + assert_eq!( + status["data"]["operator_alert_delivery"]["alert"]["schema"], + CONTENT_OPERATOR_ALERT_SCHEMA + ); + assert_eq!( + status["data"]["operator_alert_delivery"]["alert"]["local_signals"] + ["storage_pressure_status"], + "accounting_observed" + ); + let outbox = std::fs::read_to_string(content.operator_alert_receipts_path()).unwrap(); + assert!(outbox.contains(CONTENT_OPERATOR_ALERT_RECEIPT_SCHEMA)); + assert!(outbox.contains(CONTENT_OPERATOR_ALERT_SCHEMA)); } #[tokio::test] - async fn content_publish_wraps_ipfs_with_availability_status() { - let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; - let response = content + async fn content_status_delivers_operator_alert_to_configured_loopback_sink() { + let (url, handle) = spawn_operator_alert_sink(); + let (_data_dir, _registry, _ipfs, content) = + registry_with_content_and_ipfs_with_alert_config(Some(json!({ + "url": url, + "authorization": "Bearer operator-alert-test", + "timeout_secs": 5, + }))) + .await; + content .send_raw(&json!({ "op": "publish", "kind": "directory", @@ -1651,691 +11721,1342 @@ mod tests { .await .unwrap(); - assert_eq!(response["status"], "ok"); - assert_eq!(response["data"]["cid"], TEST_CID); - assert_eq!(response["data"]["uri"], format!("elastos://{TEST_CID}")); - assert_eq!(response["data"]["availability"]["status"], "local_pinned"); + let status = content + .send_raw(&json!({ + "op": "status", + "emit_operator_alert": true, + })) + .await + .unwrap(); + assert_eq!( - response["data"]["receipt"]["payload"]["schema"], - AVAILABILITY_RECEIPT_SCHEMA + status["data"]["federated_operator_alerting_policy"]["status"], + "provider_local_alert_sink_configured" ); - assert_eq!(response["data"]["receipt"]["payload"]["cid"], TEST_CID); assert_eq!( - response["data"]["receipt"]["payload"]["status"], - "local_pinned" + status["data"]["federated_operator_alerting_policy"]["operator_alert_sink"] + ["configured"], + true ); - assert!(response["data"]["receipt"]["signature"] - .as_str() - .is_some_and(|sig| !sig.is_empty())); - assert!(response["data"]["receipt"]["signer_did"] - .as_str() - .is_some_and(|did| did.starts_with("did:key:z6Mk"))); - let signer_did = response["data"]["receipt"]["signer_did"] - .as_str() - .unwrap() - .to_string(); - let signed_receipt = serde_json::to_vec(&response["data"]["receipt"]).unwrap(); - crate::crypto::verify_signed_json_envelope_against_dids( - &signed_receipt, - AVAILABILITY_RECEIPT_DOMAIN, - &[signer_did], - ) - .unwrap(); - assert_eq!(*ipfs.add_count.lock().await, 1); + assert_eq!( + status["data"]["federated_operator_alerting_policy"]["operator_alert_sink"] + ["credential_exposed"], + false + ); + assert_eq!( + status["data"]["federated_operator_alerting_policy"]["federation"]["alert_delivery"], + true + ); + assert_eq!( + status["data"]["federated_operator_alerting_policy"]["federation"]["configured"], + false + ); + assert_eq!( + status["data"]["operator_alert_delivery"]["delivery"]["status"], + "delivered" + ); + assert_eq!( + status["data"]["operator_alert_delivery"]["delivery"]["http_status"], + 204 + ); + let request = handle.join().unwrap(); + assert!(request.starts_with("POST /content-alerts HTTP/1.1")); + assert!(request + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer operator-alert-test"))); + assert!(request.contains(CONTENT_OPERATOR_ALERT_SCHEMA)); + assert!(!request + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("operator-alert-test")); } #[tokio::test] - async fn content_publish_uses_registered_availability_provider() { - let (_data_dir, registry, _ipfs, content) = registry_with_content_and_ipfs().await; - let availability = Arc::new(MockAvailabilityProvider { - requests: Mutex::new(Vec::new()), - response: Mutex::new(provider_ok(json!({ - "availability": { - "status": "network_available", - "provider": "elacity-supernode", - "policy": "smartweb_default", - "replicas": 3 - } - }))), - }); - registry.register(availability.clone()).await; - - let response = content + async fn content_status_exchanges_operator_alert_with_configured_federated_endpoint() { + let (url, handle) = spawn_federated_operator_alert_exchange(json!({ + "accepted": true, + "status": "accepted", + "exchange_id": "operator-alert-exchange:test", + "receipt_id": "operator-alert-receipt:123", + })); + let (_data_dir, _registry, _ipfs, content) = + registry_with_content_and_ipfs_with_federated_alert_exchange_config(Some(json!({ + "url": url, + "authorization": "Bearer federated-alert-test", + "timeout_secs": 5, + }))) + .await; + content .send_raw(&json!({ "op": "publish", "kind": "directory", "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], - "object_did": "did:key:z6Mkobject", - "publisher_did": "did:key:z6Mkpublisher", "pin": true, })) .await .unwrap(); - assert_eq!(response["status"], "ok"); + let status = content + .send_raw(&json!({ + "op": "status", + "emit_operator_alert": true, + })) + .await + .unwrap(); + assert_eq!( - response["data"]["availability"]["status"], - "network_available" + status["data"]["federated_operator_alerting_policy"]["status"], + "federated_alert_exchange_configured" ); assert_eq!( - response["data"]["availability"]["provider"], - "elacity-supernode" + status["data"]["federated_operator_alerting_policy"]["federated_alert_exchange"] + ["configured"], + true ); - assert_eq!(response["data"]["availability"]["replicas"], 3); assert_eq!( - response["data"]["receipt"]["payload"]["status"], - "network_available" + status["data"]["federated_operator_alerting_policy"]["federated_alert_exchange"] + ["credential_exposed"], + false ); assert_eq!( - response["data"]["receipt"]["payload"]["provider"], - "elacity-supernode" + status["data"]["federated_operator_alerting_policy"]["federation"]["configured"], + true ); assert_eq!( - response["data"]["receipt"]["payload"]["policy"], - "smartweb_default" + status["data"]["federated_operator_alerting_policy"]["federation"] + ["fleet_alert_exchange"], + true ); - - let requests = availability.requests.lock().await; - assert_eq!(requests.len(), 1); - assert_eq!(requests[0]["op"], "ensure"); - assert_eq!(requests[0]["cid"], TEST_CID); - assert_eq!(requests[0]["uri"], format!("elastos://{TEST_CID}")); - assert_eq!(requests[0]["local"]["status"], "local_pinned"); - assert_eq!(requests[0]["object_did"], "did:key:z6Mkobject"); - assert_eq!(requests[0]["publisher_did"], "did:key:z6Mkpublisher"); + assert_eq!( + status["data"]["operator_alert_delivery"]["delivery"]["status"], + "not_configured" + ); + assert_eq!( + status["data"]["operator_alert_delivery"]["federated_exchange"]["schema"], + CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_RECEIPT_SCHEMA + ); + assert_eq!( + status["data"]["operator_alert_delivery"]["federated_exchange"]["status"], + "accepted" + ); + assert_eq!( + status["data"]["operator_alert_delivery"]["federated_exchange"]["remote_exchange_id"], + "operator-alert-exchange:test" + ); + let request = handle.join().unwrap(); + assert!(request.starts_with("POST /alerts/exchange HTTP/1.1")); + assert!(request + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer federated-alert-test"))); + assert!(request.contains(CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_REQUEST_SCHEMA)); + assert!(request.contains(CONTENT_OPERATOR_ALERT_SCHEMA)); + assert!(!request + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("federated-alert-test")); } #[tokio::test] - async fn content_publish_directory_injects_object_manifest() { - let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; - let response = content + async fn content_storage_accounting_ledger_is_durable_per_principal() { + let (data_dir, registry, _ipfs, content) = registry_with_content_and_ipfs().await; + content .send_raw(&json!({ "op": "publish", "kind": "directory", - "object_kind": "document", "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, "object_did": "did:key:z6Mkobject", "publisher_did": "did:key:z6Mkpublisher", })) .await .unwrap(); - assert_eq!(response["status"], "ok"); - let directories = ipfs.added_directories.lock().await; - let manifest_entry = directories[0] - .iter() - .find(|entry| entry["path"].as_str() == Some(OBJECT_MANIFEST_PATH)) - .expect("object manifest should be injected"); - let manifest_bytes = base64::engine::general_purpose::STANDARD - .decode(manifest_entry["data"].as_str().unwrap()) + let restarted_content = + ContentProvider::new(data_dir.path().to_path_buf(), Arc::downgrade(®istry)); + let status = restarted_content + .send_raw(&json!({ + "op": "status", + })) + .await .unwrap(); - let manifest: ContentObjectManifest = serde_json::from_slice(&manifest_bytes).unwrap(); - - assert_eq!(manifest.schema, OBJECT_MANIFEST_SCHEMA); - assert_eq!(manifest.kind, "document"); - assert_eq!(manifest.object_did.as_deref(), Some("did:key:z6Mkobject")); - assert_eq!( - manifest.publisher_did.as_deref(), - Some("did:key:z6Mkpublisher") - ); - assert_eq!(manifest.files.len(), 1); - assert_eq!(manifest.files[0].path, "index.md"); - assert!(manifest.content_digest.starts_with("sha256:")); - } - - fn sealed_object_value() -> Value { - json!({ - "schema": "elastos.sealed.object/v1", - "payload_cid": TEST_CID, - "rights_policy_cid": TEST_CID, - "availability_receipt_cid": TEST_CID, - "key_envelope": { - "scheme": "elastos-pq-hybrid-threshold-v0", - "kid": "kid:test", - "wrapped_cek": "wrapped", - "policy_hash": "sha256:test", - "algorithms": { - "cipher": "aes-256-gcm", - "signature": ["ed25519", "ml-dsa-65"], - "kem": ["x25519", "ml-kem-768"], - "share_scheme": "shamir-t-of-n" - } - }, - "viewer": { - "required_interface": "elastos.viewer/document@1" - } - }) - } - - fn sealed_object_data() -> String { - base64::engine::general_purpose::STANDARD - .encode(serde_json::to_vec(&sealed_object_value()).unwrap()) - } - - fn sealed_object_data_from(value: &Value) -> String { - base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(value).unwrap()) - } - - fn sealed_object_links() -> Vec { - vec![ - json!({"rel": "availability.receipt", "cid": TEST_CID}), - json!({"rel": "payload", "cid": TEST_CID}), - json!({"rel": "provenance", "cid": TEST_CID}), - json!({"rel": "rights.policy", "cid": TEST_CID}), - ] - } + let principal = + &status["data"]["accounting"]["ledger"]["by_principal"]["did:key:z6Mkpublisher"]; + assert_eq!(principal["tracked_objects"], 1); + assert_eq!(principal["active_objects"], 1); + assert_eq!(principal["content_bytes"], 7); + assert_eq!(principal["replica_bytes_estimate"], 7); - #[tokio::test] - async fn content_publish_directory_accepts_linked_release_and_sealed_manifests() { - let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; - let response = content + content .send_raw(&json!({ - "op": "publish", - "kind": "directory", - "object_kind": "sealed", - "links": sealed_object_links(), - "files": [{"path": "sealed.json", "data": sealed_object_data()}], + "op": "unpublish", + "cid": TEST_CID, })) .await .unwrap(); - - assert_eq!(response["status"], "ok"); - let directories = ipfs.added_directories.lock().await; - let manifest_entry = directories[0] - .iter() - .find(|entry| entry["path"].as_str() == Some(OBJECT_MANIFEST_PATH)) - .expect("object manifest should be injected"); - let manifest_bytes = base64::engine::general_purpose::STANDARD - .decode(manifest_entry["data"].as_str().unwrap()) + let status = restarted_content + .send_raw(&json!({ + "op": "status", + })) + .await .unwrap(); - let manifest: ContentObjectManifest = serde_json::from_slice(&manifest_bytes).unwrap(); - - assert_eq!(manifest.kind, "sealed"); - assert_eq!(manifest.links.len(), 4); - assert_eq!(manifest.links[0].rel, "availability.receipt"); - assert_eq!(manifest.links[0].cid, TEST_CID); - assert_eq!(manifest.links[1].rel, "payload"); - assert_eq!(manifest.links[1].cid, TEST_CID); - assert_eq!(manifest.links[2].rel, "provenance"); - assert_eq!(manifest.links[2].cid, TEST_CID); - assert_eq!(manifest.links[3].rel, "rights.policy"); - assert_eq!(manifest.links[3].cid, TEST_CID); - drop(directories); - - let release_response = content + let ledger = &status["data"]["accounting"]["ledger"]; + assert_eq!(ledger["tracked_objects"], 1); + assert_eq!(ledger["active_objects"], 0); + let principal = &ledger["by_principal"]["did:key:z6Mkpublisher"]; + assert_eq!(principal["tracked_objects"], 1); + assert_eq!(principal["active_objects"], 0); + assert_eq!(principal["by_status"]["local_unpinned"], 1); + + let object_status = restarted_content .send_raw(&json!({ - "op": "publish", - "kind": "directory", - "object_kind": "release", - "links": [{"rel": "sealed", "cid": TEST_CID}], - "files": [{"path": "release.json", "data": "e30="}], + "op": "status", + "cid": TEST_CID, })) .await .unwrap(); - assert_eq!(release_response["status"], "ok"); + assert_eq!( + object_status["data"]["availability"]["storage_accounting"]["principal_did"], + "did:key:z6Mkpublisher" + ); } #[tokio::test] - async fn content_publish_directory_rejects_incomplete_sealed_objects() { - let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; - let missing_descriptor = content + async fn content_publish_enforces_principal_storage_quota() { + let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; + let rejected = content .send_raw(&json!({ "op": "publish", "kind": "directory", - "object_kind": "sealed", - "links": sealed_object_links(), - "files": [{"path": "payload.bin", "data": "c2VhbGVkCg=="}], + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + "publisher_did": "did:key:z6Mkpublisher", + "availability_requirements": { + "max_storage_bytes_per_principal": 6 + } })) .await - .unwrap_err(); - assert!(missing_descriptor - .to_string() - .contains("sealed content object requires sealed.json")); + .unwrap(); - let missing_provenance = content - .send_raw(&json!({ - "op": "publish", - "kind": "directory", - "object_kind": "sealed", - "links": [ - {"rel": "availability.receipt", "cid": TEST_CID}, - {"rel": "payload", "cid": TEST_CID}, - {"rel": "rights.policy", "cid": TEST_CID} - ], - "files": [{"path": "sealed.json", "data": sealed_object_data()}], - })) - .await - .unwrap_err(); - assert!(missing_provenance - .to_string() - .contains("sealed content object requires provenance link")); + assert_eq!(rejected["status"], "error"); + assert_eq!(rejected["code"], "storage_quota_exceeded"); + assert_eq!(*ipfs.add_count.lock().await, 0); - let mut weak_envelope = sealed_object_value(); - weak_envelope["key_envelope"]["algorithms"]["cipher"] = Value::String("aes-128-gcm".into()); - let weak_cipher = content + let accepted = content .send_raw(&json!({ "op": "publish", "kind": "directory", - "object_kind": "sealed", - "links": sealed_object_links(), - "files": [{"path": "sealed.json", "data": sealed_object_data_from(&weak_envelope)}], + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + "publisher_did": "did:key:z6Mkpublisher", + "availability_requirements": { + "max_storage_bytes_per_principal": 7 + } })) .await - .unwrap_err(); - assert!(weak_cipher - .to_string() - .contains("key_envelope.algorithms.cipher uses unsupported algorithm")); - } + .unwrap(); - #[tokio::test] - async fn content_publish_directory_sorts_entries_for_stable_cids() { - let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; - let response = content + assert_eq!(accepted["status"], "ok"); + assert_eq!( + accepted["data"]["receipt"]["payload"]["accounting"]["storage_quota"]["policy"], + "principal_storage_quota" + ); + assert_eq!( + accepted["data"]["receipt"]["payload"]["accounting"]["storage_quota"]["status"], + "within_quota" + ); + + let status = content .send_raw(&json!({ - "op": "publish", - "kind": "directory", - "object_kind": "share", - "files": [ - {"path": "z.md", "data": "eg=="}, - {"path": "a.md", "data": "YQ=="} - ], + "op": "status", })) .await .unwrap(); - - assert_eq!(response["status"], "ok"); - let directories = ipfs.added_directories.lock().await; - let paths = directories[0] - .iter() - .map(|entry| entry["path"].as_str().unwrap().to_string()) - .collect::>(); - assert_eq!(paths, vec![OBJECT_MANIFEST_PATH, "a.md", "z.md"]); + assert_eq!(status["data"]["accounting"]["storage_quota_enforced"], 1); + assert_eq!(status["data"]["accounting"]["ledger"]["quota_enforced"], 1); + assert_eq!( + status["data"]["accounting"]["ledger"]["by_principal"]["did:key:z6Mkpublisher"] + ["quota_enforced"], + 1 + ); } #[tokio::test] - async fn content_publish_directory_rejects_ambiguous_object_shape() { + async fn content_admission_accepts_within_principal_quota() { let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; - let duplicate_path = content - .send_raw(&json!({ - "op": "publish", - "kind": "directory", - "object_kind": "share", - "files": [ - {"path": "index.md", "data": "YQ=="}, - {"path": "index.md", "data": "Yg=="} - ], - })) - .await - .unwrap_err(); - assert!(duplicate_path - .to_string() - .contains("duplicate directory publish path")); - - let unknown_kind = content + content .send_raw(&json!({ "op": "publish", "kind": "directory", - "object_kind": "random", - "files": [{"path": "index.md", "data": "YQ=="}], + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + "publisher_did": "did:key:z6Mkpublisher", })) .await - .unwrap_err(); - assert!(unknown_kind - .to_string() - .contains("unsupported content object kind")); + .unwrap(); - let invalid_link = content + let admission = content .send_raw(&json!({ - "op": "publish", - "kind": "directory", - "object_kind": "release", - "links": [{"rel": "Bad Rel", "cid": TEST_CID}], - "files": [{"path": "release.json", "data": "e30="}], + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } })) .await - .unwrap_err(); - assert!(invalid_link.to_string().contains("content object link rel")); + .unwrap(); - let invalid_link_cid = content + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], true); + assert_eq!( + admission["data"]["admission"]["quota"]["status"], + "within_quota" + ); + assert_eq!( + admission["data"]["admission"]["quota"]["active_content_bytes"], + 7 + ); + assert_eq!( + admission["data"]["admission"]["quota"]["projected_content_bytes"], + 10 + ); + let signer_did = admission["data"]["receipt"]["signer_did"] + .as_str() + .unwrap() + .to_string(); + let signed_receipt = serde_json::to_vec(&admission["data"]["receipt"]).unwrap(); + crate::crypto::verify_signed_json_envelope_against_dids( + &signed_receipt, + CONTENT_ADMISSION_DOMAIN, + &[signer_did], + ) + .unwrap(); + assert_eq!( + admission["data"]["receipt"]["payload"], + admission["data"]["admission"] + ); + } + + #[tokio::test] + async fn content_admission_rejects_quota_exceeded() { + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; + content .send_raw(&json!({ "op": "publish", "kind": "directory", - "object_kind": "release", - "links": [{"rel": "release", "cid": "not-a-cid"}], - "files": [{"path": "release.json", "data": "e30="}], + "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], + "pin": true, + "publisher_did": "did:key:z6Mkpublisher", })) .await - .unwrap_err(); - assert!(invalid_link_cid - .to_string() - .contains("invalid content object link cid")); - } + .unwrap(); - #[tokio::test] - async fn content_unpublish_wraps_ipfs_unpin() { - let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; - let response = content + let admission = content .send_raw(&json!({ - "op": "unpublish", - "cid": TEST_CID, + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 4, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } })) .await .unwrap(); - assert_eq!(response["status"], "ok"); - assert_eq!(response["data"]["cid"], TEST_CID); + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], false); + assert_eq!(admission["data"]["admission"]["status"], "rejected"); assert_eq!( - response["data"]["receipt"]["payload"]["status"], - "local_unpinned" + admission["data"]["admission"]["quota"]["status"], + "quota_exceeded" ); assert_eq!( - ipfs.unpinned.lock().await.as_slice(), - [TEST_CID.to_string()] + admission["data"]["admission"]["quota"]["projected_content_bytes"], + 11 + ); + let signer_did = admission["data"]["receipt"]["signer_did"] + .as_str() + .unwrap() + .to_string(); + let signed_receipt = serde_json::to_vec(&admission["data"]["receipt"]).unwrap(); + crate::crypto::verify_signed_json_envelope_against_dids( + &signed_receipt, + CONTENT_ADMISSION_DOMAIN, + &[signer_did], + ) + .unwrap(); + assert_eq!( + admission["data"]["receipt"]["payload"], + admission["data"]["admission"] ); } #[tokio::test] - async fn content_repair_pins_cid_and_records_receipt() { - let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; - let response = content + async fn content_admission_records_configured_federated_quota_ledger_acceptance() { + let receipt = signed_federated_quota_ledger_exchange_receipt(true, None); + let (url, handle) = spawn_federated_quota_ledger_exchange_endpoint(json!({ + "accepted": true, + "status": "accepted", + "exchange_id": "quota-exchange:test", + "receipt_id": "quota-receipt:accepted", + "receipt": receipt, + })); + let (_data_dir, _registry, _ipfs, content) = + registry_with_content_and_ipfs_with_quota_ledger_exchange_config(Some(json!({ + "url": url, + "authorization": "Bearer quota-test", + "timeout_secs": 5, + }))) + .await; + + let admission = content .send_raw(&json!({ - "op": "repair", - "cid": TEST_CID, + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } })) .await .unwrap(); - assert_eq!(response["status"], "ok"); - assert_eq!(response["data"]["availability"]["status"], "local_pinned"); + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], true); assert_eq!( - response["data"]["receipt"]["payload"]["policy"], - "local_repair_pin" + admission["data"]["admission"]["federated_quota_ledger_exchange"]["schema"], + CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_RECEIPT_SCHEMA + ); + assert_eq!( + admission["data"]["admission"]["federated_quota_ledger_exchange"]["signed_receipt"] + ["verified"], + true + ); + assert_eq!( + admission["data"]["admission"]["quota"]["federated_quota_ledger_policy"]["status"], + "federated_quota_ledger_accepted" + ); + assert_eq!( + admission["data"]["admission"]["quota"]["federated_quota_ledger_policy"]["federation"] + ["configured"], + true + ); + let signer_did = admission["data"]["receipt"]["signer_did"] + .as_str() + .unwrap() + .to_string(); + let signed_receipt = serde_json::to_vec(&admission["data"]["receipt"]).unwrap(); + crate::crypto::verify_signed_json_envelope_against_dids( + &signed_receipt, + CONTENT_ADMISSION_DOMAIN, + &[signer_did], + ) + .unwrap(); + assert_eq!( + admission["data"]["receipt"]["payload"], + admission["data"]["admission"] ); - assert_eq!(ipfs.pinned.lock().await.as_slice(), [TEST_CID.to_string()]); - } - #[tokio::test] - async fn content_ensure_pins_cid_and_records_policy() { - let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; - let response = content + let status = content .send_raw(&json!({ - "op": "ensure", - "cid": TEST_CID, + "op": "status", })) .await .unwrap(); - - assert_eq!(response["status"], "ok"); - assert_eq!(response["data"]["availability"]["status"], "local_pinned"); assert_eq!( - response["data"]["receipt"]["payload"]["policy"], - "local_ensure_pin" + status["data"]["federated_quota_ledger_policy"]["federation"]["configured"], + true ); - assert_eq!(ipfs.pinned.lock().await.as_slice(), [TEST_CID.to_string()]); + assert_eq!( + status["data"]["federated_quota_ledger_policy"]["federation"]["exchange_client"] + ["authorization_configured"], + true + ); + assert!(!status.to_string().contains("quota-test")); + + let request = handle.join().unwrap(); + assert!(request.starts_with("POST /quota/exchange HTTP/1.1")); + assert!(request + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer quota-test"))); + assert!(request.contains(CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_SCHEMA)); + let body = request.split("\r\n\r\n").nth(1).unwrap_or(""); + assert!(!body.contains("quota-test")); + let signed_request: Value = serde_json::from_str(body).unwrap(); + assert_eq!( + signed_request["payload"]["schema"], + CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_SCHEMA + ); + assert_eq!( + signed_request["payload"]["authority"]["credential_exposed"], + false + ); + let request_signer = signed_request["signer_did"].as_str().unwrap().to_string(); + let signed_request_bytes = serde_json::to_vec(&signed_request).unwrap(); + crate::crypto::verify_signed_json_envelope_against_dids( + &signed_request_bytes, + CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_DOMAIN, + &[request_signer], + ) + .unwrap(); } #[tokio::test] - async fn content_repair_records_repair_needed_when_pin_fails() { - let (_data_dir, _registry, ipfs, content) = registry_with_content_and_ipfs().await; - *ipfs.pin_error.lock().await = Some("not available".to_string()); + async fn content_admission_accepts_configured_federated_quota_ledger_quorum() { + let receipt_a = signed_federated_quota_ledger_exchange_receipt(true, None); + let (url_a, handle_a) = spawn_federated_quota_ledger_exchange_endpoint(json!({ + "accepted": true, + "status": "accepted", + "exchange_id": "quota-exchange:a", + "receipt_id": "quota-receipt:a", + "receipt": receipt_a, + })); + let receipt_b = signed_federated_quota_ledger_exchange_receipt(true, None); + let (url_b, handle_b) = spawn_federated_quota_ledger_exchange_endpoint(json!({ + "accepted": true, + "status": "accepted", + "exchange_id": "quota-exchange:b", + "receipt_id": "quota-receipt:b", + "receipt": receipt_b, + })); + let (_data_dir, _registry, _ipfs, content) = + registry_with_content_and_ipfs_with_quota_ledger_exchange_config(Some(json!({ + "quorum": 2, + "endpoints": [ + { + "id": "ledger-a", + "url": url_a, + "authorization": "Bearer quota-a", + "timeout_secs": 5 + }, + { + "id": "ledger-b", + "url": url_b, + "authorization": "Bearer quota-b", + "timeout_secs": 5 + } + ] + }))) + .await; - let response = content + let admission = content .send_raw(&json!({ - "op": "repair", - "cid": TEST_CID, + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } })) .await .unwrap(); - assert_eq!(response["status"], "ok"); - assert_eq!(response["data"]["availability"]["status"], "repair_needed"); - assert_eq!(response["data"]["availability"]["reason"], "not available"); + let exchange = &admission["data"]["admission"]["federated_quota_ledger_exchange"]; + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], true); + assert_eq!(exchange["accepted"], true); + assert_eq!(exchange["quorum"]["required"], 2); + assert_eq!(exchange["quorum"]["endpoint_count"], 2); + assert_eq!(exchange["quorum"]["accepted"], 2); + assert_eq!(exchange["signed_receipt"]["verified"], true); + assert_eq!(exchange["exchange"]["multi_endpoint"], true); + assert_eq!(exchange["exchange"]["endpoint_count"], 2); assert_eq!( - response["data"]["receipt"]["payload"]["status"], - "repair_needed" + admission["data"]["admission"]["quota"]["federated_quota_ledger_policy"]["status"], + "federated_quota_ledger_accepted" + ); + + let status = content + .send_raw(&json!({ + "op": "status", + })) + .await + .unwrap(); + assert_eq!( + status["data"]["federated_quota_ledger_policy"]["federation"]["exchange_client"] + ["endpoint_count"], + 2 + ); + assert_eq!( + status["data"]["federated_quota_ledger_policy"]["federation"]["exchange_client"] + ["quorum_required"], + 2 ); + assert!(!status.to_string().contains("quota-a")); + assert!(!status.to_string().contains("quota-b")); + + let request_a = handle_a.join().unwrap(); + let request_b = handle_b.join().unwrap(); + assert!(request_a + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer quota-a"))); + assert!(request_b + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer quota-b"))); + assert!(!request_a + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("quota-a")); + assert!(!request_b + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("quota-b")); } #[tokio::test] - async fn content_publish_file_wraps_ipfs_bytes_with_receipt() { - let (_data_dir, registry, ipfs, _content) = registry_with_content_and_ipfs().await; - let cid = publish_bytes_via_provider( - ®istry, - "provenance.json", - br#"{"ok":true}"#, - Some("did:key:z6Mkobject"), - Some("did:key:z6Mkpublisher"), - ) - .await - .unwrap(); + async fn content_admission_rejects_when_configured_federated_quota_ledger_rejects() { + let receipt = + signed_federated_quota_ledger_exchange_receipt(false, Some("ledger exhausted")); + let (url, handle) = spawn_federated_quota_ledger_exchange_endpoint(json!({ + "accepted": false, + "status": "rejected", + "reason": "ledger exhausted", + "receipt": receipt, + })); + let (_data_dir, _registry, _ipfs, content) = + registry_with_content_and_ipfs_with_quota_ledger_exchange_config(Some(json!({ + "url": url, + "authorization": "Bearer quota-test", + "timeout_secs": 5, + }))) + .await; + + let admission = content + .send_raw(&json!({ + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } + })) + .await + .unwrap(); - assert_eq!(cid, TEST_CID); + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], false); + assert_eq!(admission["data"]["admission"]["status"], "rejected"); + assert!(admission["data"]["admission"]["reason"] + .as_str() + .unwrap() + .contains("ledger exhausted")); assert_eq!( - ipfs.added_files.lock().await.as_slice(), - ["provenance.json".to_string()] + admission["data"]["admission"]["federated_quota_ledger_exchange"]["status"], + "rejected" + ); + assert_eq!( + admission["data"]["admission"]["federated_quota_ledger_exchange"]["signed_receipt"] + ["verified"], + true + ); + assert_eq!( + admission["data"]["admission"]["quota"]["federated_quota_ledger_policy"]["status"], + "federated_quota_ledger_rejected" ); + assert_eq!( + admission["data"]["receipt"]["payload"], + admission["data"]["admission"] + ); + + let request = handle.join().unwrap(); + assert!(request.contains(CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_SCHEMA)); } #[tokio::test] - async fn content_fetch_wraps_ipfs_cat() { - let (_data_dir, registry, _ipfs, _content) = registry_with_content_and_ipfs().await; - let bytes = fetch_bytes_via_provider(®istry, TEST_CID, Some("capsule.json")) + async fn content_admission_rejects_configured_federated_quota_ledger_quorum_failure() { + let accepted_receipt = signed_federated_quota_ledger_exchange_receipt(true, None); + let (accepted_url, accepted_handle) = + spawn_federated_quota_ledger_exchange_endpoint(json!({ + "accepted": true, + "status": "accepted", + "receipt": accepted_receipt, + })); + let rejected_receipt = + signed_federated_quota_ledger_exchange_receipt(false, Some("ledger exhausted")); + let (rejected_url, rejected_handle) = + spawn_federated_quota_ledger_exchange_endpoint(json!({ + "accepted": false, + "status": "rejected", + "reason": "ledger exhausted", + "receipt": rejected_receipt, + })); + let (_data_dir, _registry, _ipfs, content) = + registry_with_content_and_ipfs_with_quota_ledger_exchange_config(Some(json!({ + "quorum": 2, + "endpoints": [ + {"id": "ledger-a", "url": accepted_url, "timeout_secs": 5}, + {"id": "ledger-b", "url": rejected_url, "timeout_secs": 5} + ] + }))) + .await; + + let admission = content + .send_raw(&json!({ + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } + })) .await .unwrap(); - assert_eq!(bytes, b"hello content"); + let exchange = &admission["data"]["admission"]["federated_quota_ledger_exchange"]; + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], false); + assert_eq!(admission["data"]["admission"]["status"], "rejected"); + assert!(admission["data"]["admission"]["reason"] + .as_str() + .unwrap() + .contains("ledger exhausted")); + assert_eq!(exchange["accepted"], false); + assert_eq!(exchange["quorum"]["required"], 2); + assert_eq!(exchange["quorum"]["accepted"], 1); + assert_eq!(exchange["quorum"]["rejected"], 1); + assert_eq!(exchange["signed_receipt"]["verified"], true); + assert_eq!( + admission["data"]["admission"]["quota"]["federated_quota_ledger_policy"]["status"], + "federated_quota_ledger_rejected" + ); + + let accepted_request = accepted_handle.join().unwrap(); + let rejected_request = rejected_handle.join().unwrap(); + assert!(accepted_request.contains(CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_SCHEMA)); + assert!(rejected_request.contains(CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_REQUEST_SCHEMA)); } #[tokio::test] - async fn content_prepare_data_capsule_materializes_verified_manifest_files() { - let (_data_dir, registry, ipfs, _content) = registry_with_content_and_ipfs().await; - let capsule_json = serde_json::json!({ - "schema": elastos_common::SCHEMA_V1, - "version": "0.1.0", - "name": "shared-doc", - "role": "content", - "type": "data", - "entrypoint": "index.html" - }); - let capsule_bytes = serde_json::to_vec(&capsule_json).unwrap(); - let index_bytes = b"viewer".to_vec(); - let markdown_bytes = b"# Hello\n".to_vec(); - let object_manifest = ContentObjectManifest { - schema: OBJECT_MANIFEST_SCHEMA.to_string(), - kind: "share".to_string(), - content_digest: "sha256:test".to_string(), - files: vec![ - object_file("capsule.json", &capsule_bytes), - object_file("docs/readme.md", &markdown_bytes), - object_file("index.html", &index_bytes), - ], - links: Vec::new(), - object_did: None, - publisher_did: None, - }; - let object_manifest_bytes = serde_json::to_vec(&object_manifest).unwrap(); + async fn content_admission_records_configured_federated_abuse_control_acceptance() { + let receipt = signed_federated_abuse_control_exchange_receipt(true, None); + let (url, handle) = spawn_federated_abuse_control_exchange_endpoint(json!({ + "accepted": true, + "status": "accepted", + "exchange_id": "abuse-control-exchange:test", + "receipt_id": "abuse-control-receipt:accepted", + "receipt": receipt, + })); + let (_data_dir, _registry, _ipfs, content) = + registry_with_content_and_ipfs_with_abuse_control_exchange_config(Some(json!({ + "url": url, + "authorization": "Bearer abuse-test", + "timeout_secs": 5, + }))) + .await; + + let admission = content + .send_raw(&json!({ + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } + })) + .await + .unwrap(); - { - let mut cat_files = ipfs.cat_files.lock().await; - cat_files.insert("capsule.json".to_string(), capsule_bytes); - cat_files.insert(OBJECT_MANIFEST_PATH.to_string(), object_manifest_bytes); - cat_files.insert("index.html".to_string(), index_bytes.clone()); - cat_files.insert("docs/readme.md".to_string(), markdown_bytes.clone()); - } + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], true); + assert_eq!( + admission["data"]["admission"]["federated_abuse_control_exchange"]["schema"], + CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_RECEIPT_SCHEMA + ); + assert_eq!( + admission["data"]["admission"]["federated_abuse_control_exchange"]["signed_receipt"] + ["verified"], + true + ); + assert_eq!( + admission["data"]["admission"]["federated_abuse_control_exchange"]["signed_receipt"] + ["abuse_ledger_id"], + "abuse-ledger:test" + ); + let signer_did = admission["data"]["receipt"]["signer_did"] + .as_str() + .unwrap() + .to_string(); + let signed_receipt = serde_json::to_vec(&admission["data"]["receipt"]).unwrap(); + crate::crypto::verify_signed_json_envelope_against_dids( + &signed_receipt, + CONTENT_ADMISSION_DOMAIN, + &[signer_did], + ) + .unwrap(); + assert_eq!( + admission["data"]["receipt"]["payload"], + admission["data"]["admission"] + ); - let capsule_dir = prepare_capsule_from_content_provider(®istry, TEST_CID) + let status = content + .send_raw(&json!({ + "op": "status", + })) .await .unwrap(); assert_eq!( - std::fs::read(capsule_dir.join("index.html")).unwrap(), - index_bytes + status["data"]["network_abuse_policy"]["status"], + "configured_federated_abuse_control_exchange" ); assert_eq!( - std::fs::read(capsule_dir.join("docs/readme.md")).unwrap(), - markdown_bytes + status["data"]["network_abuse_policy"]["network_federation"]["configured"], + true ); - assert!(capsule_dir.join(OBJECT_MANIFEST_PATH).is_file()); - std::fs::remove_dir_all(capsule_dir).unwrap(); + assert_eq!( + status["data"]["network_abuse_policy"]["network_federation"]["exchange_client"] + ["authorization_configured"], + true + ); + assert!(!status.to_string().contains("abuse-test")); + + let request = handle.join().unwrap(); + assert!(request.starts_with("POST /abuse/exchange HTTP/1.1")); + assert!(request + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer abuse-test"))); + assert!(request.contains(CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_REQUEST_SCHEMA)); + let body = request.split("\r\n\r\n").nth(1).unwrap_or(""); + assert!(!body.contains("abuse-test")); + let signed_request: Value = serde_json::from_str(body).unwrap(); + assert_eq!( + signed_request["payload"]["schema"], + CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_REQUEST_SCHEMA + ); + assert_eq!( + signed_request["payload"]["authority"]["credential_exposed"], + false + ); + assert_eq!( + signed_request["payload"]["authority"]["raw_peer_authority"], + false + ); + let request_signer = signed_request["signer_did"].as_str().unwrap().to_string(); + let signed_request_bytes = serde_json::to_vec(&signed_request).unwrap(); + crate::crypto::verify_signed_json_envelope_against_dids( + &signed_request_bytes, + CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_REQUEST_DOMAIN, + &[request_signer], + ) + .unwrap(); } #[tokio::test] - async fn content_prepare_data_capsule_rejects_object_hash_mismatch() { - let (_data_dir, registry, ipfs, _content) = registry_with_content_and_ipfs().await; - let capsule_json = serde_json::json!({ - "schema": elastos_common::SCHEMA_V1, - "version": "0.1.0", - "name": "shared-doc", - "role": "content", - "type": "data", - "entrypoint": "index.html" - }); - let capsule_bytes = serde_json::to_vec(&capsule_json).unwrap(); - let original_index = b"viewer".to_vec(); - let tampered_index = b"viewed".to_vec(); - let object_manifest = ContentObjectManifest { - schema: OBJECT_MANIFEST_SCHEMA.to_string(), - kind: "share".to_string(), - content_digest: "sha256:test".to_string(), - files: vec![ - object_file("capsule.json", &capsule_bytes), - object_file("index.html", &original_index), - ], - links: Vec::new(), - object_did: None, - publisher_did: None, - }; - let object_manifest_bytes = serde_json::to_vec(&object_manifest).unwrap(); - - { - let mut cat_files = ipfs.cat_files.lock().await; - cat_files.insert("capsule.json".to_string(), capsule_bytes); - cat_files.insert(OBJECT_MANIFEST_PATH.to_string(), object_manifest_bytes); - cat_files.insert("index.html".to_string(), tampered_index); - } + async fn content_admission_accepts_configured_federated_abuse_control_quorum() { + let receipt_a = signed_federated_abuse_control_exchange_receipt(true, None); + let (url_a, handle_a) = spawn_federated_abuse_control_exchange_endpoint(json!({ + "accepted": true, + "status": "accepted", + "exchange_id": "abuse-control-exchange:a", + "receipt_id": "abuse-control-receipt:a", + "receipt": receipt_a, + })); + let receipt_b = signed_federated_abuse_control_exchange_receipt(true, None); + let (url_b, handle_b) = spawn_federated_abuse_control_exchange_endpoint(json!({ + "accepted": true, + "status": "accepted", + "exchange_id": "abuse-control-exchange:b", + "receipt_id": "abuse-control-receipt:b", + "receipt": receipt_b, + })); + let (_data_dir, _registry, _ipfs, content) = + registry_with_content_and_ipfs_with_abuse_control_exchange_config(Some(json!({ + "quorum": 2, + "endpoints": [ + { + "id": "abuse-a", + "url": url_a, + "authorization": "Bearer abuse-secret-a", + "timeout_secs": 5 + }, + { + "id": "abuse-b", + "url": url_b, + "authorization": "Bearer abuse-secret-b", + "timeout_secs": 5 + } + ] + }))) + .await; - let err = prepare_capsule_from_content_provider(®istry, TEST_CID) + let admission = content + .send_raw(&json!({ + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } + })) .await - .unwrap_err(); - assert!(err - .to_string() - .contains("content object file hash mismatch")); - } - - #[tokio::test] - async fn content_prepare_capsule_rejects_release_object_as_not_launchable() { - let (_data_dir, registry, ipfs, _content) = registry_with_content_and_ipfs().await; - let release_bytes = br#"{"payload":{},"signature":"00","signer_did":"did:key:z6Mk"}"#; - let object_manifest = ContentObjectManifest { - schema: OBJECT_MANIFEST_SCHEMA.to_string(), - kind: "release".to_string(), - content_digest: "sha256:test".to_string(), - files: vec![object_file("release.json", release_bytes)], - links: Vec::new(), - object_did: Some("elastos://release/stable/0.2.0".to_string()), - publisher_did: Some("did:key:z6Mkpublisher".to_string()), - }; - let object_manifest_bytes = serde_json::to_vec(&object_manifest).unwrap(); + .unwrap(); - { - let mut cat_files = ipfs.cat_files.lock().await; - cat_files.insert(OBJECT_MANIFEST_PATH.to_string(), object_manifest_bytes); - } - ipfs.missing_paths - .lock() - .await - .push("capsule.json".to_string()); + let exchange = &admission["data"]["admission"]["federated_abuse_control_exchange"]; + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], true); + assert_eq!(exchange["accepted"], true); + assert_eq!(exchange["quorum"]["required"], 2); + assert_eq!(exchange["quorum"]["endpoint_count"], 2); + assert_eq!(exchange["quorum"]["accepted"], 2); + assert_eq!(exchange["signed_receipt"]["verified"], true); + assert_eq!(exchange["exchange"]["multi_endpoint"], true); + assert_eq!(exchange["exchange"]["endpoint_count"], 2); - let err = prepare_capsule_from_content_provider(®istry, TEST_CID) + let status = content + .send_raw(&json!({ + "op": "status", + })) .await - .unwrap_err(); - assert!(err.to_string().contains("kind 'release'")); - assert!(err.to_string().contains("not a launchable capsule")); + .unwrap(); + assert_eq!( + status["data"]["network_abuse_policy"]["network_federation"]["exchange_client"] + ["endpoint_count"], + 2 + ); + assert_eq!( + status["data"]["network_abuse_policy"]["network_federation"]["exchange_client"] + ["quorum_required"], + 2 + ); + assert!(!status.to_string().contains("abuse-secret-a")); + assert!(!status.to_string().contains("abuse-secret-b")); + + let request_a = handle_a.join().unwrap(); + let request_b = handle_b.join().unwrap(); + assert!(request_a + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer abuse-secret-a"))); + assert!(request_b + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer abuse-secret-b"))); + assert!(!request_a + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("abuse-secret-a")); + assert!(!request_b + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("abuse-secret-b")); } #[tokio::test] - async fn content_fetch_rejects_invalid_cid_and_path() { - let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; - let invalid_cid = content + async fn content_admission_rejects_when_configured_federated_abuse_control_rejects() { + let receipt = signed_federated_abuse_control_exchange_receipt( + false, + Some("abuse threshold exceeded"), + ); + let (url, handle) = spawn_federated_abuse_control_exchange_endpoint(json!({ + "accepted": false, + "status": "rejected", + "reason": "abuse threshold exceeded", + "receipt": receipt, + })); + let (_data_dir, _registry, _ipfs, content) = + registry_with_content_and_ipfs_with_abuse_control_exchange_config(Some(json!({ + "url": url, + "authorization": "Bearer abuse-test", + "timeout_secs": 5, + }))) + .await; + + let admission = content .send_raw(&json!({ - "op": "fetch", - "cid": "not-a-cid", + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } })) .await .unwrap(); - assert_eq!(invalid_cid["status"], "error"); - assert_eq!(invalid_cid["code"], "invalid_cid"); - let invalid_path = content + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], false); + assert_eq!(admission["data"]["admission"]["status"], "rejected"); + assert!(admission["data"]["admission"]["reason"] + .as_str() + .unwrap() + .contains("abuse threshold exceeded")); + assert_eq!( + admission["data"]["admission"]["federated_abuse_control_exchange"]["status"], + "rejected" + ); + assert_eq!( + admission["data"]["admission"]["federated_abuse_control_exchange"]["signed_receipt"] + ["verified"], + true + ); + assert_eq!( + admission["data"]["receipt"]["payload"], + admission["data"]["admission"] + ); + + let request = handle.join().unwrap(); + assert!(request.contains(CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_REQUEST_SCHEMA)); + } + + #[tokio::test] + async fn content_admission_rejects_configured_federated_abuse_control_quorum_failure() { + let accepted_receipt = signed_federated_abuse_control_exchange_receipt(true, None); + let (accepted_url, accepted_handle) = + spawn_federated_abuse_control_exchange_endpoint(json!({ + "accepted": true, + "status": "accepted", + "receipt": accepted_receipt, + })); + let rejected_receipt = signed_federated_abuse_control_exchange_receipt( + false, + Some("abuse threshold exceeded"), + ); + let (rejected_url, rejected_handle) = + spawn_federated_abuse_control_exchange_endpoint(json!({ + "accepted": false, + "status": "rejected", + "reason": "abuse threshold exceeded", + "receipt": rejected_receipt, + })); + let (_data_dir, _registry, _ipfs, content) = + registry_with_content_and_ipfs_with_abuse_control_exchange_config(Some(json!({ + "quorum": 2, + "endpoints": [ + {"id": "abuse-a", "url": accepted_url, "timeout_secs": 5}, + {"id": "abuse-b", "url": rejected_url, "timeout_secs": 5} + ] + }))) + .await; + + let admission = content .send_raw(&json!({ - "op": "fetch", - "cid": TEST_CID, - "path": "../secret", + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } })) .await .unwrap(); - assert_eq!(invalid_path["status"], "error"); - assert_eq!(invalid_path["code"], "invalid_path"); + + let exchange = &admission["data"]["admission"]["federated_abuse_control_exchange"]; + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], false); + assert_eq!(admission["data"]["admission"]["status"], "rejected"); + assert!(admission["data"]["admission"]["reason"] + .as_str() + .unwrap() + .contains("abuse threshold exceeded")); + assert_eq!(exchange["accepted"], false); + assert_eq!(exchange["quorum"]["required"], 2); + assert_eq!(exchange["quorum"]["accepted"], 1); + assert_eq!(exchange["quorum"]["rejected"], 1); + assert_eq!(exchange["signed_receipt"]["verified"], true); + + let accepted_request = accepted_handle.join().unwrap(); + let rejected_request = rejected_handle.join().unwrap(); + assert!(accepted_request.contains(CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_REQUEST_SCHEMA)); + assert!(rejected_request.contains(CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_REQUEST_SCHEMA)); } #[tokio::test] - async fn content_status_rejects_invalid_cid() { - let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; - let invalid_cid = content + async fn content_admission_records_configured_storage_market_acceptance() { + let (url, handle) = spawn_storage_market_admission_endpoint(json!({ + "accepted": true, + "status": "accepted", + "market_id": "market:test", + "offer_id": "offer:123", + "receipt": { + "schema": "elastos.test.storage-market.offer/v1", + "offer_id": "offer:123" + } + })); + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs_with_configs( + None, + Some(json!({ + "url": url, + "authorization": "Bearer market-test", + "timeout_secs": 5, + })), + None, + None, + ) + .await; + + let admission = content .send_raw(&json!({ - "op": "status", - "cid": "not-a-cid", + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } })) .await .unwrap(); - assert_eq!(invalid_cid["status"], "error"); - assert_eq!(invalid_cid["code"], "invalid_cid"); - } + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], true); + assert_eq!( + admission["data"]["admission"]["storage_market_admission"]["schema"], + CONTENT_STORAGE_MARKET_ADMISSION_DECISION_SCHEMA + ); + assert_eq!( + admission["data"]["admission"]["storage_market_admission"]["accepted"], + true + ); + assert_eq!( + admission["data"]["admission"]["storage_market_admission"]["offer_id"], + "offer:123" + ); + assert_eq!( + admission["data"]["admission"]["storage_market_admission"]["client"] + ["credential_exposed"], + false + ); + let signer_did = admission["data"]["receipt"]["signer_did"] + .as_str() + .unwrap() + .to_string(); + let signed_receipt = serde_json::to_vec(&admission["data"]["receipt"]).unwrap(); + crate::crypto::verify_signed_json_envelope_against_dids( + &signed_receipt, + CONTENT_ADMISSION_DOMAIN, + &[signer_did], + ) + .unwrap(); + assert_eq!( + admission["data"]["receipt"]["payload"], + admission["data"]["admission"] + ); - fn object_file(path: &str, bytes: &[u8]) -> ContentObjectFile { - ContentObjectFile { - path: path.to_string(), - sha256: format!("{:x}", sha2::Sha256::digest(bytes)), - size: bytes.len() as u64, - } + let status = content + .send_raw(&json!({ + "op": "status", + })) + .await + .unwrap(); + assert_eq!( + status["data"]["storage_market_admission_policy"]["production_market"]["configured"], + true + ); + assert_eq!( + status["data"]["storage_market_admission_policy"]["external_admission_client"] + ["authorization_configured"], + true + ); + assert!(!status.to_string().contains("market-test")); + + let request = handle.join().unwrap(); + assert!(request.contains(CONTENT_STORAGE_MARKET_ADMISSION_REQUEST_SCHEMA)); + assert!(request + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer market-test"))); + assert!(!request + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("market-test")); } #[tokio::test] - async fn content_status_reads_latest_availability_receipt() { - let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs().await; - content - .send_raw(&json!({ - "op": "publish", - "kind": "directory", - "files": [{"path": "index.md", "data": "IyBUZXN0Cg=="}], - "pin": true, - "object_did": "did:key:z6Mkobject", - "publisher_did": "did:key:z6Mkpublisher", + async fn content_storage_market_admission_accepts_endpoint_quorum() { + let (url_a, handle_a) = spawn_storage_market_admission_endpoint(json!({ + "accepted": true, + "status": "accepted", + "market_id": "market:a", + "offer_id": "offer:a", + "receipt": { + "schema": "elastos.test.storage-market.offer/v1", + "offer_id": "offer:a" + } + })); + let (url_b, handle_b) = spawn_storage_market_admission_endpoint(json!({ + "accepted": true, + "status": "accepted", + "market_id": "market:b", + "offer_id": "offer:b", + "receipt": { + "schema": "elastos.test.storage-market.offer/v1", + "offer_id": "offer:b" + } + })); + let client = ContentStorageMarketAdmissionClient::from_config(json!({ + "quorum": 2, + "endpoints": [ + { + "id": "market-a", + "url": url_a, + "authorization": "Bearer market-secret-a", + "timeout_secs": 5 + }, + { + "id": "market-b", + "url": url_b, + "authorization": "Bearer market-secret-b", + "timeout_secs": 5 + } + ] + })) + .unwrap(); + + let decision = client + .decide(&json!({ + "schema": CONTENT_STORAGE_MARKET_ADMISSION_REQUEST_SCHEMA, + "cid": TEST_CID, + "estimated_content_bytes": 22, })) .await .unwrap(); - content - .send_raw(&json!({ - "op": "unpublish", + + assert_eq!( + decision["schema"], + CONTENT_STORAGE_MARKET_ADMISSION_DECISION_SCHEMA + ); + assert_eq!(decision["accepted"], true); + assert_eq!(decision["status"], "accepted"); + assert_eq!(decision["offer_id"], "offer:a"); + assert_eq!(decision["quorum"]["required"], 2); + assert_eq!(decision["quorum"]["endpoint_count"], 2); + assert_eq!(decision["quorum"]["accepted"], 2); + assert_eq!(decision["client"]["multi_endpoint"], true); + assert_eq!(decision["client"]["endpoint_count"], 2); + assert!(!decision.to_string().contains("market-secret-a")); + assert!(!decision.to_string().contains("market-secret-b")); + + let request_a = handle_a.join().unwrap(); + let request_b = handle_b.join().unwrap(); + assert!(request_a + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer market-secret-a"))); + assert!(request_b + .lines() + .any(|line| line.eq_ignore_ascii_case("authorization: Bearer market-secret-b"))); + assert!(request_a.contains(CONTENT_STORAGE_MARKET_ADMISSION_REQUEST_SCHEMA)); + assert!(request_b.contains(CONTENT_STORAGE_MARKET_ADMISSION_REQUEST_SCHEMA)); + assert!(!request_a + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("market-secret-a")); + assert!(!request_b + .split("\r\n\r\n") + .nth(1) + .unwrap_or("") + .contains("market-secret-b")); + } + + #[tokio::test] + async fn content_storage_market_admission_rejects_endpoint_quorum_failure() { + let (accepted_url, accepted_handle) = spawn_storage_market_admission_endpoint(json!({ + "accepted": true, + "status": "accepted", + "market_id": "market:a", + "offer_id": "offer:a", + })); + let (rejected_url, rejected_handle) = spawn_storage_market_admission_endpoint(json!({ + "accepted": false, + "status": "rejected", + "reason": "market capacity exhausted", + })); + let client = ContentStorageMarketAdmissionClient::from_config(json!({ + "quorum": 2, + "endpoints": [ + {"id": "market-a", "url": accepted_url, "timeout_secs": 5}, + {"id": "market-b", "url": rejected_url, "timeout_secs": 5} + ] + })) + .unwrap(); + + let decision = client + .decide(&json!({ + "schema": CONTENT_STORAGE_MARKET_ADMISSION_REQUEST_SCHEMA, "cid": TEST_CID, + "estimated_content_bytes": 22, })) .await .unwrap(); - let status = content + assert_eq!(decision["accepted"], false); + assert_eq!(decision["status"], "rejected"); + assert_eq!(decision["quorum"]["required"], 2); + assert_eq!(decision["quorum"]["accepted"], 1); + assert_eq!(decision["quorum"]["rejected"], 1); + assert!(decision["reason"] + .as_str() + .unwrap() + .contains("market capacity exhausted")); + + let accepted_request = accepted_handle.join().unwrap(); + let rejected_request = rejected_handle.join().unwrap(); + assert!(accepted_request.contains(CONTENT_STORAGE_MARKET_ADMISSION_REQUEST_SCHEMA)); + assert!(rejected_request.contains(CONTENT_STORAGE_MARKET_ADMISSION_REQUEST_SCHEMA)); + } + + #[tokio::test] + async fn content_admission_rejects_when_configured_storage_market_rejects() { + let (url, handle) = spawn_storage_market_admission_endpoint(json!({ + "accepted": false, + "status": "rejected", + "reason": "capacity exhausted" + })); + let (_data_dir, _registry, _ipfs, content) = registry_with_content_and_ipfs_with_configs( + None, + Some(json!({ + "url": url, + "authorization": "Bearer market-test", + "timeout_secs": 5, + })), + None, + None, + ) + .await; + + let admission = content .send_raw(&json!({ - "op": "status", - "cid": TEST_CID, + "op": "admission", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "publisher_did": "did:key:z6Mkpublisher", + "estimated_content_bytes": 3, + "availability_requirements": { + "max_storage_bytes_per_principal": 10 + } })) .await .unwrap(); - assert_eq!(status["status"], "ok"); - assert_eq!(status["data"]["cid"], TEST_CID); - assert_eq!(status["data"]["availability"]["status"], "local_unpinned"); + assert_eq!(admission["status"], "ok"); + assert_eq!(admission["data"]["admission"]["accepted"], false); + assert_eq!(admission["data"]["admission"]["status"], "rejected"); + assert!(admission["data"]["admission"]["reason"] + .as_str() + .unwrap() + .contains("capacity exhausted")); assert_eq!( - status["data"]["receipt"]["payload"]["schema"], - AVAILABILITY_RECEIPT_SCHEMA + admission["data"]["admission"]["storage_market_admission"]["status"], + "rejected" + ); + assert_eq!( + admission["data"]["receipt"]["payload"], + admission["data"]["admission"] ); + + let request = handle.join().unwrap(); + assert!(request.contains(CONTENT_STORAGE_MARKET_ADMISSION_REQUEST_SCHEMA)); } } diff --git a/elastos/crates/elastos-server/src/content_cmd.rs b/elastos/crates/elastos-server/src/content_cmd.rs index 590a2e72..9d3243bf 100644 --- a/elastos/crates/elastos-server/src/content_cmd.rs +++ b/elastos/crates/elastos-server/src/content_cmd.rs @@ -2,6 +2,10 @@ use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use clap::Subcommand; +use elastos_runtime::provider::{ + ProviderInvocation, ProviderInvocationTransport, ProviderTransfer, +}; +use serde_json::{json, Value}; #[derive(Subcommand)] pub enum ContentCommand { @@ -31,6 +35,38 @@ pub enum ContentCommand { #[arg(long = "link")] links: Vec, }, + + /// Run the Runtime-provider-only content repair worker + #[command(name = "repair-worker")] + RepairWorker { + /// Ignore next-check timers and retry eligible tasks immediately + #[arg(long)] + force: bool, + + /// Also health-check currently healthy tasks + #[arg(long)] + include_healthy_check: bool, + + /// Maximum tasks to examine in one run + #[arg(long)] + limit: Option, + + /// Maximum retry attempts per CID before this run skips it + #[arg(long)] + max_attempts: Option, + + /// Maximum failed repairs allowed before this run throttles remaining tasks + #[arg(long)] + failure_budget: Option, + }, + + /// Print content availability, storage-accounting, and repair status + #[command(name = "status")] + Status { + /// Optional CID; omit for the provider-wide availability dashboard + #[arg(long)] + cid: Option, + }, } pub async fn run(cmd: ContentCommand) -> anyhow::Result<()> { @@ -82,10 +118,109 @@ pub async fn run(cmd: ContentCommand) -> anyhow::Result<()> { }; println!("{cid}"); } + ContentCommand::RepairWorker { + force, + include_healthy_check, + limit, + max_attempts, + failure_budget, + } => { + let registry = crate::get_content_registry().await?; + let response = run_repair_worker_via_provider( + ®istry, + repair_worker_request( + force, + include_healthy_check, + limit, + max_attempts, + failure_budget, + ), + ) + .await?; + println!("{}", serde_json::to_string_pretty(&response)?); + } + ContentCommand::Status { cid } => { + let registry = crate::get_content_registry().await?; + let response = + run_status_via_provider(®istry, status_request(cid.as_deref())).await?; + println!("{}", serde_json::to_string_pretty(&response)?); + } } Ok(()) } +async fn run_status_via_provider( + registry: &elastos_runtime::provider::ProviderRegistry, + request: Value, +) -> anyhow::Result { + registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "content".to_string(), + op: "status".to_string(), + request, + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .map_err(|err| anyhow::anyhow!("content status failed: {err}")) +} + +async fn run_repair_worker_via_provider( + registry: &elastos_runtime::provider::ProviderRegistry, + request: Value, +) -> anyhow::Result { + registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "content".to_string(), + op: "repair_worker".to_string(), + request, + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .map_err(|err| anyhow::anyhow!("content repair-worker failed: {err}")) +} + +fn repair_worker_request( + force: bool, + include_healthy_check: bool, + limit: Option, + max_attempts: Option, + failure_budget: Option, +) -> Value { + let mut request = json!({ + "op": "repair_worker", + "force": force, + "include_healthy_check": include_healthy_check, + }); + if let Some(limit) = limit { + request["limit"] = Value::from(limit as u64); + } + if let Some(max_attempts) = max_attempts { + request["max_attempts"] = Value::from(u64::from(max_attempts)); + } + if let Some(failure_budget) = failure_budget { + request["failure_budget"] = Value::from(u64::from(failure_budget)); + } + request +} + +fn status_request(cid: Option<&str>) -> Value { + let mut request = json!({ + "op": "status", + }); + if let Some(cid) = cid.filter(|value| !value.trim().is_empty()) { + request["cid"] = Value::String(cid.to_string()); + } + request +} + async fn publish_object_dir( registry: &elastos_runtime::provider::ProviderRegistry, path: &Path, @@ -207,4 +342,27 @@ mod tests { assert!(validate_entry_name("../release.json").is_err()); assert!(validate_entry_name("nested/release.json").is_err()); } + + #[test] + fn content_command_builds_repair_worker_request() { + let request = repair_worker_request(true, true, Some(5), Some(2), Some(1)); + + assert_eq!(request["op"], "repair_worker"); + assert_eq!(request["force"], true); + assert_eq!(request["include_healthy_check"], true); + assert_eq!(request["limit"], 5); + assert_eq!(request["max_attempts"], 2); + assert_eq!(request["failure_budget"], 1); + } + + #[test] + fn content_command_builds_status_request() { + let dashboard = status_request(None); + assert_eq!(dashboard["op"], "status"); + assert!(dashboard.get("cid").is_none()); + + let object = status_request(Some(TEST_CID)); + assert_eq!(object["op"], "status"); + assert_eq!(object["cid"], TEST_CID); + } } diff --git a/elastos/crates/elastos-server/src/home_cmd.rs b/elastos/crates/elastos-server/src/home_cmd.rs index 9b6ada73..dfc29964 100644 --- a/elastos/crates/elastos-server/src/home_cmd.rs +++ b/elastos/crates/elastos-server/src/home_cmd.rs @@ -200,6 +200,7 @@ const PROVIDER_CAPSULE_NAMES: &[&str] = &[ "did-provider", "chain-provider", "wallet-provider", + "object-provider", "drm-provider", "rights-provider", "key-provider", @@ -3276,6 +3277,7 @@ mod tests { assert!(PROVIDER_CAPSULE_NAMES.contains(&"did-provider")); assert!(PROVIDER_CAPSULE_NAMES.contains(&"chain-provider")); assert!(PROVIDER_CAPSULE_NAMES.contains(&"wallet-provider")); + assert!(PROVIDER_CAPSULE_NAMES.contains(&"object-provider")); assert!(PROVIDER_CAPSULE_NAMES.contains(&"drm-provider")); assert!(PROVIDER_CAPSULE_NAMES.contains(&"rights-provider")); assert!(PROVIDER_CAPSULE_NAMES.contains(&"key-provider")); diff --git a/elastos/crates/elastos-server/src/lib.rs b/elastos/crates/elastos-server/src/lib.rs index e5f53ee7..b1fa0cdf 100644 --- a/elastos/crates/elastos-server/src/lib.rs +++ b/elastos/crates/elastos-server/src/lib.rs @@ -19,6 +19,7 @@ pub mod gateway_cmd; pub mod host_lock; pub mod init; pub mod ipfs; +pub mod library; pub mod local_http; pub mod notifications; pub mod operator_control; diff --git a/elastos/crates/elastos-server/src/library.rs b/elastos/crates/elastos-server/src/library.rs new file mode 100644 index 00000000..cc2e5503 --- /dev/null +++ b/elastos/crates/elastos-server/src/library.rs @@ -0,0 +1,6870 @@ +use std::collections::BTreeSet; +use std::fs; +use std::io::{Cursor, Read as _, Write as _}; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, OnceLock, Weak}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, bail, Context as _}; +use base64::Engine as _; +use elastos_common::localhost::rooted_localhost_fs_path; +use elastos_common::protected_content::{ + DecryptSessionRequestV1, KeyEnvelopeAlgorithmsV1, KeyEnvelopeV1, KeyReleaseRequestV1, + ReleaseReceiptV1, RightsDecisionReceiptV1, SealedObjectV1, ViewerRequirementV1, + DECRYPT_SESSION_REQUEST_SCHEMA, DECRYPT_SESSION_SCHEMA, DEFAULT_PROTECTED_CONTENT_CIPHER, + DEFAULT_PROTECTED_CONTENT_KEMS, DEFAULT_PROTECTED_CONTENT_SHARE_SCHEME, + DEFAULT_PROTECTED_CONTENT_SIGNATURES, KEY_RELEASE_REQUEST_SCHEMA, RELEASE_RECEIPT_SCHEMA, + RIGHTS_DECISION_RECEIPT_SCHEMA, SEALED_OBJECT_SCHEMA, +}; +use elastos_runtime::provider::{ + Provider, ProviderError, ProviderInvocation, ProviderInvocationTransport, ProviderRegistry, + ProviderTransfer, ResourceRequest, ResourceResponse, +}; +use flate2::{read::GzDecoder, write::GzEncoder, Compression}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest as _, Sha256}; +use zip::{ZipArchive, ZipWriter}; + +const LIBRARY_OBJECT_SCHEMA: &str = "elastos.library.object/v1"; +const LIBRARY_ROOT_SCHEMA: &str = "elastos.library.root/v1"; +const LIBRARY_EVENT_SCHEMA: &str = "elastos.library.event/v1"; +const LIBRARY_ARCHIVE_ENTRIES_SCHEMA: &str = "elastos.library.archive-entries/v1"; +const LIBRARY_ARCHIVE_EXTRACT_ENTRIES_SCHEMA: &str = "elastos.library.archive-extract-entries/v1"; +const LIBRARY_ARCHIVE_PREVIEW_ENTRY_SCHEMA: &str = "elastos.library.archive-preview-entry/v1"; +const LIBRARY_VISIBILITY_SCHEMA: &str = "elastos.library.visibility/v1"; +const LIBRARY_TRASH_RECORD_SCHEMA: &str = "elastos.library.trash-record/v1"; +const MAX_LIBRARY_EVENTS: usize = 256; +const MAX_ARCHIVE_LIST_ENTRIES: usize = 512; +const MAX_ARCHIVE_PREVIEW_BYTES: usize = 64 * 1024; + +static LIBRARY_EVENT_NOTIFY: OnceLock = OnceLock::new(); + +pub(crate) fn library_event_notifier() -> &'static tokio::sync::Notify { + LIBRARY_EVENT_NOTIFY.get_or_init(tokio::sync::Notify::new) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct LibraryObject { + schema: &'static str, + uri: String, + name: String, + kind: &'static str, + mime: String, + size: u64, + created_at: u64, + modified_at: u64, + revision: String, + #[serde(skip_serializing_if = "Option::is_none")] + viewer: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + viewers: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + thumbnail_uri: Option, + availability: String, + #[serde(skip_serializing_if = "Option::is_none")] + blocked_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + content_cid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + published_cid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + metadata: Option, + published: bool, + shared: bool, + capabilities: Vec<&'static str>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct LibraryViewerOption { + id: String, + label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + default: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct LibraryRoot { + schema: &'static str, + id: &'static str, + label: &'static str, + uri: String, + kind: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + metadata: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct LibraryPublishRecord { + schema: String, + object_uri: String, + cid: String, + published_at: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + unpublished_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + shared_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + share_policy: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + share_grants: Vec, + #[serde(default = "default_publish_content_security")] + content_security: Value, + receipt: Value, + availability: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct LibraryShareGrant { + schema: String, + grant_id: String, + recipient: String, + uri: String, + cid: String, + policy: String, + #[serde(default = "default_share_key_release")] + key_release: Value, + created_at: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct LibraryTrashRecord { + schema: String, + trash_uri: String, + original_uri: String, + original_name: String, + trashed_at: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct LibraryEvent { + schema: String, + event_id: String, + op: String, + uri: String, + at: u64, + #[serde(default, skip_serializing_if = "Value::is_null")] + details: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] +enum ObjectProviderRequest { + Roots { + principal_id: String, + }, + List { + principal_id: String, + #[serde(default)] + uri: Option, + }, + Stat { + principal_id: String, + uri: String, + }, + Read { + principal_id: String, + uri: String, + }, + Download { + principal_id: String, + uri: String, + }, + ExtractArchive { + principal_id: String, + uri: String, + #[serde(default)] + if_revision: Option, + }, + ArchiveEntries { + principal_id: String, + uri: String, + }, + ArchivePreviewEntry { + principal_id: String, + uri: String, + entry: String, + #[serde(default)] + if_revision: Option, + }, + ArchiveExtractEntries { + principal_id: String, + uri: String, + destination_uri: String, + #[serde(default)] + entries: Vec, + #[serde(default)] + conflict_policy: Option, + #[serde(default)] + if_revision: Option, + #[serde(default)] + cancel: bool, + }, + CompressArchive { + principal_id: String, + #[serde(default)] + uri: Option, + #[serde(default)] + uris: Vec, + #[serde(default)] + if_revision: Option, + }, + Write { + principal_id: String, + uri: String, + data: String, + #[serde(default)] + mime: Option, + #[serde(default)] + if_revision: Option, + }, + Mkdir { + principal_id: String, + parent_uri: String, + name: String, + }, + Rename { + principal_id: String, + uri: String, + name: String, + #[serde(default)] + if_revision: Option, + }, + Move { + principal_id: String, + uri: String, + target_parent_uri: String, + #[serde(default)] + if_revision: Option, + }, + Copy { + principal_id: String, + uri: String, + target_parent_uri: String, + #[serde(default)] + if_revision: Option, + }, + Trash { + principal_id: String, + uri: String, + #[serde(default)] + if_revision: Option, + }, + Restore { + principal_id: String, + uri: String, + #[serde(default)] + target_uri: Option, + #[serde(default)] + if_revision: Option, + }, + DeletePermanently { + principal_id: String, + uri: String, + #[serde(default)] + if_revision: Option, + }, + EmptyTrash { + principal_id: String, + }, + Status { + principal_id: String, + uri: String, + }, + Sync { + principal_id: String, + uri: String, + }, + Events { + principal_id: String, + #[serde(default)] + uri: Option, + #[serde(default)] + since: Option, + #[serde(default)] + limit: Option, + }, + Publish { + principal_id: String, + uri: String, + #[serde(default)] + if_revision: Option, + #[serde(default)] + protected_content_fixture: bool, + }, + Unpublish { + principal_id: String, + uri: String, + #[serde(default)] + if_revision: Option, + }, + Repair { + principal_id: String, + uri: String, + }, + Share { + principal_id: String, + uri: String, + #[serde(default)] + recipients: Vec, + #[serde(default)] + policy: Option, + #[serde(default)] + key_release_policy: Option, + }, + SharedAccess { + principal_id: String, + uri: String, + recipient: String, + #[serde(default)] + recipient_proof: Option, + }, +} + +pub struct ObjectProvider { + data_dir: PathBuf, + registry: Weak, +} + +impl ObjectProvider { + pub fn new(data_dir: PathBuf, registry: Weak) -> Self { + Self { data_dir, registry } + } +} + +#[async_trait::async_trait] +impl Provider for ObjectProvider { + async fn handle(&self, _request: ResourceRequest) -> Result { + Err(ProviderError::Provider( + "object provider does not support URI resource routing; use raw operations".into(), + )) + } + + fn schemes(&self) -> Vec<&'static str> { + vec!["object"] + } + + fn name(&self) -> &'static str { + "object-provider" + } + + async fn send_raw(&self, request: &Value) -> Result { + let request = match serde_json::from_value::(request.clone()) { + Ok(request) => request, + Err(err) => return Ok(provider_error("invalid_request", &err.to_string())), + }; + + let data_dir = self.data_dir.clone(); + let result = match request { + ObjectProviderRequest::Publish { + principal_id, + uri, + if_revision, + protected_content_fixture, + } => { + let Some(registry) = self.registry.upgrade() else { + return Ok(provider_error( + "library_error", + "object provider registry unavailable", + )); + }; + library_publish( + &data_dir, + registry, + &principal_id, + &uri, + if_revision.as_deref(), + protected_content_fixture, + ) + .await + } + ObjectProviderRequest::Unpublish { + principal_id, + uri, + if_revision, + } => { + let Some(registry) = self.registry.upgrade() else { + return Ok(provider_error( + "library_error", + "object provider registry unavailable", + )); + }; + library_unpublish( + &data_dir, + registry, + &principal_id, + &uri, + if_revision.as_deref(), + ) + .await + } + ObjectProviderRequest::Repair { principal_id, uri } => { + let Some(registry) = self.registry.upgrade() else { + return Ok(provider_error( + "library_error", + "object provider registry unavailable", + )); + }; + library_repair(&data_dir, registry, &principal_id, &uri).await + } + request @ (ObjectProviderRequest::Status { .. } + | ObjectProviderRequest::Share { .. } + | ObjectProviderRequest::SharedAccess { .. }) => { + handle_library_request_with_protected_content_status( + data_dir, + request, + self.registry.upgrade(), + ) + .await + } + request => { + tokio::task::spawn_blocking(move || handle_library_request(&data_dir, request)) + .await + .map_err(|err| anyhow!("object provider task failed: {err}")) + .and_then(|result| result) + } + }; + + Ok(match result { + Ok(data) => provider_ok(data), + Err(err) => provider_error("library_error", &err.to_string()), + }) + } +} + +/// Handle one raw object provider request inside an isolated provider process. +/// +/// The standalone provider owns principal-root object storage and Library event +/// state. Content publish/unpublish/repair are coordinated by Runtime for now, +/// because the current stdio provider ABI has no provider-to-provider +/// invocation channel. +pub fn handle_object_provider_raw_request(data_dir: &Path, request: &Value) -> Value { + let request = match serde_json::from_value::(request.clone()) { + Ok(request) => request, + Err(err) => return provider_error("invalid_request", &err.to_string()), + }; + + let result = match request { + ObjectProviderRequest::Publish { .. } + | ObjectProviderRequest::Unpublish { .. } + | ObjectProviderRequest::Repair { .. } => Err(anyhow!( + "library content operation requires Runtime content coordinator" + )), + request => handle_library_request(data_dir, request), + }; + + match result { + Ok(data) => provider_ok(data), + Err(err) => provider_error("library_error", &err.to_string()), + } +} + +pub fn handle_library_upload_bytes( + data_dir: &Path, + principal_id: &str, + uri: &str, + mime: Option<&str>, + if_revision: Option<&str>, + bytes: &[u8], +) -> anyhow::Result { + let object = write_library_file_bytes(data_dir, principal_id, uri, mime, if_revision, bytes)?; + Ok(provider_ok(json!({ + "object": object, + "transport": "raw-body", + }))) +} + +pub(crate) async fn handle_library_upload_bytes_runtime( + data_dir: &Path, + registry: Arc, + principal_id: &str, + uri: &str, + mime: Option<&str>, + if_revision: Option<&str>, + bytes: &[u8], +) -> anyhow::Result { + if is_webspace_uri(uri) { + let uri = clean_webspace_uri(uri)?; + let receipt = webspace_write_bytes(®istry, &uri, bytes).await?; + let object = webspace_stat_object(data_dir, ®istry, &uri).await?; + return Ok(provider_ok(json!({ + "object": object, + "transport": "raw-body", + "provider_receipt": receipt, + }))); + } + handle_library_upload_bytes(data_dir, principal_id, uri, mime, if_revision, bytes) +} + +pub(crate) struct LibraryDownloadBytes { + pub(crate) filename: String, + pub(crate) mime: String, + pub(crate) bytes: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LibraryArchiveFormat { + TarGz, + Zip, +} + +impl LibraryArchiveFormat { + pub(crate) fn parse(value: Option<&str>) -> anyhow::Result { + match value + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("tar.gz") + .to_ascii_lowercase() + .as_str() + { + "tar.gz" | "tgz" | "gzip" | "gz" => Ok(Self::TarGz), + "zip" => Ok(Self::Zip), + other => bail!("unsupported Library archive format: {other}"), + } + } + + fn mime(self) -> &'static str { + match self { + Self::TarGz => "application/gzip", + Self::Zip => "application/zip", + } + } + + fn extension(self) -> &'static str { + match self { + Self::TarGz => "tar.gz", + Self::Zip => "zip", + } + } +} + +pub(crate) fn handle_library_download_bytes_with_format( + data_dir: &Path, + principal_id: &str, + uri: &str, + archive_format: LibraryArchiveFormat, +) -> anyhow::Result { + let (object, filename, bytes) = + library_download_object(data_dir, principal_id, uri, archive_format)?; + Ok(LibraryDownloadBytes { + filename, + mime: object.mime, + bytes, + }) +} + +pub(crate) fn handle_library_download_selection_bytes_with_format( + data_dir: &Path, + principal_id: &str, + uris: &[String], + archive_format: LibraryArchiveFormat, +) -> anyhow::Result { + let (filename, bytes) = + archive_library_selection(data_dir, principal_id, uris, archive_format)?; + Ok(LibraryDownloadBytes { + filename, + mime: archive_format.mime().to_string(), + bytes, + }) +} + +pub(crate) async fn handle_library_download_bytes_runtime( + data_dir: &Path, + registry: Arc, + principal_id: &str, + uri: &str, + archive_format: LibraryArchiveFormat, +) -> anyhow::Result { + if is_webspace_uri(uri) { + return webspace_download_bytes(data_dir, ®istry, uri).await; + } + handle_library_download_bytes_with_format(data_dir, principal_id, uri, archive_format) +} + +pub(crate) async fn handle_library_download_selection_bytes_runtime( + data_dir: &Path, + principal_id: &str, + uris: &[String], + archive_format: LibraryArchiveFormat, +) -> anyhow::Result { + if uris.iter().any(|uri| is_webspace_uri(uri)) { + bail!("Spaces selections cannot be archived from Library yet"); + } + handle_library_download_selection_bytes_with_format( + data_dir, + principal_id, + uris, + archive_format, + ) +} + +/// Handle one Library request with Runtime coordination available. +/// +/// This bridge keeps publish/share/status content effects Runtime-mediated so +/// Library never gains raw content, Carrier, Kubo/IPFS, or backend authority. +pub async fn handle_object_provider_runtime_request( + data_dir: &Path, + registry: Arc, + request: &Value, +) -> Value { + let request = match serde_json::from_value::(request.clone()) { + Ok(request) => request, + Err(err) => return provider_error("invalid_request", &err.to_string()), + }; + + let data_dir = data_dir.to_path_buf(); + if library_request_touches_webspace(&request) { + let result = handle_library_webspace_request(&data_dir, ®istry, request).await; + return match result { + Ok(data) => provider_ok(data), + Err(err) => provider_error("library_error", &err.to_string()), + }; + } + + let result = match request { + ObjectProviderRequest::Publish { + principal_id, + uri, + if_revision, + protected_content_fixture, + } => { + library_publish( + &data_dir, + registry, + &principal_id, + &uri, + if_revision.as_deref(), + protected_content_fixture, + ) + .await + } + ObjectProviderRequest::Unpublish { + principal_id, + uri, + if_revision, + } => { + library_unpublish( + &data_dir, + registry, + &principal_id, + &uri, + if_revision.as_deref(), + ) + .await + } + ObjectProviderRequest::Repair { principal_id, uri } => { + library_repair(&data_dir, registry, &principal_id, &uri).await + } + request @ (ObjectProviderRequest::Status { .. } + | ObjectProviderRequest::Share { .. } + | ObjectProviderRequest::SharedAccess { .. }) => { + handle_library_request_with_protected_content_status(data_dir, request, Some(registry)) + .await + } + request => tokio::task::spawn_blocking(move || handle_library_request(&data_dir, request)) + .await + .map_err(|err| anyhow!("object provider task failed: {err}")) + .and_then(|result| result), + }; + + match result { + Ok(data) => provider_ok(data), + Err(err) => provider_error("library_error", &err.to_string()), + } +} + +async fn handle_library_request_with_protected_content_status( + data_dir: PathBuf, + request: ObjectProviderRequest, + registry: Option>, +) -> anyhow::Result { + let request_is_shared_access = matches!(request, ObjectProviderRequest::SharedAccess { .. }); + let mut data = tokio::task::spawn_blocking(move || handle_library_request(&data_dir, request)) + .await + .map_err(|err| anyhow!("object provider task failed: {err}")) + .and_then(|result| result)?; + if let Some(registry) = registry { + if request_is_shared_access { + attach_protected_content_open_chain(®istry, &mut data).await?; + } + attach_protected_content_provider_status(®istry, &mut data).await; + } + Ok(data) +} + +async fn attach_protected_content_open_chain( + registry: &ProviderRegistry, + data: &mut Value, +) -> anyhow::Result<()> { + let object_cid = data + .get("cid") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow!("protected shared_access missing cid"))? + .to_string(); + let Some(access) = data.get_mut("access") else { + return Ok(()); + }; + let key_release_required = access + .get("key_release") + .and_then(|value| value.get("required")) + .and_then(Value::as_bool) + .unwrap_or(false); + if !key_release_required { + return Ok(()); + } + + let content_security = access + .get("content_security") + .cloned() + .ok_or_else(|| anyhow!("protected shared_access missing content_security"))?; + let sealed_object = protected_content_sealed_object_from_security(&content_security)?; + let recipient_proof = access + .get("recipient_proof") + .cloned() + .ok_or_else(|| anyhow!("protected shared_access missing recipient_proof"))?; + let principal_id = recipient_proof + .get("recipient") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow!("protected shared_access recipient proof missing recipient"))? + .to_string(); + let session_id = recipient_proof + .get("session_id") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow!("protected shared_access recipient proof missing session_id"))? + .to_string(); + let now = now_ts(); + let expires_at = now.saturating_add(900); + let action = "view"; + let reason = "library protected shared_access open"; + + let drm_receipt = protected_provider_data( + registry, + "drm", + "open", + &json!({ + "op": "open", + "request": { + "object": sealed_object, + "principal_id": principal_id, + "session_id": session_id, + "action": action, + "reason": reason + } + }), + ) + .await?; + reject_forbidden_protected_content_fields(&drm_receipt)?; + + let rights_receipt_value = protected_provider_data( + registry, + "rights", + "has_access_by_content_id", + &json!({ + "op": "has_access_by_content_id", + "request": { + "principal_id": principal_id, + "session_id": session_id, + "content_id": object_cid, + "right": action, + "reason": reason, + "policy_ref": sealed_object.rights_policy_cid + } + }), + ) + .await?; + reject_forbidden_protected_content_fields(&rights_receipt_value)?; + let rights_receipt: RightsDecisionReceiptV1 = + serde_json::from_value(rights_receipt_value.clone()) + .context("rights provider returned invalid protected-content receipt")?; + if rights_receipt.schema != RIGHTS_DECISION_RECEIPT_SCHEMA || !rights_receipt.allowed { + bail!("rights provider did not allow protected shared_access"); + } + + let key_release_request = KeyReleaseRequestV1 { + schema: KEY_RELEASE_REQUEST_SCHEMA.to_string(), + request_id: protected_request_id("key-release", &object_cid, &principal_id, now), + principal_id: principal_id.clone(), + session_id: session_id.clone(), + object_cid: object_cid.clone(), + action: action.to_string(), + rights_receipt, + key_envelope: sealed_object.key_envelope.clone(), + reason: reason.to_string(), + expires_at, + }; + let release_receipt_value = protected_provider_data( + registry, + "key", + "release", + &json!({ + "op": "release", + "request": key_release_request + }), + ) + .await?; + reject_forbidden_protected_content_fields(&release_receipt_value)?; + let release_receipt: ReleaseReceiptV1 = + serde_json::from_value(release_receipt_value.clone()) + .context("key provider returned invalid release receipt")?; + if release_receipt.schema != RELEASE_RECEIPT_SCHEMA { + bail!("key provider returned unsupported release receipt schema"); + } + + let decrypt_request = DecryptSessionRequestV1 { + schema: DECRYPT_SESSION_REQUEST_SCHEMA.to_string(), + request_id: protected_request_id("decrypt-session", &object_cid, &principal_id, now), + principal_id: principal_id.clone(), + session_id: session_id.clone(), + object_cid: object_cid.clone(), + action: action.to_string(), + viewer_interface: sealed_object.viewer.required_interface.clone(), + release_receipt, + output_kind: "rendered".to_string(), + reason: reason.to_string(), + expires_at, + }; + let decrypt_session_value = protected_provider_data( + registry, + "decrypt", + "open_session", + &json!({ + "op": "open_session", + "request": decrypt_request + }), + ) + .await?; + reject_forbidden_protected_content_fields(&decrypt_session_value)?; + if decrypt_session_value.get("schema").and_then(Value::as_str) != Some(DECRYPT_SESSION_SCHEMA) { + bail!("decrypt provider returned unsupported decrypt session schema"); + } + + if let Some(open) = access.get_mut("open").and_then(Value::as_object_mut) { + open.insert( + "provider".to_string(), + Value::String("decrypt-provider".to_string()), + ); + open.insert( + "transport".to_string(), + Value::String("runtime-protected-provider-chain".to_string()), + ); + open.insert( + "status".to_string(), + Value::String("ready_for_protected_viewer_session".to_string()), + ); + open.insert( + "protected_content".to_string(), + json!({ + "schema": "elastos.library.protected-open/v1", + "action": action, + "provider_chain": ["drm-provider.open", "rights-provider.has_access_by_content_id", "key-provider.release", "decrypt-provider.open_session"], + "drm_receipt": drm_receipt, + "rights_receipt": rights_receipt_value, + "key_release_receipt": release_receipt_value, + "decrypt_session": decrypt_session_value, + "viewer": { + "required_interface": sealed_object.viewer.required_interface, + "handoff": "viewer_capsule_session" + }, + "raw_cek_exposed": false, + "raw_plaintext_exposed": false + }), + ); + } + Ok(()) +} + +async fn protected_provider_data( + registry: &ProviderRegistry, + scheme: &str, + op: &str, + request: &Value, +) -> anyhow::Result { + let response = registry + .send_raw(scheme, request) + .await + .map_err(|err| anyhow!("{scheme} provider unavailable for protected {op}: {err}"))?; + if response.get("status").and_then(Value::as_str) == Some("error") { + let message = response + .get("message") + .and_then(Value::as_str) + .unwrap_or("provider returned error"); + bail!("{scheme} provider rejected protected {op}: {message}"); + } + response + .get("data") + .cloned() + .ok_or_else(|| anyhow!("{scheme} provider protected {op} response missing data")) +} + +fn protected_request_id(kind: &str, object_cid: &str, principal_id: &str, now: u64) -> String { + let digest = Sha256::digest(format!("{kind}:{object_cid}:{principal_id}:{now}")); + format!("{kind}:{}", hex::encode(&digest[..16])) +} + +fn reject_forbidden_protected_content_fields(value: &Value) -> anyhow::Result<()> { + const FORBIDDEN: &[&str] = &[ + "raw_cek", + "cek", + "raw_plaintext", + "plaintext", + "private_key", + "provider_credentials", + "kms_node_credentials", + "wallet_rpc", + "chain_rpc", + "kubo_api", + "elacity_sdk", + ]; + let mut stack = vec![value]; + while let Some(value) = stack.pop() { + match value { + Value::Object(map) => { + for (key, value) in map { + if FORBIDDEN.contains(&key.as_str()) { + bail!("protected provider response exposed forbidden field: {key}"); + } + stack.push(value); + } + } + Value::Array(values) => stack.extend(values), + _ => {} + } + } + Ok(()) +} + +async fn handle_library_webspace_request( + data_dir: &Path, + registry: &ProviderRegistry, + request: ObjectProviderRequest, +) -> anyhow::Result { + match request { + ObjectProviderRequest::List { principal_id, uri } => { + let uri = clean_webspace_uri(uri.as_deref().unwrap_or("localhost://WebSpaces"))?; + webspace_try_refresh_index_from_adapter(data_dir, registry, &uri).await?; + let data = webspace_provider_data( + registry, + json!({ + "op": "list", + "path": uri, + "token": "", + }), + "list", + ) + .await?; + let entries: Vec = serde_json::from_value(data) + .context("webspace-provider list response has invalid entries")?; + let mut objects = entries + .into_iter() + .map(|entry| webspace_entry_object(data_dir, &uri, entry)) + .collect::>>()?; + if uri == "localhost://WebSpaces" { + objects.push(localhost_space_pointer_object(data_dir, &principal_id)?); + sort_spaces_root_objects(&mut objects); + } + let object = webspace_stat_object(data_dir, registry, &uri).await?; + Ok(json!({ + "uri": uri, + "object": object, + "objects": objects, + })) + } + ObjectProviderRequest::Stat { uri, .. } => { + let uri = clean_webspace_uri(&uri)?; + Ok(json!({ + "object": webspace_stat_object(data_dir, registry, &uri).await?, + })) + } + ObjectProviderRequest::Read { uri, .. } | ObjectProviderRequest::Download { uri, .. } => { + let uri = clean_webspace_uri(&uri)?; + let (object, content) = webspace_read_bytes(data_dir, registry, &uri).await?; + Ok(json!({ + "object": object, + "encoding": "base64", + "data": base64::engine::general_purpose::STANDARD.encode(content), + })) + } + ObjectProviderRequest::ArchiveEntries { uri, .. } => { + let uri = clean_webspace_uri(&uri)?; + let (object, bytes) = webspace_read_bytes(data_dir, registry, &uri).await?; + let archive_name = object.name.clone(); + archive_entries_for_object(webspace_archive_object(object), &uri, &archive_name, bytes) + } + ObjectProviderRequest::ArchivePreviewEntry { + uri, + entry, + if_revision, + .. + } => { + let uri = clean_webspace_uri(&uri)?; + let (object, bytes) = webspace_read_bytes(data_dir, registry, &uri).await?; + check_object_revision(&object, if_revision.as_deref())?; + let archive_name = object.name.clone(); + archive_preview_entry_for_object( + data_dir, + webspace_archive_object(object), + &uri, + &archive_name, + bytes, + &entry, + ) + } + ObjectProviderRequest::ArchiveExtractEntries { + principal_id, + uri, + destination_uri, + entries, + conflict_policy, + if_revision, + cancel, + } => { + let (source_uri, archive_name, bytes) = if is_webspace_uri(uri.as_str()) { + let uri = clean_webspace_uri(&uri)?; + let (object, bytes) = webspace_read_bytes(data_dir, registry, &uri).await?; + check_object_revision(&object, if_revision.as_deref())?; + let archive_name = object.name.clone(); + (uri, archive_name, bytes) + } else { + let target = library_target(data_dir, &principal_id, &uri)?; + check_revision(data_dir, &principal_id, &target.uri, if_revision.as_deref())?; + let archive_name = library_archive_name(&target, "selected extraction")?; + let bytes = read_library_file_bytes(data_dir, &principal_id, &target)?; + (target.uri.clone(), archive_name, bytes) + }; + let request = ArchiveExtractRequest { + source_uri: &source_uri, + archive_name: &archive_name, + destination_uri: &destination_uri, + entries: &entries, + conflict_policy: conflict_policy.as_deref(), + cancel, + }; + if is_webspace_uri(destination_uri.as_str()) { + extract_archive_entries_to_webspace_destination( + data_dir, + registry, + &principal_id, + bytes, + request, + ) + .await + } else { + extract_archive_entries_to_local_destination( + data_dir, + &principal_id, + bytes, + request, + ) + } + } + ObjectProviderRequest::Sync { principal_id, uri } => { + let _ = principal_id; + let uri = clean_webspace_uri(&uri)?; + let receipt = webspace_sync_bytes(data_dir, registry, &uri).await?; + Ok(json!({ + "object": receipt.get("object").cloned().unwrap_or(Value::Null), + "receipt": receipt, + })) + } + ObjectProviderRequest::Status { uri, .. } => { + let uri = clean_webspace_uri(&uri)?; + Ok(json!({ + "object": webspace_stat_object(data_dir, registry, &uri).await?, + "published": null, + })) + } + ObjectProviderRequest::Write { + uri, + data, + if_revision: _, + .. + } => { + let uri = clean_webspace_uri(&uri)?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(data.trim()) + .context("library WebSpace write data must be base64")?; + let receipt = webspace_write_bytes(registry, &uri, &bytes).await?; + Ok(json!({ + "object": webspace_stat_object(data_dir, registry, &uri).await?, + "receipt": receipt, + })) + } + ObjectProviderRequest::Mkdir { + parent_uri, name, .. + } => { + let parent_uri = clean_webspace_uri(&parent_uri)?; + let uri = child_uri(&parent_uri, &name)?; + let receipt = webspace_mkdir(registry, &uri).await?; + Ok(json!({ + "object": webspace_stat_object(data_dir, registry, &uri).await?, + "receipt": receipt, + })) + } + ObjectProviderRequest::DeletePermanently { uri, .. } => { + let uri = clean_webspace_uri(&uri)?; + let receipt = webspace_delete_permanently(registry, &uri).await?; + Ok(json!({ + "deleted_uri": uri, + "receipt": receipt, + })) + } + _ => Err(anyhow!( + "Spaces operation is not supported by the current resolver lifecycle model" + )), + } +} + +fn handle_library_request( + data_dir: &Path, + request: ObjectProviderRequest, +) -> anyhow::Result { + match request { + ObjectProviderRequest::Roots { principal_id } => { + Ok(json!({ "roots": library_roots(data_dir, &principal_id) })) + } + ObjectProviderRequest::List { principal_id, uri } => { + let root = crate::auth::principal_localhost_root(&principal_id); + let uri = uri.unwrap_or_else(|| root.clone()); + let target = library_target(data_dir, &principal_id, &uri)?; + if !target.path.exists() { + return Ok(json!({ "uri": target.uri, "objects": [] })); + } + if !target.path.is_dir() { + bail!("library list target must be a directory"); + } + let mut objects = Vec::new(); + for entry in fs::read_dir(&target.path) + .with_context(|| format!("failed to list {:?}", target.path))? + { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + if target.uri == root && (name == ".AppData" || name == ".Trash") { + continue; + } + let child_uri = format!("{}/{}", target.uri.trim_end_matches('/'), name); + objects.push(library_object(data_dir, &principal_id, &child_uri)?); + } + objects.sort_by(|a, b| { + a.kind + .cmp(b.kind) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + Ok(json!({ "uri": target.uri, "objects": objects })) + } + ObjectProviderRequest::Stat { principal_id, uri } => { + Ok(json!({ "object": library_object(data_dir, &principal_id, &uri)? })) + } + ObjectProviderRequest::Read { principal_id, uri } => { + let target = library_target(data_dir, &principal_id, &uri)?; + if !target.path.is_file() { + bail!("library read target must be a file"); + } + let bytes = read_library_file_bytes(data_dir, &principal_id, &target)?; + Ok(json!({ + "object": library_object(data_dir, &principal_id, &target.uri)?, + "encoding": "base64", + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + })) + } + ObjectProviderRequest::Download { principal_id, uri } => { + let (object, filename, bytes) = library_download_object( + data_dir, + &principal_id, + &uri, + LibraryArchiveFormat::TarGz, + )?; + Ok(json!({ + "object": object, + "encoding": "base64", + "filename": filename, + "data": base64::engine::general_purpose::STANDARD.encode(bytes), + })) + } + ObjectProviderRequest::ExtractArchive { + principal_id, + uri, + if_revision, + } => { + let target = library_target(data_dir, &principal_id, &uri)?; + check_revision(data_dir, &principal_id, &target.uri, if_revision.as_deref())?; + let extracted_uri = extract_library_archive(data_dir, &principal_id, &target)?; + let object = library_object(data_dir, &principal_id, &extracted_uri)?; + append_library_event( + data_dir, + &principal_id, + "extract_archive", + &extracted_uri, + json!({ + "source_uri": target.uri, + "object": object.clone(), + }), + )?; + Ok(json!({ + "object": object, + "source_uri": target.uri, + })) + } + ObjectProviderRequest::ArchiveEntries { principal_id, uri } => { + let target = library_target(data_dir, &principal_id, &uri)?; + Ok(library_archive_entries(data_dir, &principal_id, &target)?) + } + ObjectProviderRequest::ArchivePreviewEntry { + principal_id, + uri, + entry, + if_revision, + } => { + let target = library_target(data_dir, &principal_id, &uri)?; + check_revision(data_dir, &principal_id, &target.uri, if_revision.as_deref())?; + archive_preview_entry(data_dir, &principal_id, &target, &entry) + } + ObjectProviderRequest::ArchiveExtractEntries { + principal_id, + uri, + destination_uri, + entries, + conflict_policy, + if_revision, + cancel, + } => { + let target = library_target(data_dir, &principal_id, &uri)?; + check_revision(data_dir, &principal_id, &target.uri, if_revision.as_deref())?; + extract_library_archive_entries( + data_dir, + &principal_id, + &target, + &destination_uri, + &entries, + conflict_policy.as_deref(), + cancel, + ) + } + ObjectProviderRequest::CompressArchive { + principal_id, + uri, + uris, + if_revision, + } => { + let object = compress_library_archive( + data_dir, + &principal_id, + uri.as_deref(), + &uris, + if_revision.as_deref(), + )?; + Ok(json!({ "object": object })) + } + ObjectProviderRequest::Write { + principal_id, + uri, + data, + mime, + if_revision, + } => { + let bytes = base64::engine::general_purpose::STANDARD + .decode(data.trim()) + .context("library write data must be base64")?; + let target = library_target(data_dir, &principal_id, &uri)?; + if is_trash_uri(&target.localhost_root, &target.uri) { + bail!("library Trash accepts objects only through delete"); + } + let object = write_library_file_bytes( + data_dir, + &principal_id, + &target.uri, + mime.as_deref(), + if_revision.as_deref(), + &bytes, + )?; + Ok(json!({ "object": object })) + } + ObjectProviderRequest::Mkdir { + principal_id, + parent_uri, + name, + } => { + let parent = library_target(data_dir, &principal_id, &parent_uri)?; + if parent.path.exists() && !parent.path.is_dir() { + bail!("library mkdir parent must be a directory"); + } + if is_trash_uri(&parent.localhost_root, &parent.uri) { + bail!("library Trash accepts objects only through delete"); + } + let child_uri = child_uri(&parent.uri, &name)?; + let child = library_target(data_dir, &principal_id, &child_uri)?; + fs::create_dir_all(&child.path)?; + let object = library_object(data_dir, &principal_id, &child.uri)?; + append_library_event( + data_dir, + &principal_id, + "mkdir", + &child.uri, + json!({ + "object": object.clone(), + }), + )?; + Ok(json!({ "object": object })) + } + ObjectProviderRequest::Rename { + principal_id, + uri, + name, + if_revision, + } => { + let target = library_target(data_dir, &principal_id, &uri)?; + check_revision(data_dir, &principal_id, &target.uri, if_revision.as_deref())?; + if is_trash_uri(&target.localhost_root, &target.uri) { + bail!("library Trash objects can be restored or deleted permanently"); + } + let parent_uri = target + .uri + .rsplit_once('/') + .map(|(parent, _)| parent) + .ok_or_else(|| anyhow!("library rename target has no parent"))?; + let new_uri = child_uri(parent_uri, &name)?; + move_library_object(data_dir, &principal_id, &target.uri, &new_uri)?; + let object = library_object(data_dir, &principal_id, &new_uri)?; + append_library_event( + data_dir, + &principal_id, + "rename", + &new_uri, + json!({ + "old_uri": target.uri, + "object": object.clone(), + }), + )?; + Ok(json!({ "object": object })) + } + ObjectProviderRequest::Move { + principal_id, + uri, + target_parent_uri, + if_revision, + } => { + let target = library_target(data_dir, &principal_id, &uri)?; + check_revision(data_dir, &principal_id, &target.uri, if_revision.as_deref())?; + let parent = library_target(data_dir, &principal_id, &target_parent_uri)?; + if parent.path.exists() && !parent.path.is_dir() { + bail!("library move target parent must be a directory"); + } + if is_trash_uri(&target.localhost_root, &target.uri) + || is_trash_uri(&parent.localhost_root, &parent.uri) + { + bail!("library Trash objects can be moved only through trash or restore"); + } + if parent.uri == target.uri || parent.uri.starts_with(&(target.uri.clone() + "/")) { + bail!("library object cannot be moved inside itself"); + } + let name = target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .ok_or_else(|| anyhow!("library move target has no name"))?; + let new_uri = unique_child_uri(data_dir, &principal_id, &parent.uri, name)?; + move_library_object(data_dir, &principal_id, &target.uri, &new_uri)?; + let object = library_object(data_dir, &principal_id, &new_uri)?; + append_library_event( + data_dir, + &principal_id, + "move", + &new_uri, + json!({ + "old_uri": target.uri, + "target_uri": new_uri, + "object": object.clone(), + }), + )?; + Ok(json!({ "object": object })) + } + ObjectProviderRequest::Copy { + principal_id, + uri, + target_parent_uri, + if_revision, + } => { + let target = library_target(data_dir, &principal_id, &uri)?; + check_revision(data_dir, &principal_id, &target.uri, if_revision.as_deref())?; + let parent = library_target(data_dir, &principal_id, &target_parent_uri)?; + if parent.path.exists() && !parent.path.is_dir() { + bail!("library copy target parent must be a directory"); + } + if is_trash_uri(&target.localhost_root, &target.uri) + || is_trash_uri(&parent.localhost_root, &parent.uri) + { + bail!("library Trash objects cannot be copied directly"); + } + if parent.uri == target.uri || parent.uri.starts_with(&(target.uri.clone() + "/")) { + bail!("library object cannot be copied inside itself"); + } + let name = target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .ok_or_else(|| anyhow!("library copy target has no name"))?; + let new_uri = unique_child_uri(data_dir, &principal_id, &parent.uri, name)?; + copy_library_object(data_dir, &principal_id, &target.uri, &new_uri)?; + let object = library_object(data_dir, &principal_id, &new_uri)?; + append_library_event( + data_dir, + &principal_id, + "copy", + &new_uri, + json!({ + "source_uri": target.uri, + "target_uri": new_uri, + "object": object.clone(), + }), + )?; + Ok(json!({ "object": object })) + } + ObjectProviderRequest::Trash { + principal_id, + uri, + if_revision, + } => { + let target = library_target(data_dir, &principal_id, &uri)?; + check_revision(data_dir, &principal_id, &target.uri, if_revision.as_deref())?; + if is_trash_uri(&target.localhost_root, &target.uri) { + bail!("library object is already in Trash"); + } + if is_runtime_private_uri(&target.localhost_root, &target.uri) { + bail!("runtime-private Library objects cannot be moved to Trash"); + } + let name = target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .ok_or_else(|| anyhow!("library trash target has no name"))?; + let trash_root = format!("{}/.Trash", target.localhost_root); + let trash_uri = unique_child_uri(data_dir, &principal_id, &trash_root, name)?; + move_library_object(data_dir, &principal_id, &target.uri, &trash_uri)?; + write_trash_record( + data_dir, + &principal_id, + &LibraryTrashRecord { + schema: LIBRARY_TRASH_RECORD_SCHEMA.to_string(), + trash_uri: trash_uri.clone(), + original_uri: target.uri.clone(), + original_name: name.to_string(), + trashed_at: now_ts(), + }, + )?; + let object = library_object(data_dir, &principal_id, &trash_uri)?; + append_library_event( + data_dir, + &principal_id, + "trash", + &trash_uri, + json!({ + "original_uri": target.uri, + "object": object.clone(), + }), + )?; + Ok(json!({ + "object": object, + "original_uri": target.uri, + })) + } + ObjectProviderRequest::Restore { + principal_id, + uri, + target_uri, + if_revision, + } => { + let target = library_target(data_dir, &principal_id, &uri)?; + if !is_trash_child_uri(&target.localhost_root, &target.uri) { + bail!("library restore target must be in Trash"); + } + check_revision(data_dir, &principal_id, &target.uri, if_revision.as_deref())?; + let trash_record = read_trash_record(data_dir, &principal_id, &target.uri).ok(); + let restore_uri = target_uri + .as_deref() + .filter(|uri| !uri.trim().is_empty()) + .map(|uri| clean_library_uri(&target.localhost_root, uri)) + .transpose()? + .map(Ok) + .unwrap_or_else(|| { + restore_uri_from_trash_record( + data_dir, + &principal_id, + &target, + trash_record.as_ref(), + ) + })?; + let restore_target = library_target(data_dir, &principal_id, &restore_uri)?; + if is_trash_uri(&restore_target.localhost_root, &restore_target.uri) { + bail!("library restore target cannot be inside Trash"); + } + move_library_object(data_dir, &principal_id, &target.uri, &restore_target.uri)?; + remove_trash_record(data_dir, &principal_id, &target.uri)?; + let object = library_object(data_dir, &principal_id, &restore_target.uri)?; + append_library_event( + data_dir, + &principal_id, + "restore", + &restore_target.uri, + json!({ + "trash_uri": target.uri, + "original_uri": trash_record.as_ref().map(|record| record.original_uri.clone()), + "object": object.clone(), + }), + )?; + Ok(json!({ "object": object })) + } + ObjectProviderRequest::DeletePermanently { + principal_id, + uri, + if_revision, + } => { + let target = library_target(data_dir, &principal_id, &uri)?; + if !is_trash_child_uri(&target.localhost_root, &target.uri) { + bail!("library delete_permanently target must be in Trash"); + } + check_revision(data_dir, &principal_id, &target.uri, if_revision.as_deref())?; + if target.path.is_dir() { + fs::remove_dir_all(&target.path)?; + } else { + fs::remove_file(&target.path)?; + } + remove_trash_record(data_dir, &principal_id, &target.uri)?; + append_library_event( + data_dir, + &principal_id, + "delete_permanently", + &target.uri, + json!({}), + )?; + Ok(json!({ "deleted_uri": target.uri })) + } + ObjectProviderRequest::EmptyTrash { principal_id } => { + let root = crate::auth::principal_localhost_root(&principal_id); + let trash_root = format!("{root}/.Trash"); + let target = library_target(data_dir, &principal_id, &trash_root)?; + let mut deleted_uris = Vec::new(); + if target.path.exists() { + if !target.path.is_dir() { + bail!("library Trash root must be a directory"); + } + for entry in fs::read_dir(&target.path) + .with_context(|| format!("failed to list {:?}", target.path))? + { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + let child_uri = format!("{}/{}", target.uri, name); + let child = library_target(data_dir, &principal_id, &child_uri)?; + if child.path.is_dir() { + fs::remove_dir_all(&child.path)?; + } else { + fs::remove_file(&child.path)?; + } + remove_trash_record(data_dir, &principal_id, &child.uri)?; + deleted_uris.push(child.uri); + } + } + let deleted_count = deleted_uris.len(); + append_library_event( + data_dir, + &principal_id, + "empty_trash", + &target.uri, + json!({ + "deleted_count": deleted_uris.len(), + "deleted_uris": deleted_uris, + }), + )?; + Ok(json!({ "deleted_count": deleted_count })) + } + ObjectProviderRequest::Status { principal_id, uri } => { + let object = library_object(data_dir, &principal_id, &uri)?; + let record = read_publish_record(data_dir, &principal_id, &uri).ok(); + Ok(json!({ + "object": object, + "published": record, + })) + } + ObjectProviderRequest::Sync { principal_id, .. } => { + let _ = principal_id; + bail!("library sync is only supported for Spaces objects") + } + ObjectProviderRequest::Events { + principal_id, + uri, + since, + limit, + } => Ok(json!({ + "schema": "elastos.library.events/v1", + "events": library_events(data_dir, &principal_id, uri.as_deref(), since, limit)?, + })), + ObjectProviderRequest::Share { + principal_id, + uri, + recipients, + policy, + key_release_policy, + } => { + let object = library_object(data_dir, &principal_id, &uri)?; + let mut record = read_publish_record(data_dir, &principal_id, &uri) + .context("library share requires a published object")?; + if !record_is_published(&record) { + bail!("library share requires an actively published object"); + } + let shared_at = now_ts(); + let recipients = normalized_share_recipients(&recipients)?; + let share_policy = normalized_share_policy(policy.as_deref(), recipients.is_empty())?; + let key_release = normalized_key_release_policy( + key_release_policy.as_deref(), + &record.content_security, + )?; + let remote_enforcement = share_remote_enforcement_contract(&share_policy, &key_release); + record.shared_at = Some(shared_at); + record.share_policy = Some(share_policy.clone()); + record.share_grants = recipients + .iter() + .map(|recipient| { + share_grant( + recipient, + &record.cid, + &format!("elastos://{}", record.cid), + &share_policy, + key_release.clone(), + shared_at, + ) + }) + .collect(); + write_publish_record(data_dir, &principal_id, &record)?; + append_library_event( + data_dir, + &principal_id, + "share", + &uri, + json!({ + "cid": record.cid, + "policy": share_policy, + "key_release": key_release.clone(), + "remote_enforcement": remote_enforcement.clone(), + "recipients": recipients, + "shared_at": record.shared_at, + }), + )?; + Ok(json!({ + "schema": "elastos.library.share/v1", + "object": library_object(data_dir, &principal_id, &uri)?, + "uri": format!("elastos://{}", record.cid), + "cid": record.cid, + "policy": record.share_policy, + "content_security": record.content_security, + "key_release": key_release.clone(), + "remote_enforcement": remote_enforcement, + "recipients": recipients, + "grants": record.share_grants, + "availability": record.availability, + "shared_at": record.shared_at, + "object_uri": object.uri, + })) + } + ObjectProviderRequest::SharedAccess { + principal_id, + uri, + recipient, + recipient_proof, + } => { + let object = library_object(data_dir, &principal_id, &uri)?; + let record = read_publish_record(data_dir, &principal_id, &uri) + .context("library shared_access requires a published object")?; + if !record_is_published(&record) || record.shared_at.is_none() { + bail!("library shared_access requires an actively shared object"); + } + let access = match shared_access_receipt(&record, &recipient, recipient_proof.as_ref()) + { + Ok(access) => access, + Err(err) => { + append_library_event( + data_dir, + &principal_id, + "shared_access", + &uri, + json!({ + "cid": record.cid, + "recipient": recipient, + "policy": record.share_policy, + "allowed": false, + "reason": err.to_string(), + }), + )?; + return Err(err); + } + }; + append_library_event( + data_dir, + &principal_id, + "shared_access", + &uri, + json!({ + "cid": record.cid, + "recipient": recipient, + "policy": record.share_policy, + "allowed": true, + "decision": access.get("decision").cloned().unwrap_or(Value::Null), + "open": access.get("open").cloned().unwrap_or(Value::Null), + "key_release": access.get("key_release").cloned().unwrap_or(Value::Null), + }), + )?; + Ok(json!({ + "schema": "elastos.library.shared-access/v1", + "object": object, + "uri": format!("elastos://{}", record.cid), + "cid": record.cid, + "access": access, + "availability": record.availability, + })) + } + ObjectProviderRequest::Publish { .. } + | ObjectProviderRequest::Unpublish { .. } + | ObjectProviderRequest::Repair { .. } => { + unreachable!("publish/unpublish/repair handled asynchronously") + } + } +} + +async fn library_publish( + data_dir: &Path, + registry: Arc, + principal_id: &str, + uri: &str, + if_revision: Option<&str>, + protected_content_fixture: bool, +) -> anyhow::Result { + let target = library_target(data_dir, principal_id, uri)?; + check_revision(data_dir, principal_id, &target.uri, if_revision)?; + if !target.path.is_file() { + bail!("library publish currently supports files only"); + } + let bytes = read_library_file_bytes(data_dir, principal_id, &target)?; + let filename = target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("object.bin"); + let publish_request = json!({ + "op": "publish", + "kind": "file", + "filename": filename, + "mime": mime_for_name(filename), + "data": base64::engine::general_purpose::STANDARD.encode(&bytes), + "pin": true, + "publisher_did": principal_id, + }); + let response = registry + .send_raw("content", &publish_request) + .await + .map_err(|err| anyhow!("content provider unavailable: {err}"))?; + if response.get("status").and_then(Value::as_str) == Some("error") { + let message = response + .get("message") + .and_then(Value::as_str) + .unwrap_or("content publish failed"); + bail!("content publish failed: {message}"); + } + let data = response + .get("data") + .cloned() + .ok_or_else(|| anyhow!("content publish response missing data"))?; + let payload_cid = data + .get("cid") + .and_then(Value::as_str) + .filter(|cid| !cid.trim().is_empty()) + .ok_or_else(|| anyhow!("content publish response missing cid"))? + .to_string(); + let (cid, receipt, availability, content_security) = if protected_content_fixture { + let content_security = + protected_content_fixture_security(data_dir, principal_id, &target, &payload_cid)?; + let sealed = protected_content_sealed_object_from_security(&content_security)?; + let sealed_publish_request = protected_content_fixture_publish_request( + principal_id, + &target, + &sealed, + data.get("availability"), + )?; + let sealed_response = registry + .send_raw("content", &sealed_publish_request) + .await + .map_err(|err| anyhow!("content provider unavailable: {err}"))?; + if sealed_response.get("status").and_then(Value::as_str) == Some("error") { + let message = sealed_response + .get("message") + .and_then(Value::as_str) + .unwrap_or("sealed content publish failed"); + bail!("sealed content publish failed: {message}"); + } + let sealed_data = sealed_response + .get("data") + .cloned() + .ok_or_else(|| anyhow!("sealed content publish response missing data"))?; + let sealed_cid = sealed_data + .get("cid") + .and_then(Value::as_str) + .filter(|cid| !cid.trim().is_empty()) + .ok_or_else(|| anyhow!("sealed content publish response missing cid"))? + .to_string(); + let mut content_security = content_security; + if let Some(object) = content_security.as_object_mut() { + object.insert("sealed_cid".to_string(), Value::String(sealed_cid.clone())); + } + ( + sealed_cid, + sealed_data + .get("receipt") + .cloned() + .unwrap_or_else(|| json!({"status": "not_provided"})), + sealed_data + .get("availability") + .cloned() + .unwrap_or_else(|| json!({"status": "unknown"})), + content_security, + ) + } else { + ( + payload_cid, + data.get("receipt") + .cloned() + .unwrap_or_else(|| json!({"status": "not_provided"})), + data.get("availability") + .cloned() + .unwrap_or_else(|| json!({"status": "unknown"})), + published_content_security(data_dir, principal_id, &target)?, + ) + }; + let record = LibraryPublishRecord { + schema: "elastos.library.publish-record/v1".to_string(), + object_uri: target.uri.clone(), + cid: cid.clone(), + published_at: now_ts(), + unpublished_at: None, + shared_at: None, + share_policy: None, + share_grants: Vec::new(), + content_security, + receipt, + availability, + }; + write_publish_record(data_dir, principal_id, &record)?; + let object = library_object(data_dir, principal_id, &target.uri)?; + append_library_event( + data_dir, + principal_id, + "publish", + &target.uri, + json!({ + "cid": cid, + "availability": record.availability, + "object": object, + }), + )?; + Ok(json!({ + "object": object, + "uri": format!("elastos://{}", record.cid), + "cid": record.cid, + "receipt": record.receipt, + "availability": record.availability, + "content_security": record.content_security, + "published_at": record.published_at, + })) +} + +async fn library_unpublish( + data_dir: &Path, + registry: Arc, + principal_id: &str, + uri: &str, + if_revision: Option<&str>, +) -> anyhow::Result { + let target = library_target(data_dir, principal_id, uri)?; + check_revision(data_dir, principal_id, &target.uri, if_revision)?; + let mut record = read_publish_record(data_dir, principal_id, &target.uri) + .context("library unpublish requires a published object")?; + if !record_is_published(&record) { + bail!("library object is not actively published"); + } + let response = registry + .send_raw( + "content", + &json!({ + "op": "unpublish", + "cid": record.cid, + "object_did": target.uri, + "publisher_did": principal_id, + }), + ) + .await + .map_err(|err| anyhow!("content provider unavailable: {err}"))?; + if response.get("status").and_then(Value::as_str) == Some("error") { + let message = response + .get("message") + .and_then(Value::as_str) + .unwrap_or("content unpublish failed"); + bail!("content unpublish failed: {message}"); + } + let data = response + .get("data") + .cloned() + .ok_or_else(|| anyhow!("content unpublish response missing data"))?; + record.unpublished_at = Some(now_ts()); + record.shared_at = None; + record.receipt = data + .get("receipt") + .cloned() + .unwrap_or_else(|| json!({"status": "not_provided"})); + record.availability = data + .get("availability") + .cloned() + .unwrap_or_else(|| json!({"status": "local_unpinned"})); + write_publish_record(data_dir, principal_id, &record)?; + let object = library_object(data_dir, principal_id, &target.uri)?; + append_library_event( + data_dir, + principal_id, + "unpublish", + &target.uri, + json!({ + "cid": record.cid, + "availability": record.availability, + "object": object, + }), + )?; + Ok(json!({ + "object": object, + "uri": format!("elastos://{}", record.cid), + "cid": record.cid, + "receipt": record.receipt, + "availability": record.availability, + "unpublished_at": record.unpublished_at, + })) +} + +async fn library_repair( + data_dir: &Path, + registry: Arc, + principal_id: &str, + uri: &str, +) -> anyhow::Result { + let target = library_target(data_dir, principal_id, uri)?; + let mut record = read_publish_record(data_dir, principal_id, &target.uri) + .context("library repair requires a published object")?; + let response = registry + .send_raw( + "content", + &json!({ + "op": "repair", + "cid": record.cid, + "object_did": target.uri, + "publisher_did": principal_id, + }), + ) + .await + .map_err(|err| anyhow!("content provider unavailable: {err}"))?; + if response.get("status").and_then(Value::as_str) == Some("error") { + let message = response + .get("message") + .and_then(Value::as_str) + .unwrap_or("content repair failed"); + bail!("content repair failed: {message}"); + } + let data = response + .get("data") + .cloned() + .ok_or_else(|| anyhow!("content repair response missing data"))?; + record.unpublished_at = None; + record.receipt = data + .get("receipt") + .cloned() + .unwrap_or_else(|| json!({"status": "not_provided"})); + record.availability = data + .get("availability") + .cloned() + .unwrap_or_else(|| json!({"status": "unknown"})); + write_publish_record(data_dir, principal_id, &record)?; + let object = library_object(data_dir, principal_id, &target.uri)?; + append_library_event( + data_dir, + principal_id, + "repair", + &target.uri, + json!({ + "cid": record.cid, + "availability": record.availability, + "object": object, + }), + )?; + Ok(json!({ + "object": object, + "uri": format!("elastos://{}", record.cid), + "cid": record.cid, + "receipt": record.receipt, + "availability": record.availability, + })) +} + +struct LibraryTarget { + localhost_root: String, + uri: String, + path: PathBuf, +} + +#[derive(Debug, Deserialize)] +struct WebSpaceDirEntry { + name: String, + is_file: bool, + is_dir: bool, + size: u64, + #[serde(default)] + target_uri: Option, + #[serde(default)] + resolver_state: Option, + #[serde(default)] + resolver: Option, + #[serde(default)] + cache_policy: Option, + #[serde(default)] + sync_policy: Option, + #[serde(default, rename = "kind")] + webspace_kind: Option, + #[serde(default)] + object_id: Option, + #[serde(default)] + head_id: Option, + #[serde(default)] + cache_state: Option, + #[serde(default)] + sync_state: Option, + #[serde(default)] + provider: Option, + #[serde(default)] + readonly: Option, + #[serde(default)] + access_policy: Option, +} + +#[derive(Debug, Deserialize)] +struct WebSpaceFileStat { + path: String, + is_file: bool, + is_dir: bool, + size: u64, + #[serde(default)] + modified: Option, + #[serde(default)] + created: Option, + #[serde(default)] + target_uri: Option, + #[serde(default)] + resolver_state: Option, + #[serde(default)] + resolver: Option, + #[serde(default)] + cache_policy: Option, + #[serde(default)] + sync_policy: Option, + #[serde(default, rename = "kind")] + webspace_kind: Option, + #[serde(default)] + object_id: Option, + #[serde(default)] + head_id: Option, + #[serde(default)] + cache_state: Option, + #[serde(default)] + sync_state: Option, + #[serde(default)] + provider: Option, + #[serde(default)] + readonly: Option, + #[serde(default)] + access_policy: Option, +} + +async fn webspace_provider_data( + registry: &ProviderRegistry, + request: Value, + op: &str, +) -> anyhow::Result { + let response = registry + .send_raw("webspace", &request) + .await + .map_err(|err| anyhow!("webspace-provider unavailable: {err}"))?; + if response.get("status").and_then(Value::as_str) == Some("error") { + let code = response + .get("code") + .and_then(Value::as_str) + .unwrap_or("webspace_error"); + let message = response + .get("message") + .and_then(Value::as_str) + .unwrap_or("webspace-provider request failed"); + bail!("webspace-provider {op} failed [{code}]: {message}"); + } + response + .get("data") + .cloned() + .ok_or_else(|| anyhow!("webspace-provider {op} response missing data")) +} + +async fn webspace_stat_object( + data_dir: &Path, + registry: &ProviderRegistry, + uri: &str, +) -> anyhow::Result { + let data = webspace_provider_data( + registry, + json!({ + "op": "stat", + "path": uri, + "token": "", + }), + "stat", + ) + .await?; + let stat: WebSpaceFileStat = + serde_json::from_value(data).context("webspace-provider stat response is invalid")?; + webspace_stat_to_object(data_dir, uri, stat) +} + +async fn webspace_download_bytes( + data_dir: &Path, + registry: &ProviderRegistry, + uri: &str, +) -> anyhow::Result { + let uri = clean_webspace_uri(uri)?; + let (object, bytes) = webspace_read_bytes(data_dir, registry, &uri).await?; + Ok(LibraryDownloadBytes { + filename: object.name, + mime: object.mime, + bytes, + }) +} + +async fn webspace_read_bytes( + data_dir: &Path, + registry: &ProviderRegistry, + uri: &str, +) -> anyhow::Result<(LibraryObject, Vec)> { + let object = webspace_stat_object(data_dir, registry, uri).await?; + if object.kind != "file" { + bail!("library read target must be a file"); + } + if let Some((object, bytes)) = + webspace_try_cache_bytes_from_adapter(data_dir, registry, &object).await? + { + return Ok((object, bytes)); + } + let content = webspace_read_provider_bytes(registry, uri).await?; + Ok((object, content)) +} + +async fn webspace_sync_bytes( + data_dir: &Path, + registry: &ProviderRegistry, + uri: &str, +) -> anyhow::Result { + let object = webspace_stat_object(data_dir, registry, uri).await?; + if object.kind != "file" { + bail!("Spaces byte sync target must be a file"); + } + if webspace_metadata_str(&object, "sync_state") == Some("manual_pending") + || (webspace_metadata_bool(&object, "readonly") == Some(false) + && webspace_metadata_str(&object, "sync_state") != Some("manual_synced")) + { + return webspace_sync_mutable_file_to_resolver(data_dir, registry, object).await; + } + if webspace_metadata_str(&object, "cache_state") == Some("content_cached") + || webspace_metadata_str(&object, "webspace_kind") == Some("materialized-file") + { + let availability_hint = webspace_object_availability_hint(&object); + return Ok(json!({ + "schema": "elastos.webspace.byte-sync-receipt/v1", + "action": "already_content_cached", + "handle_uri": object.uri, + "content_synced": true, + "foreground_read": false, + "bytes_exposed": false, + "availability_hint": availability_hint, + "object": object, + "note": "Resolver bytes are already present in the provider-owned WebSpace cache." + })); + } + let Some((cached_object, bytes)) = + webspace_try_cache_bytes_from_adapter(data_dir, registry, &object).await? + else { + bail!("Spaces byte sync requires a connected resolver adapter with read_bytes"); + }; + let availability_hint = webspace_object_availability_hint(&cached_object); + Ok(json!({ + "schema": "elastos.webspace.byte-sync-receipt/v1", + "action": "bytes_cached_from_adapter", + "handle_uri": cached_object.uri, + "content_synced": true, + "foreground_read": false, + "bytes_exposed": false, + "bytes_cached": bytes.len(), + "availability_hint": availability_hint, + "object": cached_object, + "note": "Runtime invoked the resolver adapter and stored bytes in the provider-owned WebSpace cache without returning bytes to the caller." + })) +} + +async fn webspace_sync_mutable_file_to_resolver( + data_dir: &Path, + registry: &ProviderRegistry, + object: LibraryObject, +) -> anyhow::Result { + let bytes = webspace_read_provider_bytes(registry, &object.uri).await?; + let Some(adapter) = webspace_adapter_target(registry, &object).await? else { + return Ok(webspace_resolver_sync_failed_receipt( + &object, + "resolver_write_unavailable", + "No connected resolver adapter is available for this mutable WebSpace object.", + None, + None, + )); + }; + if !adapter.capabilities.contains("write_bytes") { + return Ok(webspace_resolver_sync_failed_receipt( + &object, + "resolver_write_unavailable", + "The connected resolver adapter does not advertise write_bytes.", + Some(adapter.provider), + None, + )); + } + let response = registry + .invoke_provider(ProviderInvocation { + source: "webspace-provider".to_string(), + target: adapter.provider.clone(), + op: "write_bytes".to_string(), + request: json!({ + "op": "write_bytes", + "schema": "elastos.webspace.adapter.write-bytes-request/v1", + "mount": adapter.mount.clone(), + "resolver": adapter.resolver.clone(), + "handle_uri": object.uri.clone(), + "target_uri": adapter.target_uri.clone(), + "data": base64::engine::general_purpose::STANDARD.encode(&bytes), + "if_head": webspace_metadata_str(&object, "head_id"), + }), + transfer: ProviderTransfer::Bytes, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .map_err(|err| anyhow!("WebSpace adapter write_bytes failed: {err}"))?; + if response.get("status").and_then(Value::as_str) == Some("error") { + let code = response + .get("code") + .and_then(Value::as_str) + .unwrap_or("adapter_write_failed") + .to_string(); + let message = response + .get("message") + .and_then(Value::as_str) + .unwrap_or("resolver adapter write_bytes failed") + .to_string(); + return Ok(webspace_resolver_sync_failed_receipt( + &object, + if code == "conflict" { + "resolver_write_conflict" + } else { + "resolver_write_failed" + }, + &message, + Some(adapter.provider), + Some(response), + )); + } + let data = response_data_object(&response, "write_bytes")?; + if data.get("schema").and_then(Value::as_str) != Some("elastos.webspace.adapter.write-bytes/v1") + { + bail!("WebSpace adapter write_bytes response schema mismatch"); + } + let provider_sync_receipt = webspace_provider_data( + registry, + json!({ + "op": "sync", + "path": object.uri.clone(), + "token": "", + }), + "sync", + ) + .await?; + let synced_object = webspace_stat_object(data_dir, registry, &object.uri).await?; + let availability_hint = webspace_object_availability_hint(&synced_object); + Ok(json!({ + "schema": "elastos.webspace.resolver-sync-receipt/v1", + "action": "resolver_write_synced", + "handle_uri": synced_object.uri.clone(), + "target_uri": adapter.target_uri, + "resolver": adapter.resolver, + "provider": adapter.provider, + "resolver_synced": true, + "content_synced": true, + "fail_closed": false, + "conflict": false, + "bytes_exposed": false, + "bytes_synced": bytes.len(), + "availability_hint": availability_hint, + "object": synced_object, + "provider_sync_receipt": provider_sync_receipt, + "adapter_receipt": data.get("receipt").cloned(), + "runtime_transfer": response.get("_runtime_transfer").cloned(), + })) +} + +fn webspace_resolver_sync_failed_receipt( + object: &LibraryObject, + action: &str, + message: &str, + provider: Option, + adapter_response: Option, +) -> Value { + json!({ + "schema": "elastos.webspace.resolver-sync-receipt/v1", + "action": action, + "handle_uri": object.uri.clone(), + "target_uri": webspace_metadata_str(object, "target_uri"), + "resolver": webspace_metadata_str(object, "resolver"), + "provider": provider, + "resolver_synced": false, + "content_synced": false, + "fail_closed": true, + "conflict": action == "resolver_write_conflict", + "bytes_exposed": false, + "object": object, + "adapter_response": adapter_response, + "message": message, + }) +} + +#[derive(Debug)] +struct WebSpaceAdapterTarget { + mount: String, + resolver: String, + target_uri: String, + provider: String, + capabilities: BTreeSet, +} + +async fn webspace_try_refresh_index_from_adapter( + data_dir: &Path, + registry: &ProviderRegistry, + uri: &str, +) -> anyhow::Result<()> { + let object = match webspace_stat_object(data_dir, registry, uri).await { + Ok(object) => object, + Err(_) => return Ok(()), + }; + let Some(adapter) = webspace_adapter_target(registry, &object).await? else { + return Ok(()); + }; + if !adapter.capabilities.contains("metadata_index") { + return Ok(()); + } + let response = registry + .invoke_provider(ProviderInvocation { + source: "webspace-provider".to_string(), + target: adapter.provider.clone(), + op: "metadata_index".to_string(), + request: json!({ + "op": "metadata_index", + "schema": "elastos.webspace.adapter.metadata-index-request/v1", + "mount": adapter.mount.clone(), + "resolver": adapter.resolver.clone(), + "handle_uri": object.uri, + "target_uri": adapter.target_uri.clone(), + }), + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .map_err(|err| anyhow!("WebSpace adapter metadata_index failed: {err}"))?; + let data = response_data_object(&response, "metadata_index")?; + if data.get("schema").and_then(Value::as_str) + != Some("elastos.webspace.adapter.metadata-index/v1") + { + bail!("WebSpace adapter metadata_index response schema mismatch"); + } + let entries = data + .get("entries") + .cloned() + .ok_or_else(|| anyhow!("WebSpace adapter metadata_index response missing entries"))?; + webspace_provider_data( + registry, + json!({ + "op": "refresh", + "path": format!("localhost://WebSpaces/{}", adapter.mount), + "entries": entries, + "token": "", + }), + "refresh", + ) + .await?; + Ok(()) +} + +async fn webspace_try_cache_bytes_from_adapter( + data_dir: &Path, + registry: &ProviderRegistry, + object: &LibraryObject, +) -> anyhow::Result)>> { + if webspace_metadata_str(object, "cache_state") == Some("content_cached") + || webspace_metadata_str(object, "webspace_kind") == Some("materialized-file") + { + return Ok(None); + } + let Some(adapter) = webspace_adapter_target(registry, object).await? else { + return Ok(None); + }; + if !adapter.capabilities.contains("read_bytes") { + return Ok(None); + } + let response = registry + .invoke_provider(ProviderInvocation { + source: "webspace-provider".to_string(), + target: adapter.provider.clone(), + op: "read_bytes".to_string(), + request: json!({ + "op": "read_bytes", + "schema": "elastos.webspace.adapter.read-bytes-request/v1", + "mount": adapter.mount.clone(), + "resolver": adapter.resolver.clone(), + "handle_uri": object.uri, + "target_uri": adapter.target_uri.clone(), + }), + transfer: ProviderTransfer::Bytes, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await + .map_err(|err| anyhow!("WebSpace adapter read_bytes failed: {err}"))?; + let data = response_data_object(&response, "read_bytes")?; + if data.get("schema").and_then(Value::as_str) != Some("elastos.webspace.adapter.read-bytes/v1") + { + bail!("WebSpace adapter read_bytes response schema mismatch"); + } + let encoded = data + .get("data") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("WebSpace adapter read_bytes response missing data"))?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .context("WebSpace adapter read_bytes response has invalid base64 data")?; + let mime = data + .get("mime") + .and_then(Value::as_str) + .unwrap_or("application/octet-stream"); + let source_receipt = json!({ + "schema": "elastos.webspace.adapter-cache-source/v1", + "provider": adapter.provider.clone(), + "resolver": adapter.resolver.clone(), + "target_uri": adapter.target_uri.clone(), + "runtime_transfer": response.get("_runtime_transfer").cloned(), + "adapter_receipt": data.get("receipt").cloned(), + }); + webspace_provider_data( + registry, + json!({ + "op": "cache", + "path": object.uri, + "content": bytes, + "mime": mime, + "source_receipt": source_receipt, + "token": "", + }), + "cache", + ) + .await?; + let cached_object = webspace_stat_object(data_dir, registry, &object.uri).await?; + Ok(Some((cached_object, bytes))) +} + +async fn webspace_adapter_target( + registry: &ProviderRegistry, + object: &LibraryObject, +) -> anyhow::Result> { + let Some(mount) = webspace_metadata_string(object, "mount") else { + return Ok(None); + }; + let Some(resolver) = webspace_metadata_string(object, "resolver") else { + return Ok(None); + }; + if resolver == "builtin" { + return Ok(None); + } + let Some(target_uri) = webspace_metadata_string(object, "target_uri") else { + return Ok(None); + }; + let health = match webspace_provider_data( + registry, + json!({ + "op": "health", + "moniker": mount.clone(), + "token": "", + }), + "health", + ) + .await + { + Ok(health) => health, + Err(_) => return Ok(None), + }; + let adapter = health + .get("mounts") + .and_then(Value::as_array) + .into_iter() + .flatten() + .find(|mount_health| { + mount_health.get("moniker").and_then(Value::as_str) + == webspace_metadata_str(object, "mount") + }) + .and_then(|mount_health| mount_health.get("adapter")) + .and_then(Value::as_object); + let Some(adapter) = adapter else { + return Ok(None); + }; + if adapter.get("live").and_then(Value::as_bool) != Some(true) + || adapter.get("state").and_then(Value::as_str) != Some("connected") + { + return Ok(None); + } + let Some(provider) = adapter.get("provider").and_then(Value::as_str) else { + return Ok(None); + }; + let capabilities = adapter + .get("capabilities") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_string) + .collect::>(); + Ok(Some(WebSpaceAdapterTarget { + mount, + resolver, + target_uri, + provider: provider.to_string(), + capabilities, + })) +} + +fn response_data_object<'a>(response: &'a Value, op: &str) -> anyhow::Result<&'a Value> { + if response.get("status").and_then(Value::as_str) == Some("error") { + let message = response + .get("message") + .and_then(Value::as_str) + .unwrap_or("provider request failed"); + bail!("WebSpace adapter {op} failed: {message}"); + } + response + .get("data") + .ok_or_else(|| anyhow!("WebSpace adapter {op} response missing data")) +} + +fn webspace_metadata_string(object: &LibraryObject, key: &str) -> Option { + webspace_metadata_str(object, key).map(str::to_string) +} + +fn webspace_metadata_str<'a>(object: &'a LibraryObject, key: &str) -> Option<&'a str> { + object + .metadata + .as_ref() + .and_then(|metadata| metadata.get(key)) + .and_then(Value::as_str) +} + +fn webspace_metadata_bool(object: &LibraryObject, key: &str) -> Option { + object + .metadata + .as_ref() + .and_then(|metadata| metadata.get(key)) + .and_then(Value::as_bool) +} + +async fn webspace_read_provider_bytes( + registry: &ProviderRegistry, + uri: &str, +) -> anyhow::Result> { + let data = webspace_provider_data( + registry, + json!({ + "op": "read", + "path": uri, + "token": "", + "offset": null, + "length": null, + }), + "read", + ) + .await?; + data.get("content") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("webspace-provider read response missing content"))? + .iter() + .map(|value| { + value + .as_u64() + .filter(|byte| *byte <= u8::MAX as u64) + .map(|byte| byte as u8) + .ok_or_else(|| anyhow!("webspace-provider read response has invalid byte")) + }) + .collect() +} + +async fn webspace_write_bytes( + registry: &ProviderRegistry, + uri: &str, + bytes: &[u8], +) -> anyhow::Result { + webspace_provider_data( + registry, + json!({ + "op": "write", + "path": uri, + "token": "", + "content": bytes, + "append": false, + }), + "write", + ) + .await +} + +async fn webspace_mkdir(registry: &ProviderRegistry, uri: &str) -> anyhow::Result { + webspace_provider_data( + registry, + json!({ + "op": "mkdir", + "path": uri, + "token": "", + "parents": false, + }), + "mkdir", + ) + .await +} + +async fn webspace_delete_permanently( + registry: &ProviderRegistry, + uri: &str, +) -> anyhow::Result { + webspace_provider_data( + registry, + json!({ + "op": "delete", + "path": uri, + "token": "", + "recursive": true, + }), + "delete", + ) + .await +} + +fn webspace_entry_object( + data_dir: &Path, + parent_uri: &str, + entry: WebSpaceDirEntry, +) -> anyhow::Result { + let uri = child_uri(parent_uri, &entry.name)?; + webspace_stat_to_object( + data_dir, + &uri, + WebSpaceFileStat { + path: uri.clone(), + is_file: entry.is_file, + is_dir: entry.is_dir, + size: entry.size, + modified: None, + created: None, + target_uri: entry.target_uri, + resolver_state: entry.resolver_state, + resolver: entry.resolver, + cache_policy: entry.cache_policy, + sync_policy: entry.sync_policy, + webspace_kind: entry.webspace_kind, + object_id: entry.object_id, + head_id: entry.head_id, + cache_state: entry.cache_state, + sync_state: entry.sync_state, + provider: entry.provider, + readonly: entry.readonly, + access_policy: entry.access_policy, + }, + ) +} + +fn webspace_stat_to_object( + data_dir: &Path, + uri: &str, + stat: WebSpaceFileStat, +) -> anyhow::Result { + let uri = clean_webspace_uri(uri)?; + let is_dir = stat.is_dir && !stat.is_file; + let name = if uri == "localhost://WebSpaces" { + "Spaces".to_string() + } else { + uri.rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("Space") + .to_string() + }; + let revision = format!( + "rev:webspace:{}", + hex::encode(Sha256::digest(format!( + "{}:{}:{}", + stat.path, + stat.size, + stat.modified.unwrap_or(0) + ))) + ); + let metadata = webspace_object_metadata(&uri, &stat); + let availability = webspace_availability_label(&metadata); + let readonly = stat.readonly.unwrap_or(true); + let viewers = if is_dir { + Vec::new() + } else { + viewer_options_for_name(data_dir, &name) + }; + let viewer = viewers.first().map(|viewer| viewer.id.clone()); + let mime = if is_dir { + "inode/directory".to_string() + } else if stat.webspace_kind.as_deref() == Some("file-endpoint") { + "application/json".to_string() + } else { + mime_for_name(&name).to_string() + }; + let capabilities = if is_dir { + if readonly { + vec!["open", "list", "properties"] + } else { + vec!["open", "list", "new_folder", "write", "properties"] + } + } else if readonly { + vec!["open", "read", "download", "properties"] + } else { + vec![ + "open", + "read", + "download", + "write", + "delete_permanently", + "properties", + ] + }; + let content_cid = if is_dir { + None + } else { + webspace_content_cid_from_metadata(&metadata) + }; + Ok(LibraryObject { + schema: LIBRARY_OBJECT_SCHEMA, + uri, + name, + kind: if is_dir { "directory" } else { "file" }, + mime, + size: stat.size, + created_at: stat.created.unwrap_or(0), + modified_at: stat.modified.unwrap_or(0), + revision, + viewer, + viewers, + thumbnail_uri: None, + availability, + blocked_reason: None, + content_cid, + published_cid: None, + metadata: Some(metadata), + published: false, + shared: false, + capabilities, + }) +} + +fn localhost_space_pointer_object( + data_dir: &Path, + principal_id: &str, +) -> anyhow::Result { + let uri = crate::auth::principal_localhost_root(principal_id); + let target = library_target(data_dir, principal_id, &uri)?; + let metadata = fs::metadata(&target.path).ok(); + let modified_at = metadata + .as_ref() + .and_then(|metadata| system_time_secs(metadata.modified().ok())) + .unwrap_or_else(now_ts); + let created_at = metadata + .as_ref() + .and_then(|metadata| system_time_secs(metadata.created().ok())) + .unwrap_or(modified_at); + Ok(LibraryObject { + schema: LIBRARY_OBJECT_SCHEMA, + uri: uri.clone(), + name: "Localhost".to_string(), + kind: "directory", + mime: "inode/directory".to_string(), + size: 0, + created_at, + modified_at, + revision: format!("rev:space:{}", hex::encode(Sha256::digest(uri.as_bytes()))), + viewer: None, + viewers: Vec::new(), + thumbnail_uri: None, + availability: "local-principal".to_string(), + blocked_reason: None, + content_cid: None, + published_cid: None, + metadata: Some(json!({ + "schema": "elastos.library.space-pointer/v1", + "space": "localhost", + "label": "Localhost", + "target_uri": uri, + "provider": "object-provider", + "authority": "signed-principal-root", + "writable": true, + "note": "This opens the signed principal's mutable localhost object space. It is not a broad host filesystem grant." + })), + published: false, + shared: false, + capabilities: vec!["open", "list", "properties"], + }) +} + +fn sort_spaces_root_objects(objects: &mut [LibraryObject]) { + objects.sort_by(|left, right| { + spaces_root_rank(left) + .cmp(&spaces_root_rank(right)) + .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) + }); +} + +fn spaces_root_rank(object: &LibraryObject) -> u8 { + match object.name.as_str() { + "Localhost" => 0, + "Elastos" => 1, + _ => 2, + } +} + +fn webspace_content_cid_from_metadata(metadata: &Value) -> Option { + metadata + .get("target_uri") + .and_then(Value::as_str) + .and_then(|target| target.strip_prefix("elastos://")) + .map(str::trim) + .filter(|cid| cid::Cid::try_from(*cid).is_ok()) + .map(str::to_string) +} + +fn webspace_object_metadata(uri: &str, stat: &WebSpaceFileStat) -> Value { + let mount = uri + .strip_prefix("localhost://WebSpaces/") + .and_then(|rest| rest.split('/').next()) + .filter(|segment| !segment.is_empty()); + let target_uri = stat + .target_uri + .clone() + .or_else(|| inferred_webspace_target_uri(uri)); + let mut metadata = json!({ + "schema": "elastos.library.webspace-object/v1", + "handle_uri": uri, + "mount": mount, + "resolver_state": stat.resolver_state.as_deref().unwrap_or("resolved"), + "resolver": stat.resolver.as_deref().unwrap_or("builtin"), + "cache_policy": stat.cache_policy.as_deref().unwrap_or("metadata-only"), + "sync_policy": stat.sync_policy.as_deref().unwrap_or("manual"), + "cache_state": stat.cache_state.as_deref().unwrap_or("metadata_cached"), + "sync_state": stat.sync_state.as_deref().unwrap_or("manual_idle"), + "webspace_kind": stat.webspace_kind.as_deref().unwrap_or("webspace-object"), + "readonly": stat.readonly.unwrap_or(true), + "access_policy": stat.access_policy.as_deref().unwrap_or("resolver-readonly"), + "provider": stat.provider.as_deref().unwrap_or("webspace-provider"), + }); + if let Some(object_id) = stat.object_id.as_deref() { + metadata["object_id"] = Value::String(object_id.to_string()); + } + if let Some(head_id) = stat.head_id.as_deref() { + metadata["head_id"] = Value::String(head_id.to_string()); + } + if let Some(target_uri) = target_uri { + metadata["target_uri"] = Value::String(target_uri); + } + if let Some(hint) = webspace_availability_hint(uri, stat, metadata.get("target_uri")) { + metadata["availability_hint"] = hint; + } + metadata +} + +fn webspace_availability_hint( + uri: &str, + stat: &WebSpaceFileStat, + target_uri: Option<&Value>, +) -> Option { + if !stat.is_file { + return None; + } + let resolver = stat.resolver.as_deref().unwrap_or("builtin"); + if resolver == "builtin" || resolver == "local-materialized" { + return None; + } + let target_uri = target_uri.and_then(Value::as_str)?; + let webspace_kind = stat.webspace_kind.as_deref().unwrap_or("webspace-object"); + let cache_state = stat.cache_state.as_deref().unwrap_or("metadata_cached"); + let sync_state = stat.sync_state.as_deref().unwrap_or("manual_idle"); + let readonly = stat.readonly.unwrap_or(true); + let status = if !readonly && sync_state == "manual_synced" { + "resolver_synced" + } else if readonly && webspace_kind == "materialized-file" && cache_state == "content_cached" { + "resolver_cached" + } else { + return None; + }; + Some(json!({ + "schema": "elastos.webspace.availability-hint/v1", + "scope": "resolver", + "status": status, + "handle_uri": uri, + "target_uri": target_uri, + "resolver": resolver, + "cache_state": cache_state, + "sync_state": sync_state, + "not_content_availability": true, + "note": "This is a resolver/cache hint only. It is not a SmartWeb content availability receipt and does not prove CID replication." + })) +} + +fn webspace_availability_label(metadata: &Value) -> String { + match metadata + .get("availability_hint") + .and_then(|hint| hint.get("status")) + .and_then(Value::as_str) + { + Some("resolver_synced") => "resolver-synced", + Some("resolver_cached") => "resolver-cached", + _ => "resolver-owned", + } + .to_string() +} + +fn webspace_object_availability_hint(object: &LibraryObject) -> Option { + object + .metadata + .as_ref() + .and_then(|metadata| metadata.get("availability_hint")) + .cloned() +} + +fn inferred_webspace_target_uri(uri: &str) -> Option { + let cid = uri.strip_prefix("localhost://WebSpaces/Elastos/content/")?; + let cid = cid.split('/').next()?.trim(); + if cid.is_empty() { + None + } else { + Some(format!("elastos://{cid}")) + } +} + +fn library_target(data_dir: &Path, principal_id: &str, uri: &str) -> anyhow::Result { + let localhost_root = crate::auth::principal_localhost_root(principal_id); + let uri = clean_library_uri(&localhost_root, uri)?; + let rooted = uri + .strip_prefix("localhost://") + .ok_or_else(|| anyhow!("library object URI must be localhost://"))?; + let path = rooted_localhost_fs_path(data_dir, rooted) + .ok_or_else(|| anyhow!("invalid library object path"))?; + Ok(LibraryTarget { + localhost_root, + uri, + path, + }) +} + +fn clean_library_uri(localhost_root: &str, uri: &str) -> anyhow::Result { + let uri = uri.trim().trim_end_matches('/').to_string(); + if !uri.starts_with("localhost://") { + bail!("library object URI must be localhost://"); + } + let under_root = uri == localhost_root + || uri + .strip_prefix(localhost_root) + .is_some_and(|rest| rest.starts_with('/')); + if !under_root { + bail!("library object URI is outside the active principal root"); + } + if uri.split('/').any(|part| part == ".." || part == ".") { + bail!("library object URI must not contain traversal segments"); + } + Ok(uri) +} + +fn is_webspace_uri(uri: &str) -> bool { + uri == "localhost://WebSpaces" + || uri + .strip_prefix("localhost://WebSpaces/") + .is_some_and(|rest| !rest.is_empty()) +} + +fn clean_webspace_uri(uri: &str) -> anyhow::Result { + let uri = uri.trim().trim_end_matches('/').to_string(); + if !is_webspace_uri(&uri) { + bail!("Library Spaces URI must be under localhost://WebSpaces"); + } + if uri.split('/').any(|part| part == ".." || part == ".") { + bail!("Library Spaces URI must not contain traversal segments"); + } + Ok(uri) +} + +fn library_request_touches_webspace(request: &ObjectProviderRequest) -> bool { + fn any_webspace(values: &[&str]) -> bool { + values + .iter() + .any(|value| is_webspace_uri(value.trim_end_matches('/'))) + } + + match request { + ObjectProviderRequest::List { uri: Some(uri), .. } => any_webspace(&[uri]), + ObjectProviderRequest::Stat { uri, .. } + | ObjectProviderRequest::Read { uri, .. } + | ObjectProviderRequest::Download { uri, .. } + | ObjectProviderRequest::ExtractArchive { uri, .. } + | ObjectProviderRequest::ArchiveEntries { uri, .. } + | ObjectProviderRequest::ArchivePreviewEntry { uri, .. } + | ObjectProviderRequest::Write { uri, .. } + | ObjectProviderRequest::Rename { uri, .. } + | ObjectProviderRequest::Trash { uri, .. } + | ObjectProviderRequest::DeletePermanently { uri, .. } + | ObjectProviderRequest::Status { uri, .. } + | ObjectProviderRequest::Sync { uri, .. } + | ObjectProviderRequest::Publish { uri, .. } + | ObjectProviderRequest::Unpublish { uri, .. } + | ObjectProviderRequest::Repair { uri, .. } + | ObjectProviderRequest::Share { uri, .. } + | ObjectProviderRequest::SharedAccess { uri, .. } => any_webspace(&[uri]), + ObjectProviderRequest::CompressArchive { uri, uris, .. } => { + uri.as_deref().is_some_and(|uri| any_webspace(&[uri])) + || uris + .iter() + .any(|uri| is_webspace_uri(uri.trim_end_matches('/'))) + } + ObjectProviderRequest::ArchiveExtractEntries { + uri, + destination_uri, + .. + } => any_webspace(&[uri, destination_uri]), + ObjectProviderRequest::Mkdir { parent_uri, .. } => any_webspace(&[parent_uri]), + ObjectProviderRequest::Move { + uri, + target_parent_uri, + .. + } + | ObjectProviderRequest::Copy { + uri, + target_parent_uri, + .. + } => any_webspace(&[uri, target_parent_uri]), + ObjectProviderRequest::Restore { + uri, target_uri, .. + } => target_uri + .as_deref() + .map(|target_uri| any_webspace(&[uri, target_uri])) + .unwrap_or_else(|| any_webspace(&[uri])), + ObjectProviderRequest::Roots { .. } + | ObjectProviderRequest::List { uri: None, .. } + | ObjectProviderRequest::EmptyTrash { .. } + | ObjectProviderRequest::Events { .. } => false, + } +} + +fn library_object(data_dir: &Path, principal_id: &str, uri: &str) -> anyhow::Result { + let target = library_target(data_dir, principal_id, uri)?; + let metadata = fs::metadata(&target.path)?; + let is_dir = metadata.is_dir(); + let name = if target.uri == target.localhost_root { + "Home".to_string() + } else { + target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("object") + .to_string() + }; + let modified_at = system_time_secs(metadata.modified().ok()).unwrap_or_else(now_ts); + let created_at = system_time_secs(metadata.created().ok()).unwrap_or(modified_at); + let mut blocked_reason = None; + let (size, revision, content_cid) = if is_dir { + (0, directory_revision(&target.path, &target.uri)?, None) + } else { + match read_library_file_bytes(data_dir, principal_id, &target) { + Ok(bytes) => { + let revision = format!("rev:{}", hex::encode(Sha256::digest(&bytes))); + let content_cid = raw_sha256_cid(&bytes)?; + (bytes.len() as u64, revision, Some(content_cid)) + } + Err(err) if is_unencrypted_principal_root_object(&err) => { + blocked_reason = Some("protected_principal_root_object_not_encrypted".to_string()); + let revision_input = format!("{}:{}:{}", target.uri, metadata.len(), modified_at); + ( + metadata.len(), + format!( + "rev:blocked:{}", + hex::encode(Sha256::digest(revision_input)) + ), + None, + ) + } + Err(err) => return Err(err), + } + }; + let record = read_publish_record(data_dir, principal_id, &target.uri).ok(); + let active_record = record.as_ref().filter(|record| record_is_published(record)); + let published_cid = active_record.map(|record| record.cid.clone()); + let is_trash_root = is_trash_root_uri(&target.localhost_root, &target.uri); + let in_trash = is_trash_uri(&target.localhost_root, &target.uri); + let visibility = library_visibility_metadata( + &target.localhost_root, + &target.uri, + is_dir, + blocked_reason.as_deref(), + active_record, + ); + let mut capabilities = if is_dir { + let mut capabilities = vec![ + "open", + "list", + "rename", + "move", + "copy", + "trash", + "properties", + ]; + if target.uri != target.localhost_root + && !is_runtime_private_uri(&target.localhost_root, &target.uri) + { + capabilities.push("download"); + capabilities.push("compress_archive"); + } + capabilities + } else { + let mut capabilities = vec![ + "open", + "read", + "download", + "rename", + "move", + "copy", + "publish", + "trash", + "properties", + ]; + if !is_runtime_private_uri(&target.localhost_root, &target.uri) { + capabilities.push("compress_archive"); + } + if active_record.is_some() { + capabilities.push("unpublish"); + capabilities.push("repair"); + capabilities.push("share"); + } + if is_extractable_archive_name(&name) { + capabilities.push("extract_archive"); + } + capabilities + }; + if is_trash_root { + capabilities = vec!["open", "list", "empty_trash", "properties"]; + } else if in_trash { + capabilities = vec!["restore", "delete_permanently", "properties"]; + } + if blocked_reason.is_some() { + capabilities = vec!["properties"]; + } + let mut local_metadata = if is_dir { + json!({ + "schema": "elastos.library.object-metadata/v1", + "visibility": visibility, + }) + } else { + let mut metadata = json!({ + "schema": "elastos.library.object-metadata/v1", + "visibility": visibility, + "content_identity": { + "schema": "elastos.library.content-identity/v1", + "current_cid": content_cid.clone(), + "scope": "local-object-head", + "published_cid": published_cid.clone(), + "note": "The current CID is the immutable raw-byte CID for this mutable object head. The published CID is set only after content-provider publish." + } + }); + if let Some(archive_support) = archive_support_for_name(&name) { + metadata["archive_support"] = archive_support; + } + metadata + }; + if in_trash && !is_trash_root { + if let Ok(record) = read_trash_record(data_dir, principal_id, &target.uri) { + local_metadata["trash"] = json!({ + "schema": LIBRARY_TRASH_RECORD_SCHEMA, + "trash_uri": record.trash_uri, + "original_uri": record.original_uri, + "original_name": record.original_name, + "trashed_at": record.trashed_at, + }); + } + } + let local_metadata = Some(local_metadata); + let viewers = viewer_options_for_name(data_dir, uri); + let viewer = viewers.first().map(|viewer| viewer.id.clone()); + let availability = if blocked_reason.is_some() { + "blocked".to_string() + } else { + record_availability_label(record.as_ref()) + }; + Ok(LibraryObject { + schema: LIBRARY_OBJECT_SCHEMA, + uri: target.uri, + name, + kind: if is_dir { "directory" } else { "file" }, + mime: if is_dir { + "inode/directory".to_string() + } else { + mime_for_name(uri).to_string() + }, + size, + created_at, + modified_at, + revision, + viewer, + viewers, + thumbnail_uri: None, + availability, + blocked_reason, + content_cid, + published_cid, + metadata: local_metadata, + published: active_record.is_some(), + shared: active_record.is_some_and(|record| record.shared_at.is_some()), + capabilities, + }) +} + +fn library_visibility_metadata( + localhost_root: &str, + uri: &str, + is_dir: bool, + blocked_reason: Option<&str>, + active_record: Option<&LibraryPublishRecord>, +) -> Value { + let placement = if is_trash_uri(localhost_root, uri) { + "trash" + } else if is_runtime_private_uri(localhost_root, uri) { + "runtime_private" + } else if is_public_uri(localhost_root, uri) { + "public_folder" + } else { + "private_folder" + }; + let share_policy = active_record + .and_then(|record| record.share_policy.as_deref()) + .unwrap_or("not_shared"); + let effective_access = if blocked_reason.is_some() { + "blocked" + } else if active_record.is_some_and(|record| record.shared_at.is_some()) { + match share_policy { + "recipient_scoped" => "recipient_scoped_link", + _ => "public_content_link", + } + } else if active_record.is_some() { + "public_content_link" + } else { + "principal_private" + }; + let published_cid = active_record.map(|record| record.cid.clone()); + + json!({ + "schema": LIBRARY_VISIBILITY_SCHEMA, + "placement": placement, + "placement_label": match placement { + "public_folder" => "Public folder", + "trash" => "Trash", + "runtime_private" => "Runtime private area", + _ => "Private folder", + }, + "effective_access": effective_access, + "published": active_record.is_some(), + "published_cid": published_cid.clone(), + "published_link": published_cid.map(|cid| format!("elastos://{cid}")), + "shared": active_record.is_some_and(|record| record.shared_at.is_some()), + "share_policy": share_policy, + "public_folder_policy": "placement_only", + "publish_required_for_public_link": !is_dir && active_record.is_none(), + "note": "Public folder placement is a user-facing Library projection. Public network access requires an explicit content-provider publish receipt." + }) +} + +fn is_public_uri(localhost_root: &str, uri: &str) -> bool { + let public_root = format!("{}/Public", localhost_root.trim_end_matches('/')); + uri == public_root || uri.starts_with(&format!("{public_root}/")) +} + +fn raw_sha256_cid(bytes: &[u8]) -> anyhow::Result { + let digest = Sha256::digest(bytes); + let multihash = cid::multihash::Multihash::<64>::wrap(0x12, &digest) + .map_err(|err| anyhow!("failed to build raw content CID: {err}"))?; + Ok(cid::Cid::new_v1(0x55, multihash).to_string()) +} + +fn is_unencrypted_principal_root_object(err: &anyhow::Error) -> bool { + err.chain().any(|cause| { + cause + .to_string() + .contains(crate::auth::PROTECTED_PRINCIPAL_ROOT_OBJECT_NOT_ENCRYPTED) + }) +} + +fn read_library_file_bytes( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, +) -> anyhow::Result> { + match crate::auth::read_principal_root_object( + data_dir, + principal_id, + &target.localhost_root, + &target.uri, + &target.path, + ) { + Ok(bytes) => Ok(bytes), + Err(err) if is_unencrypted_principal_root_object(&err) => { + protect_legacy_plaintext_library_file(data_dir, principal_id, target) + } + Err(err) => Err(err), + } +} + +fn write_library_file_bytes( + data_dir: &Path, + principal_id: &str, + uri: &str, + _mime: Option<&str>, + if_revision: Option<&str>, + bytes: &[u8], +) -> anyhow::Result { + let target = library_target(data_dir, principal_id, uri)?; + if is_trash_uri(&target.localhost_root, &target.uri) { + bail!("library Trash accepts objects only through delete"); + } + check_revision(data_dir, principal_id, &target.uri, if_revision)?; + if let Some(parent) = target.path.parent() { + fs::create_dir_all(parent)?; + } + crate::auth::write_principal_root_object( + data_dir, + principal_id, + &target.localhost_root, + &target.uri, + &target.path, + bytes, + )?; + let object = library_object(data_dir, principal_id, &target.uri)?; + append_library_event( + data_dir, + principal_id, + "write", + &target.uri, + json!({ + "object": object.clone(), + }), + )?; + Ok(object) +} + +fn library_download_object( + data_dir: &Path, + principal_id: &str, + uri: &str, + archive_format: LibraryArchiveFormat, +) -> anyhow::Result<(LibraryObject, String, Vec)> { + let target = library_target(data_dir, principal_id, uri)?; + let object = library_object(data_dir, principal_id, &target.uri)?; + if target.path.is_file() { + let bytes = read_library_file_bytes(data_dir, principal_id, &target)?; + let filename = object.name.clone(); + return Ok((object, filename, bytes)); + } + if !target.path.is_dir() { + bail!("library download target must be a file or directory"); + } + let bytes = archive_library_directory(data_dir, principal_id, &target, archive_format)?; + let filename = format!( + "{}.{}", + safe_archive_name(&object.name), + archive_format.extension() + ); + let mut archive_object = object; + archive_object.mime = archive_format.mime().to_string(); + archive_object.size = bytes.len() as u64; + Ok((archive_object, filename, bytes)) +} + +fn compress_library_archive( + data_dir: &Path, + principal_id: &str, + uri: Option<&str>, + uris: &[String], + if_revision: Option<&str>, +) -> anyhow::Result { + if !uris.is_empty() { + if uri.is_some() { + bail!("library compress_archive accepts either uri or uris, not both"); + } + if if_revision.is_some() { + bail!("library selected compress_archive does not accept if_revision"); + } + let (parent_uri, targets) = + library_selection_archive_targets(data_dir, principal_id, uris)?; + let bytes = archive_library_selection_zip(data_dir, principal_id, &targets)?; + let filename = library_selection_archive_filename(&parent_uri, LibraryArchiveFormat::Zip); + let archive_uri = unique_child_uri(data_dir, principal_id, &parent_uri, &filename)?; + let object = write_library_file_bytes( + data_dir, + principal_id, + &archive_uri, + Some(LibraryArchiveFormat::Zip.mime()), + None, + &bytes, + )?; + append_library_event( + data_dir, + principal_id, + "compress_archive", + &archive_uri, + json!({ + "uris": targets.iter().map(|target| target.uri.clone()).collect::>(), + "object": object.clone(), + }), + )?; + return Ok(object); + } + + let uri = uri.ok_or_else(|| anyhow!("library compress_archive requires uri or uris"))?; + let target = library_target(data_dir, principal_id, uri)?; + check_revision(data_dir, principal_id, &target.uri, if_revision)?; + let parent_uri = target + .uri + .rsplit_once('/') + .map(|(parent, _)| parent.to_string()) + .ok_or_else(|| anyhow!("library compress_archive target has no parent"))?; + let name = target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("Library"); + let filename = format!("{}.zip", safe_archive_name(name)); + let bytes = archive_library_single_zip(data_dir, principal_id, &target)?; + let archive_uri = unique_child_uri(data_dir, principal_id, &parent_uri, &filename)?; + let object = write_library_file_bytes( + data_dir, + principal_id, + &archive_uri, + Some(LibraryArchiveFormat::Zip.mime()), + None, + &bytes, + )?; + append_library_event( + data_dir, + principal_id, + "compress_archive", + &archive_uri, + json!({ + "source_uri": target.uri, + "object": object.clone(), + }), + )?; + Ok(object) +} + +fn protect_legacy_plaintext_library_file( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, +) -> anyhow::Result> { + let bytes = fs::read(&target.path).with_context(|| { + format!( + "failed to read legacy plaintext library object {:?}", + target.path + ) + })?; + crate::auth::write_principal_root_object( + data_dir, + principal_id, + &target.localhost_root, + &target.uri, + &target.path, + &bytes, + )?; + Ok(bytes) +} + +fn archive_library_directory( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, + archive_format: LibraryArchiveFormat, +) -> anyhow::Result> { + if target.uri == target.localhost_root { + bail!("library root download is not supported; use Recovery Kit for full backups"); + } + if is_runtime_private_uri(&target.localhost_root, &target.uri) { + bail!("runtime-private Library folders cannot be downloaded"); + } + match archive_format { + LibraryArchiveFormat::TarGz => { + archive_library_directory_tar_gz(data_dir, principal_id, target) + } + LibraryArchiveFormat::Zip => archive_library_directory_zip(data_dir, principal_id, target), + } +} + +fn archive_library_directory_tar_gz( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, +) -> anyhow::Result> { + let encoder = GzEncoder::new(Vec::new(), Compression::default()); + let mut builder = tar::Builder::new(encoder); + let archive_root = PathBuf::from(safe_archive_name( + target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("Library"), + )); + append_library_archive_entry(&mut builder, data_dir, principal_id, target, &archive_root)?; + let encoder = builder.into_inner()?; + let bytes = encoder.finish()?; + Ok(bytes) +} + +fn archive_library_directory_zip( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, +) -> anyhow::Result> { + let mut writer = ZipWriter::new(Cursor::new(Vec::new())); + let archive_root = PathBuf::from(safe_archive_name( + target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("Library"), + )); + append_library_zip_entry(&mut writer, data_dir, principal_id, target, &archive_root)?; + Ok(writer.finish()?.into_inner()) +} + +fn archive_library_selection( + data_dir: &Path, + principal_id: &str, + uris: &[String], + archive_format: LibraryArchiveFormat, +) -> anyhow::Result<(String, Vec)> { + let (parent_uri, targets) = library_selection_archive_targets(data_dir, principal_id, uris)?; + let bytes = match archive_format { + LibraryArchiveFormat::TarGz => { + archive_library_selection_tar_gz(data_dir, principal_id, &targets)? + } + LibraryArchiveFormat::Zip => { + archive_library_selection_zip(data_dir, principal_id, &targets)? + } + }; + Ok(( + library_selection_archive_filename(&parent_uri, archive_format), + bytes, + )) +} + +fn library_selection_archive_targets( + data_dir: &Path, + principal_id: &str, + uris: &[String], +) -> anyhow::Result<(String, Vec)> { + if uris.len() < 2 { + bail!("library selected archive requires at least two objects"); + } + let mut seen = BTreeSet::new(); + let mut parent_uri: Option = None; + let mut targets = Vec::new(); + for uri in uris { + let target = library_target(data_dir, principal_id, uri)?; + if !seen.insert(target.uri.clone()) { + continue; + } + if target.uri == target.localhost_root { + bail!("library root download is not supported; use Recovery Kit for full backups"); + } + if is_runtime_private_uri(&target.localhost_root, &target.uri) { + bail!("runtime-private Library folders cannot be downloaded"); + } + if !target.path.is_file() && !target.path.is_dir() { + bail!("library selected archive entries must be files or directories"); + } + let parent = target + .uri + .rsplit_once('/') + .map(|(parent, _)| parent.to_string()) + .ok_or_else(|| anyhow!("library selected archive entry has no parent"))?; + if parent_uri + .as_deref() + .is_some_and(|expected| expected != parent) + { + bail!("library selected archive entries must share one parent folder"); + } + parent_uri = Some(parent); + targets.push(target); + } + if targets.len() < 2 { + bail!("library selected archive requires at least two unique objects"); + } + let parent_uri = parent_uri.ok_or_else(|| anyhow!("library selected archive has no parent"))?; + Ok((parent_uri, targets)) +} + +fn library_selection_archive_filename( + parent_uri: &str, + archive_format: LibraryArchiveFormat, +) -> String { + let parent_name = parent_uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("Library"); + format!( + "{} Selection.{}", + safe_archive_name(parent_name), + archive_format.extension() + ) +} + +fn archive_library_selection_tar_gz( + data_dir: &Path, + principal_id: &str, + targets: &[LibraryTarget], +) -> anyhow::Result> { + let encoder = GzEncoder::new(Vec::new(), Compression::default()); + let mut builder = tar::Builder::new(encoder); + for target in targets { + let name = target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("Library"); + append_library_archive_entry( + &mut builder, + data_dir, + principal_id, + target, + &PathBuf::from(safe_archive_name(name)), + )?; + } + let encoder = builder.into_inner()?; + Ok(encoder.finish()?) +} + +fn archive_library_selection_zip( + data_dir: &Path, + principal_id: &str, + targets: &[LibraryTarget], +) -> anyhow::Result> { + let mut writer = ZipWriter::new(Cursor::new(Vec::new())); + for target in targets { + let name = target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("Library"); + append_library_zip_entry( + &mut writer, + data_dir, + principal_id, + target, + &PathBuf::from(safe_archive_name(name)), + )?; + } + Ok(writer.finish()?.into_inner()) +} + +fn archive_library_single_zip( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, +) -> anyhow::Result> { + if target.uri == target.localhost_root { + bail!("library root compress is not supported; use Recovery Kit for full backups"); + } + if is_runtime_private_uri(&target.localhost_root, &target.uri) { + bail!("runtime-private Library folders cannot be compressed"); + } + if !target.path.is_file() && !target.path.is_dir() { + bail!("library compress_archive target must be a file or directory"); + } + let mut writer = ZipWriter::new(Cursor::new(Vec::new())); + let name = target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("Library"); + append_library_zip_entry( + &mut writer, + data_dir, + principal_id, + target, + &PathBuf::from(safe_archive_name(name)), + )?; + Ok(writer.finish()?.into_inner()) +} + +fn library_archive_entries( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, +) -> anyhow::Result { + if !target.path.is_file() { + bail!("library archive listing target must be a file"); + } + if is_runtime_private_uri(&target.localhost_root, &target.uri) { + bail!("runtime-private Library archives cannot be listed"); + } + let name = library_archive_name(target, "listing")?; + if !is_extractable_archive_name(&name) { + bail!("library archive listing only supports .tar, .tar.gz, .tgz, and .zip archives"); + } + let bytes = read_library_file_bytes(data_dir, principal_id, target)?; + archive_entries_for_object( + library_object(data_dir, principal_id, &target.uri)?, + &target.uri, + &name, + bytes, + ) +} + +fn archive_preview_entry( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, + entry: &str, +) -> anyhow::Result { + if !target.path.is_file() { + bail!("library archive preview target must be a file"); + } + if is_runtime_private_uri(&target.localhost_root, &target.uri) { + bail!("runtime-private Library archives cannot be previewed"); + } + let name = library_archive_name(target, "preview")?; + if !is_extractable_archive_name(&name) { + bail!("library archive preview only supports .tar, .tar.gz, .tgz, and .zip archives"); + } + let bytes = read_library_file_bytes(data_dir, principal_id, target)?; + archive_preview_entry_for_object( + data_dir, + library_object(data_dir, principal_id, &target.uri)?, + &target.uri, + &name, + bytes, + entry, + ) +} + +fn archive_entries_for_object( + object: LibraryObject, + uri: &str, + archive_name: &str, + bytes: Vec, +) -> anyhow::Result { + if !is_extractable_archive_name(archive_name) { + bail!("library archive listing only supports .tar, .tar.gz, .tgz, and .zip archives"); + } + let lower_name = archive_name.to_ascii_lowercase(); + let (entries, truncated) = if lower_name.ends_with(".zip") { + list_zip_archive_entries(bytes)? + } else { + list_tar_archive_entries(archive_name, bytes)? + }; + let returned_entries = entries.len(); + Ok(json!({ + "schema": LIBRARY_ARCHIVE_ENTRIES_SCHEMA, + "object": object, + "uri": uri, + "family": archive_family_for_name(archive_name).unwrap_or("archive"), + "entries": entries, + "limits": { + "max_entries": MAX_ARCHIVE_LIST_ENTRIES, + "returned_entries": returned_entries, + "truncated": truncated, + }, + })) +} + +fn archive_preview_entry_for_object( + data_dir: &Path, + object: LibraryObject, + uri: &str, + archive_name: &str, + bytes: Vec, + entry: &str, +) -> anyhow::Result { + if !is_extractable_archive_name(archive_name) { + bail!("library archive preview only supports .tar, .tar.gz, .tgz, and .zip archives"); + } + let normalized = normalized_archive_entry_path(Path::new(entry))?; + let preview = if archive_name.to_ascii_lowercase().ends_with(".zip") { + preview_zip_archive_entry(bytes, &normalized)? + } else { + preview_tar_archive_entry(archive_name, bytes, &normalized)? + }; + let mime = mime_for_name(&normalized); + let text = if is_archive_preview_text_mime(mime) { + Some(String::from_utf8_lossy(&preview.bytes).to_string()) + } else { + None + }; + let entry_name = normalized + .rsplit('/') + .next() + .unwrap_or(normalized.as_str()) + .to_string(); + let viewers = viewer_options_for_name(data_dir, &normalized); + Ok(json!({ + "schema": LIBRARY_ARCHIVE_PREVIEW_ENTRY_SCHEMA, + "object": object, + "uri": uri, + "family": archive_family_for_name(archive_name).unwrap_or("archive"), + "entry": { + "path": normalized, + "name": entry_name, + "kind": "file", + "size": preview.size, + "compressed_size": preview.compressed_size, + "modified_at": preview.modified_at, + "mime": mime, + "safety": { + "status": "safe", + "reason": Value::Null, + }, + "viewers": viewers, + }, + "preview": { + "encoding": "base64", + "data": base64::engine::general_purpose::STANDARD.encode(&preview.bytes), + "text": text, + "truncated": preview.truncated, + "max_bytes": MAX_ARCHIVE_PREVIEW_BYTES, + "mode": "provider_bounded_safe_entry_preview", + }, + })) +} + +struct ArchiveEntryPreview { + bytes: Vec, + size: Option, + compressed_size: Option, + modified_at: Option, + truncated: bool, +} + +fn preview_zip_archive_entry( + bytes: Vec, + selected: &str, +) -> anyhow::Result { + let mut archive = + ZipArchive::new(Cursor::new(bytes)).map_err(|err| anyhow!("invalid ZIP archive: {err}"))?; + for index in 0..archive.len() { + let mut file = archive + .by_index(index) + .map_err(|err| anyhow!("invalid ZIP archive entry: {err}"))?; + let raw_name = file.name().to_string(); + let Ok(normalized) = normalized_archive_entry_path(Path::new(&raw_name)) else { + continue; + }; + if normalized != selected { + continue; + } + if file.is_dir() || !file.is_file() { + bail!("library archive preview only supports safe file entries"); + } + let declared_size = file.size(); + let mut preview_bytes = Vec::new(); + file.by_ref() + .take(MAX_ARCHIVE_PREVIEW_BYTES as u64 + 1) + .read_to_end(&mut preview_bytes)?; + let truncated = preview_bytes.len() > MAX_ARCHIVE_PREVIEW_BYTES + || declared_size > MAX_ARCHIVE_PREVIEW_BYTES as u64; + preview_bytes.truncate(MAX_ARCHIVE_PREVIEW_BYTES); + return Ok(ArchiveEntryPreview { + bytes: preview_bytes, + size: Some(declared_size), + compressed_size: Some(file.compressed_size()), + modified_at: None, + truncated, + }); + } + bail!("library archive preview entry not found"); +} + +fn preview_tar_archive_entry( + name: &str, + bytes: Vec, + selected: &str, +) -> anyhow::Result { + let reader: Box = if name.to_ascii_lowercase().ends_with(".tar") { + Box::new(Cursor::new(bytes)) + } else { + Box::new(GzDecoder::new(Cursor::new(bytes))) + }; + let mut archive = tar::Archive::new(reader); + for entry in archive.entries()? { + let mut entry = entry?; + let path = match entry.path() { + Ok(path) => path.to_path_buf(), + Err(_) => continue, + }; + let Ok(normalized) = normalized_archive_entry_path(&path) else { + continue; + }; + if normalized != selected { + continue; + } + let entry_type = entry.header().entry_type(); + if entry_type.is_dir() || !entry_type.is_file() { + bail!("library archive preview only supports safe file entries"); + } + let declared_size = entry.header().size().ok(); + let modified_at = entry.header().mtime().ok(); + let mut preview_bytes = Vec::new(); + entry + .by_ref() + .take(MAX_ARCHIVE_PREVIEW_BYTES as u64 + 1) + .read_to_end(&mut preview_bytes)?; + let truncated = preview_bytes.len() > MAX_ARCHIVE_PREVIEW_BYTES + || declared_size.is_some_and(|size| size > MAX_ARCHIVE_PREVIEW_BYTES as u64); + preview_bytes.truncate(MAX_ARCHIVE_PREVIEW_BYTES); + return Ok(ArchiveEntryPreview { + bytes: preview_bytes, + size: declared_size, + compressed_size: None, + modified_at, + truncated, + }); + } + bail!("library archive preview entry not found"); +} + +fn is_archive_preview_text_mime(mime: &str) -> bool { + mime == "text/plain" || mime == "application/json" || mime == "text/html" +} + +fn library_archive_name(target: &LibraryTarget, action: &str) -> anyhow::Result { + target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .map(str::to_string) + .ok_or_else(|| anyhow!("library archive {action} target has no name")) +} + +fn check_object_revision(object: &LibraryObject, if_revision: Option<&str>) -> anyhow::Result<()> { + if let Some(expected) = if_revision { + if expected != object.revision { + bail!("library object revision mismatch"); + } + } + Ok(()) +} + +fn webspace_archive_object(mut object: LibraryObject) -> LibraryObject { + if let Some(metadata) = object.metadata.as_mut() { + redact_resolver_private_fields(metadata); + if let Some(map) = metadata.as_object_mut() { + map.insert("resolver_target_redacted".to_string(), Value::Bool(true)); + } + } + object +} + +fn redact_resolver_private_fields(value: &mut Value) { + match value { + Value::Object(map) => { + map.remove("target_uri"); + map.remove("provider_credentials"); + map.remove("endpoint_credentials"); + map.remove("credentials"); + for child in map.values_mut() { + redact_resolver_private_fields(child); + } + } + Value::Array(values) => { + for child in values { + redact_resolver_private_fields(child); + } + } + _ => {} + } +} + +fn redacted_resolver_private_value(mut value: Value) -> Value { + redact_resolver_private_fields(&mut value); + value +} + +fn list_tar_archive_entries(name: &str, bytes: Vec) -> anyhow::Result<(Vec, bool)> { + let reader: Box = if name.to_ascii_lowercase().ends_with(".tar") { + Box::new(Cursor::new(bytes)) + } else { + Box::new(GzDecoder::new(Cursor::new(bytes))) + }; + let mut archive = tar::Archive::new(reader); + let mut entries = Vec::new(); + let mut truncated = false; + for (index, entry) in archive.entries()?.enumerate() { + if entries.len() >= MAX_ARCHIVE_LIST_ENTRIES { + truncated = true; + break; + } + let entry = entry?; + let entry_type = entry.header().entry_type(); + let size = entry.header().size().ok(); + let modified_at = entry.header().mtime().ok(); + let path = match entry.path() { + Ok(path) => path.to_path_buf(), + Err(err) => { + entries.push(blocked_archive_entry_listing( + index, + format!("entry-{index}"), + None, + None, + modified_at, + format!("invalid archive entry path: {err}"), + )); + continue; + } + }; + let display_path = archive_entry_display_path(&path, index); + let normalized = match normalized_archive_entry_path(&path) { + Ok(path) => path, + Err(err) => { + entries.push(blocked_archive_entry_listing( + index, + display_path, + size, + None, + modified_at, + err.to_string(), + )); + continue; + } + }; + let kind = if entry_type.is_dir() { + "directory" + } else if entry_type.is_file() { + "file" + } else { + entries.push(blocked_archive_entry_listing( + index, + normalized, + size, + None, + modified_at, + "library archive listing rejects non-file archive entries".to_string(), + )); + continue; + }; + entries.push(safe_archive_entry_listing( + index, + normalized, + kind, + size, + None, + modified_at, + )); + } + Ok((entries, truncated)) +} + +fn list_zip_archive_entries(bytes: Vec) -> anyhow::Result<(Vec, bool)> { + let mut archive = + ZipArchive::new(Cursor::new(bytes)).map_err(|err| anyhow!("invalid ZIP archive: {err}"))?; + let mut entries = Vec::new(); + let truncated = archive.len() > MAX_ARCHIVE_LIST_ENTRIES; + for index in 0..archive.len().min(MAX_ARCHIVE_LIST_ENTRIES) { + let file = archive + .by_index(index) + .map_err(|err| anyhow!("invalid ZIP archive entry: {err}"))?; + let raw_name = file.name().to_string(); + let path = Path::new(&raw_name); + let normalized = match normalized_archive_entry_path(path) { + Ok(path) => path, + Err(err) => { + entries.push(blocked_archive_entry_listing( + index, + archive_entry_display_name(&raw_name, index), + Some(file.size()), + Some(file.compressed_size()), + None, + err.to_string(), + )); + continue; + } + }; + let kind = if file.is_dir() { + "directory" + } else if file.is_file() { + "file" + } else { + entries.push(blocked_archive_entry_listing( + index, + normalized, + Some(file.size()), + Some(file.compressed_size()), + None, + "library archive listing rejects non-file archive entries".to_string(), + )); + continue; + }; + entries.push(safe_archive_entry_listing( + index, + normalized, + kind, + Some(file.size()), + Some(file.compressed_size()), + None, + )); + } + Ok((entries, truncated)) +} + +fn normalized_archive_entry_path(path: &Path) -> anyhow::Result { + let mut parts = Vec::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(name) => { + let name = name + .to_str() + .ok_or_else(|| anyhow!("library archive entry path must be UTF-8"))?; + if name.is_empty() + || name.contains('/') + || name.contains('\\') + || name.contains('\0') + || name == "." + || name == ".." + { + bail!("library archive entry path must be relative and safe"); + } + parts.push(name); + } + _ => bail!("library archive entry path must be relative and safe"), + } + } + if parts.is_empty() { + bail!("library archive entry path must not be empty"); + } + Ok(parts.join("/")) +} + +fn archive_entry_display_path(path: &Path, index: usize) -> String { + let display = path.to_string_lossy().trim().to_string(); + if display.is_empty() { + format!("entry-{index}") + } else { + display + } +} + +fn archive_entry_display_name(name: &str, index: usize) -> String { + let display = name.trim(); + if display.is_empty() { + format!("entry-{index}") + } else { + display.to_string() + } +} + +struct ArchiveEntryListing { + index: usize, + path: String, + kind: &'static str, + size: Option, + compressed_size: Option, + modified_at: Option, + safety_status: &'static str, + safety_reason: Option, +} + +fn safe_archive_entry_listing( + index: usize, + path: String, + kind: &'static str, + size: Option, + compressed_size: Option, + modified_at: Option, +) -> Value { + archive_entry_listing(ArchiveEntryListing { + index, + path, + kind, + size, + compressed_size, + modified_at, + safety_status: "safe", + safety_reason: None, + }) +} + +fn blocked_archive_entry_listing( + index: usize, + path: String, + size: Option, + compressed_size: Option, + modified_at: Option, + safety_reason: String, +) -> Value { + archive_entry_listing(ArchiveEntryListing { + index, + path, + kind: "blocked", + size, + compressed_size, + modified_at, + safety_status: "blocked", + safety_reason: Some(safety_reason), + }) +} + +fn archive_entry_listing(entry: ArchiveEntryListing) -> Value { + let name = entry + .path + .rsplit('/') + .next() + .unwrap_or(&entry.path) + .to_string(); + json!({ + "id": format!("entry:{}", entry.index), + "path": entry.path, + "name": name, + "kind": entry.kind, + "size": entry.size, + "compressed_size": entry.compressed_size, + "modified_at": entry.modified_at, + "safety": { + "status": entry.safety_status, + "reason": entry.safety_reason, + }, + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ArchiveConflictPolicy { + KeepBoth, + Replace, + Skip, +} + +impl ArchiveConflictPolicy { + fn parse(value: Option<&str>) -> anyhow::Result { + match value + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("keep_both") + .to_ascii_lowercase() + .replace(' ', "_") + .as_str() + { + "keep_both" => Ok(Self::KeepBoth), + "replace" => Ok(Self::Replace), + "skip" => Ok(Self::Skip), + other => bail!("unsupported archive conflict policy: {other}"), + } + } + + fn as_str(self) -> &'static str { + match self { + Self::KeepBoth => "keep_both", + Self::Replace => "replace", + Self::Skip => "skip", + } + } +} + +#[derive(Debug)] +struct ArchiveExtractOutcome { + requested: BTreeSet, + matched: BTreeSet, + written: Vec, + skipped: Vec, + blocked: Vec, + processed_entries: usize, +} + +impl ArchiveExtractOutcome { + fn new(requested: BTreeSet) -> Self { + Self { + requested, + matched: BTreeSet::new(), + written: Vec::new(), + skipped: Vec::new(), + blocked: Vec::new(), + processed_entries: 0, + } + } + + fn mark_matched(&mut self, normalized_path: &str) { + for selected in &self.requested { + if normalized_path == selected + || normalized_path + .strip_prefix(selected) + .is_some_and(|rest| rest.starts_with('/')) + { + self.matched.insert(selected.clone()); + } + } + } + + fn is_selected(&self, normalized_path: &str) -> bool { + self.requested.iter().any(|selected| { + normalized_path == selected + || normalized_path + .strip_prefix(selected) + .is_some_and(|rest| rest.starts_with('/')) + }) + } + + fn finish_missing(&mut self) { + for entry in self.requested.difference(&self.matched) { + self.skipped.push(json!({ + "path": entry, + "reason": "entry_not_found", + })); + } + } +} + +#[derive(Debug)] +enum ArchiveExtractWrite { + Directory { path: String }, + File { path: String, bytes: Vec }, +} + +struct ArchiveExtractRequest<'a> { + source_uri: &'a str, + archive_name: &'a str, + destination_uri: &'a str, + entries: &'a [String], + conflict_policy: Option<&'a str>, + cancel: bool, +} + +struct ArchiveExtractResponseInput<'a> { + destination_object: LibraryObject, + source_uri: &'a str, + destination_uri: &'a str, + archive_name: &'a str, + policy: ArchiveConflictPolicy, + status: &'a str, + outcome: ArchiveExtractOutcome, + cancel_requested: bool, +} + +fn extract_library_archive_entries( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, + destination_uri: &str, + entries: &[String], + conflict_policy: Option<&str>, + cancel: bool, +) -> anyhow::Result { + if !target.path.is_file() { + bail!("library archive selected extraction target must be a file"); + } + if is_runtime_private_uri(&target.localhost_root, &target.uri) { + bail!("runtime-private Library archives cannot be selectively extracted"); + } + let name = library_archive_name(target, "selected extraction")?; + if !is_extractable_archive_name(&name) { + bail!( + "library archive selected extraction only supports .tar, .tar.gz, .tgz, and .zip archives" + ); + } + let bytes = read_library_file_bytes(data_dir, principal_id, target)?; + let request = ArchiveExtractRequest { + source_uri: &target.uri, + archive_name: &name, + destination_uri, + entries, + conflict_policy, + cancel, + }; + extract_archive_entries_to_local_destination(data_dir, principal_id, bytes, request) +} + +fn extract_archive_entries_to_local_destination( + data_dir: &Path, + principal_id: &str, + bytes: Vec, + request: ArchiveExtractRequest<'_>, +) -> anyhow::Result { + if !is_extractable_archive_name(request.archive_name) { + bail!( + "library archive selected extraction only supports .tar, .tar.gz, .tgz, and .zip archives" + ); + } + let destination = library_target(data_dir, principal_id, request.destination_uri)?; + if destination.path.exists() && !destination.path.is_dir() { + bail!("library archive selected extraction destination must be a directory"); + } + let policy = ArchiveConflictPolicy::parse(request.conflict_policy)?; + let selected_entries = selected_archive_entries(request.entries)?; + let mut outcome = ArchiveExtractOutcome::new(selected_entries); + if request.cancel { + let destination_object = library_object(data_dir, principal_id, &destination.uri)?; + return Ok(archive_extract_entries_response( + ArchiveExtractResponseInput { + destination_object, + source_uri: request.source_uri, + destination_uri: &destination.uri, + archive_name: request.archive_name, + policy, + status: "cancelled", + outcome, + cancel_requested: true, + }, + )); + } + + fs::create_dir_all(&destination.path)?; + let writes = collect_selected_archive_writes(request.archive_name, bytes, &mut outcome)?; + apply_selected_archive_writes_to_local( + data_dir, + principal_id, + &destination, + writes, + policy, + &mut outcome, + )?; + outcome.finish_missing(); + let status = if outcome.blocked.is_empty() { + "completed" + } else { + "completed_with_blocked_entries" + }; + let destination_object = library_object(data_dir, principal_id, &destination.uri)?; + let response = archive_extract_entries_response(ArchiveExtractResponseInput { + destination_object, + source_uri: request.source_uri, + destination_uri: &destination.uri, + archive_name: request.archive_name, + policy, + status, + outcome, + cancel_requested: false, + }); + append_library_event( + data_dir, + principal_id, + "archive_extract_entries", + &destination.uri, + json!({ + "source_uri": request.source_uri, + "destination_uri": destination.uri, + "receipt": response.get("receipt").cloned().unwrap_or(Value::Null), + }), + )?; + Ok(response) +} + +fn selected_archive_entries(entries: &[String]) -> anyhow::Result> { + let mut selected = BTreeSet::new(); + for entry in entries { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + selected.insert(normalized_archive_entry_path(Path::new(entry))?); + } + if selected.is_empty() { + bail!("library archive selected extraction requires at least one entry"); + } + Ok(selected) +} + +fn collect_selected_archive_writes( + name: &str, + bytes: Vec, + outcome: &mut ArchiveExtractOutcome, +) -> anyhow::Result> { + if name.to_ascii_lowercase().ends_with(".zip") { + collect_selected_zip_writes(bytes, outcome) + } else { + collect_selected_tar_writes(name, bytes, outcome) + } +} + +fn collect_selected_tar_writes( + name: &str, + bytes: Vec, + outcome: &mut ArchiveExtractOutcome, +) -> anyhow::Result> { + let reader: Box = if name.to_ascii_lowercase().ends_with(".tar") { + Box::new(Cursor::new(bytes)) + } else { + Box::new(GzDecoder::new(Cursor::new(bytes))) + }; + let mut archive = tar::Archive::new(reader); + let mut writes = Vec::new(); + for entry in archive.entries()? { + let mut entry = entry?; + let path = match entry.path() { + Ok(path) => path.to_path_buf(), + Err(err) => { + outcome.blocked.push(json!({ + "path": "invalid-entry-path", + "reason": format!("invalid archive entry path: {err}"), + })); + continue; + } + }; + let normalized = match normalized_archive_entry_path(&path) { + Ok(path) => path, + Err(err) => { + outcome.blocked.push(json!({ + "path": archive_entry_display_path(&path, outcome.processed_entries), + "reason": err.to_string(), + })); + continue; + } + }; + if !outcome.is_selected(&normalized) { + continue; + } + outcome.mark_matched(&normalized); + outcome.processed_entries += 1; + let entry_type = entry.header().entry_type(); + if entry_type.is_dir() { + writes.push(ArchiveExtractWrite::Directory { path: normalized }); + continue; + } + if !entry_type.is_file() { + outcome.blocked.push(json!({ + "path": normalized, + "reason": "library archive selected extraction rejects non-file archive entries", + })); + continue; + } + let mut entry_bytes = Vec::new(); + entry.read_to_end(&mut entry_bytes)?; + writes.push(ArchiveExtractWrite::File { + path: normalized, + bytes: entry_bytes, + }); + } + Ok(writes) +} + +fn collect_selected_zip_writes( + bytes: Vec, + outcome: &mut ArchiveExtractOutcome, +) -> anyhow::Result> { + let mut archive = + ZipArchive::new(Cursor::new(bytes)).map_err(|err| anyhow!("invalid ZIP archive: {err}"))?; + let mut writes = Vec::new(); + for index in 0..archive.len() { + let mut file = archive + .by_index(index) + .map_err(|err| anyhow!("invalid ZIP archive entry: {err}"))?; + let raw_name = file.name().to_string(); + let normalized = match normalized_archive_entry_path(Path::new(&raw_name)) { + Ok(path) => path, + Err(err) => { + outcome.blocked.push(json!({ + "path": archive_entry_display_name(&raw_name, index), + "reason": err.to_string(), + })); + continue; + } + }; + if !outcome.is_selected(&normalized) { + continue; + } + outcome.mark_matched(&normalized); + outcome.processed_entries += 1; + if file.is_dir() { + writes.push(ArchiveExtractWrite::Directory { path: normalized }); + continue; + } + if !file.is_file() { + outcome.blocked.push(json!({ + "path": normalized, + "reason": "library archive selected extraction rejects non-file archive entries", + })); + continue; + } + let mut entry_bytes = Vec::new(); + file.read_to_end(&mut entry_bytes)?; + writes.push(ArchiveExtractWrite::File { + path: normalized, + bytes: entry_bytes, + }); + } + Ok(writes) +} + +fn apply_selected_archive_writes_to_local( + data_dir: &Path, + principal_id: &str, + destination: &LibraryTarget, + writes: Vec, + policy: ArchiveConflictPolicy, + outcome: &mut ArchiveExtractOutcome, +) -> anyhow::Result<()> { + for write in writes { + match write { + ArchiveExtractWrite::Directory { path } => { + create_selected_archive_directory(data_dir, principal_id, destination, &path)?; + outcome.written.push(json!({ + "path": path, + "uri": archive_entry_uri(&destination.uri, Path::new(&path))?, + "kind": "directory", + })); + } + ArchiveExtractWrite::File { path, bytes } => { + write_selected_archive_file( + data_dir, + principal_id, + destination, + &path, + &bytes, + policy, + outcome, + )?; + } + } + } + Ok(()) +} + +async fn extract_archive_entries_to_webspace_destination( + data_dir: &Path, + registry: &ProviderRegistry, + principal_id: &str, + bytes: Vec, + request: ArchiveExtractRequest<'_>, +) -> anyhow::Result { + if !is_extractable_archive_name(request.archive_name) { + bail!( + "library archive selected extraction only supports .tar, .tar.gz, .tgz, and .zip archives" + ); + } + let destination_uri = clean_webspace_uri(request.destination_uri)?; + let destination_object = webspace_stat_object(data_dir, registry, &destination_uri).await?; + if destination_object.kind != "directory" { + bail!("library archive selected extraction WebSpace destination must be a folder"); + } + ensure_webspace_archive_write_allowed(registry, &destination_object).await?; + let policy = ArchiveConflictPolicy::parse(request.conflict_policy)?; + if policy != ArchiveConflictPolicy::Replace { + bail!("Spaces archive write-back requires conflict_policy=replace until resolver adapters expose existence/conflict APIs"); + } + let selected_entries = selected_archive_entries(request.entries)?; + let mut outcome = ArchiveExtractOutcome::new(selected_entries); + if request.cancel { + return Ok(archive_extract_entries_response( + ArchiveExtractResponseInput { + destination_object: webspace_archive_object(destination_object), + source_uri: request.source_uri, + destination_uri: &destination_uri, + archive_name: request.archive_name, + policy, + status: "cancelled", + outcome, + cancel_requested: true, + }, + )); + } + let writes = collect_selected_archive_writes(request.archive_name, bytes, &mut outcome)?; + apply_selected_archive_writes_to_webspace( + data_dir, + registry, + &destination_uri, + writes, + &mut outcome, + ) + .await?; + outcome.finish_missing(); + let status = if outcome.blocked.is_empty() { + "completed" + } else { + "completed_with_blocked_entries" + }; + let destination_object = webspace_stat_object(data_dir, registry, &destination_uri).await?; + let response = archive_extract_entries_response(ArchiveExtractResponseInput { + destination_object: webspace_archive_object(destination_object), + source_uri: request.source_uri, + destination_uri: &destination_uri, + archive_name: request.archive_name, + policy, + status, + outcome, + cancel_requested: false, + }); + append_library_event( + data_dir, + principal_id, + "archive_extract_entries", + &destination_uri, + json!({ + "source_uri": request.source_uri, + "destination_uri": destination_uri, + "receipt": response.get("receipt").cloned().unwrap_or(Value::Null), + }), + )?; + Ok(response) +} + +async fn ensure_webspace_archive_write_allowed( + registry: &ProviderRegistry, + destination: &LibraryObject, +) -> anyhow::Result<()> { + if webspace_metadata_bool(destination, "readonly") != Some(false) { + bail!("Spaces archive write-back requires a mutable destination Space"); + } + let Some(adapter) = webspace_adapter_target(registry, destination).await? else { + bail!("Spaces archive write-back requires a connected resolver adapter"); + }; + if !adapter.capabilities.contains("write_bytes") { + bail!("Spaces archive write-back requires an adapter with write_bytes capability"); + } + Ok(()) +} + +async fn apply_selected_archive_writes_to_webspace( + data_dir: &Path, + registry: &ProviderRegistry, + destination_uri: &str, + writes: Vec, + outcome: &mut ArchiveExtractOutcome, +) -> anyhow::Result<()> { + for write in writes { + match write { + ArchiveExtractWrite::Directory { path } => { + let uri = archive_entry_uri(destination_uri, Path::new(&path))?; + let mut receipt = webspace_mkdir(registry, &uri).await?; + redact_resolver_private_fields(&mut receipt); + outcome.written.push(json!({ + "path": path, + "uri": uri, + "kind": "directory", + "webspace": { + "write_back": "materialized_directory_handle", + "provider_receipt": receipt, + }, + })); + } + ArchiveExtractWrite::File { path, bytes } => { + write_selected_archive_webspace_file( + data_dir, + registry, + destination_uri, + &path, + &bytes, + outcome, + ) + .await?; + } + } + } + Ok(()) +} + +async fn write_selected_archive_webspace_file( + data_dir: &Path, + registry: &ProviderRegistry, + destination_uri: &str, + normalized_path: &str, + bytes: &[u8], + outcome: &mut ArchiveExtractOutcome, +) -> anyhow::Result<()> { + ensure_webspace_archive_parent_dirs(registry, destination_uri, normalized_path).await?; + let uri = archive_entry_uri(destination_uri, Path::new(normalized_path))?; + let mut provider_receipt = webspace_write_bytes(registry, &uri, bytes).await?; + redact_resolver_private_fields(&mut provider_receipt); + let sync_receipt = webspace_sync_bytes(data_dir, registry, &uri).await?; + if sync_receipt + .get("fail_closed") + .and_then(Value::as_bool) + .unwrap_or(false) + { + bail!("Spaces archive write-back failed to sync resolver bytes"); + } + outcome.written.push(json!({ + "path": normalized_path, + "uri": uri, + "kind": "file", + "size": bytes.len(), + "webspace": { + "write_back": "resolver_synced", + "provider_receipt": provider_receipt, + "sync_receipt": redacted_resolver_private_value(sync_receipt), + }, + })); + Ok(()) +} + +async fn ensure_webspace_archive_parent_dirs( + registry: &ProviderRegistry, + destination_uri: &str, + normalized_path: &str, +) -> anyhow::Result<()> { + let mut uri = destination_uri.trim_end_matches('/').to_string(); + let mut components = normalized_path.split('/').peekable(); + while let Some(component) = components.next() { + if components.peek().is_none() { + break; + } + uri = child_uri(&uri, component)?; + let _ = webspace_mkdir(registry, &uri).await?; + } + Ok(()) +} + +fn create_selected_archive_directory( + data_dir: &Path, + principal_id: &str, + destination: &LibraryTarget, + normalized_path: &str, +) -> anyhow::Result<()> { + let uri = archive_entry_uri(&destination.uri, Path::new(normalized_path))?; + let target = library_target(data_dir, principal_id, &uri)?; + fs::create_dir_all(&target.path)?; + Ok(()) +} + +fn write_selected_archive_file( + data_dir: &Path, + principal_id: &str, + destination: &LibraryTarget, + normalized_path: &str, + bytes: &[u8], + policy: ArchiveConflictPolicy, + outcome: &mut ArchiveExtractOutcome, +) -> anyhow::Result<()> { + let candidate_uri = archive_entry_uri(&destination.uri, Path::new(normalized_path))?; + let target_uri = selected_archive_conflict_uri( + data_dir, + principal_id, + &candidate_uri, + normalized_path, + policy, + outcome, + )?; + let Some(target_uri) = target_uri else { + return Ok(()); + }; + let target = library_target(data_dir, principal_id, &target_uri)?; + if let Some(parent) = target.path.parent() { + fs::create_dir_all(parent)?; + } + crate::auth::write_principal_root_object( + data_dir, + principal_id, + &target.localhost_root, + &target.uri, + &target.path, + bytes, + )?; + outcome.written.push(json!({ + "path": normalized_path, + "uri": target.uri, + "kind": "file", + "size": bytes.len(), + })); + Ok(()) +} + +fn selected_archive_conflict_uri( + data_dir: &Path, + principal_id: &str, + candidate_uri: &str, + normalized_path: &str, + policy: ArchiveConflictPolicy, + outcome: &mut ArchiveExtractOutcome, +) -> anyhow::Result> { + let target = library_target(data_dir, principal_id, candidate_uri)?; + if !target.path.exists() { + return Ok(Some(candidate_uri.to_string())); + } + match policy { + ArchiveConflictPolicy::Skip => { + outcome.skipped.push(json!({ + "path": normalized_path, + "uri": candidate_uri, + "reason": "conflict_skipped", + })); + Ok(None) + } + ArchiveConflictPolicy::Replace => { + if target.path.is_dir() { + fs::remove_dir_all(&target.path)?; + } else { + fs::remove_file(&target.path)?; + } + Ok(Some(candidate_uri.to_string())) + } + ArchiveConflictPolicy::KeepBoth => { + let (parent_uri, name) = candidate_uri + .rsplit_once('/') + .ok_or_else(|| anyhow!("archive selected extraction target has no parent"))?; + unique_child_uri(data_dir, principal_id, parent_uri, name).map(Some) + } + } +} + +fn archive_extract_entries_response(input: ArchiveExtractResponseInput<'_>) -> Value { + let ArchiveExtractResponseInput { + destination_object, + source_uri, + destination_uri, + archive_name, + policy, + status, + outcome, + cancel_requested, + } = input; + let requested_entries = outcome.requested.len(); + let written_entries = outcome.written.len(); + let skipped_entries = outcome.skipped.len(); + let blocked_entries = outcome.blocked.len(); + json!({ + "schema": LIBRARY_ARCHIVE_EXTRACT_ENTRIES_SCHEMA, + "object": destination_object, + "source_uri": source_uri, + "destination_uri": destination_uri, + "family": archive_family_for_name(archive_name).unwrap_or("archive"), + "conflict_policy": policy.as_str(), + "written": outcome.written, + "skipped": outcome.skipped, + "blocked": outcome.blocked, + "receipt": { + "schema": "elastos.library.archive-extract-entries.receipt/v1", + "status": status, + "progress": { + "requested_entries": requested_entries, + "processed_entries": outcome.processed_entries, + "written_entries": written_entries, + "skipped_entries": skipped_entries, + "blocked_entries": blocked_entries, + }, + "cancel": { + "supported": true, + "requested": cancel_requested, + "status": if cancel_requested { "cancelled_before_write" } else { "not_requested" }, + "mode": "bounded_synchronous_provider_operation", + }, + }, + }) +} + +fn extract_library_archive( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, +) -> anyhow::Result { + if !target.path.is_file() { + bail!("library archive extraction target must be a file"); + } + if is_runtime_private_uri(&target.localhost_root, &target.uri) { + bail!("runtime-private Library archives cannot be extracted"); + } + let name = target + .uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .ok_or_else(|| anyhow!("library archive extraction target has no name"))?; + if !is_extractable_archive_name(name) { + bail!("library archive extraction only supports .tar, .tar.gz, .tgz, and .zip archives"); + } + let parent_uri = target + .uri + .rsplit_once('/') + .map(|(parent, _)| parent) + .ok_or_else(|| anyhow!("library archive extraction target has no parent"))?; + let destination_name = archive_extract_folder_name(name); + let destination_uri = unique_child_uri(data_dir, principal_id, parent_uri, &destination_name)?; + let destination = library_target(data_dir, principal_id, &destination_uri)?; + fs::create_dir_all(&destination.path)?; + + let bytes = read_library_file_bytes(data_dir, principal_id, target)?; + let lower_name = name.to_ascii_lowercase(); + if lower_name.ends_with(".zip") { + extract_zip_archive(data_dir, principal_id, &destination, bytes)?; + } else { + extract_tar_archive(data_dir, principal_id, &destination, name, bytes)?; + } + Ok(destination.uri) +} + +fn extract_tar_archive( + data_dir: &Path, + principal_id: &str, + destination: &LibraryTarget, + name: &str, + bytes: Vec, +) -> anyhow::Result<()> { + let reader: Box = if name.to_ascii_lowercase().ends_with(".tar") { + Box::new(Cursor::new(bytes)) + } else { + Box::new(GzDecoder::new(Cursor::new(bytes))) + }; + let mut archive = tar::Archive::new(reader); + for entry in archive.entries()? { + let mut entry = entry?; + let entry_path = entry.path()?.to_path_buf(); + let entry_uri = archive_entry_uri(&destination.uri, &entry_path)?; + if entry_uri == destination.uri { + continue; + } + let entry_target = library_target(data_dir, principal_id, &entry_uri)?; + let entry_type = entry.header().entry_type(); + if entry_type.is_dir() { + fs::create_dir_all(&entry_target.path)?; + continue; + } + if !entry_type.is_file() { + bail!("library archive extraction rejects non-file archive entries"); + } + let mut entry_bytes = Vec::new(); + entry.read_to_end(&mut entry_bytes)?; + if let Some(parent) = entry_target.path.parent() { + fs::create_dir_all(parent)?; + } + crate::auth::write_principal_root_object( + data_dir, + principal_id, + &entry_target.localhost_root, + &entry_target.uri, + &entry_target.path, + &entry_bytes, + )?; + } + Ok(()) +} + +fn extract_zip_archive( + data_dir: &Path, + principal_id: &str, + destination: &LibraryTarget, + bytes: Vec, +) -> anyhow::Result<()> { + let mut archive = + ZipArchive::new(Cursor::new(bytes)).map_err(|err| anyhow!("invalid ZIP archive: {err}"))?; + for index in 0..archive.len() { + let mut file = archive + .by_index(index) + .map_err(|err| anyhow!("invalid ZIP archive entry: {err}"))?; + let entry_path = file + .enclosed_name() + .ok_or_else(|| anyhow!("library archive entry path must be relative and safe"))?; + let entry_uri = archive_entry_uri(&destination.uri, &entry_path)?; + if entry_uri == destination.uri { + continue; + } + let entry_target = library_target(data_dir, principal_id, &entry_uri)?; + if file.is_dir() { + fs::create_dir_all(&entry_target.path)?; + continue; + } + if !file.is_file() { + bail!("library archive extraction rejects non-file archive entries"); + } + let mut entry_bytes = Vec::new(); + file.read_to_end(&mut entry_bytes)?; + if let Some(parent) = entry_target.path.parent() { + fs::create_dir_all(parent)?; + } + crate::auth::write_principal_root_object( + data_dir, + principal_id, + &entry_target.localhost_root, + &entry_target.uri, + &entry_target.path, + &entry_bytes, + )?; + } + Ok(()) +} + +fn archive_entry_uri(destination_uri: &str, path: &Path) -> anyhow::Result { + let mut uri = destination_uri.trim_end_matches('/').to_string(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(name) => { + let name = name + .to_str() + .ok_or_else(|| anyhow!("library archive entry path must be UTF-8"))?; + uri = child_uri(&uri, name)?; + } + _ => bail!("library archive entry path must be relative and safe"), + } + } + Ok(uri) +} + +fn is_extractable_archive_name(name: &str) -> bool { + let name = name.to_ascii_lowercase(); + name.ends_with(".tar") + || name.ends_with(".tar.gz") + || name.ends_with(".tgz") + || name.ends_with(".zip") +} + +fn archive_family_for_name(name: &str) -> Option<&'static str> { + let name = name.to_ascii_lowercase(); + if name.ends_with(".tar.gz") || name.ends_with(".tgz") { + Some("tar.gz") + } else if name.ends_with(".tar") { + Some("tar") + } else if name.ends_with(".zip") { + Some("zip") + } else if name.ends_with(".tar.xz") || name.ends_with(".txz") { + Some("tar.xz") + } else if name.ends_with(".tar.bz2") || name.ends_with(".tbz2") { + Some("tar.bz2") + } else if name.ends_with(".tar.zst") || name.ends_with(".tzst") { + Some("tar.zst") + } else if name.ends_with(".rar") { + Some("rar") + } else if name.ends_with(".7z") { + Some("7z") + } else if name.ends_with(".xz") { + Some("xz") + } else if name.ends_with(".bz2") { + Some("bz2") + } else if name.ends_with(".zst") { + Some("zst") + } else if name.ends_with(".lz4") { + Some("lz4") + } else if name.ends_with(".gz") { + Some("gzip") + } else { + None + } +} + +fn archive_support_for_name(name: &str) -> Option { + let family = archive_family_for_name(name)?; + let extractable = is_extractable_archive_name(name); + Some(json!({ + "schema": "elastos.library.archive-support/v1", + "family": family, + "status": if extractable { + "extractable" + } else { + "policy_gated_unsupported_archive_family" + }, + "implemented": { + "download_formats": ["zip", "tar.gz"], + "compress_to_library": ["zip"], + "extract_formats": ["zip", "tar", "tar.gz", "tgz"], + "safety": "relative UTF-8 file paths only; non-file archive entries are rejected" + }, + "policy_gate": if extractable { + Value::Null + } else { + json!({ + "required": true, + "reason": "generic archive support needs dependency and release-policy review before enabling", + "blocked_formats": ["7z", "rar", "tar.xz", "tar.bz2", "tar.zst", "xz", "bz2", "zst", "lz4", "gzip"] + }) + } + })) +} + +fn archive_extract_folder_name(name: &str) -> String { + let lower = name.to_ascii_lowercase(); + let stem = if lower.ends_with(".tar.gz") { + &name[..name.len().saturating_sub(".tar.gz".len())] + } else if lower.ends_with(".tgz") { + &name[..name.len().saturating_sub(".tgz".len())] + } else if lower.ends_with(".tar") { + &name[..name.len().saturating_sub(".tar".len())] + } else if lower.ends_with(".zip") { + &name[..name.len().saturating_sub(".zip".len())] + } else { + name + }; + let stem = stem.trim().trim_matches('.'); + if stem.is_empty() { + "Extracted Archive".to_string() + } else { + stem.to_string() + } +} + +fn append_library_archive_entry( + builder: &mut tar::Builder>>, + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, + archive_path: &Path, +) -> anyhow::Result<()> { + let metadata = fs::metadata(&target.path)?; + if metadata.is_dir() { + builder.append_dir(archive_path, &target.path)?; + for entry in fs::read_dir(&target.path)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + let child_uri = format!("{}/{}", target.uri, name); + let child_target = library_target(data_dir, principal_id, &child_uri)?; + let child_archive_path = archive_path.join(safe_archive_name(&name)); + append_library_archive_entry( + builder, + data_dir, + principal_id, + &child_target, + &child_archive_path, + )?; + } + return Ok(()); + } + let bytes = read_library_file_bytes(data_dir, principal_id, target)?; + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o644); + if let Some(modified) = system_time_secs(metadata.modified().ok()) { + header.set_mtime(modified); + } + header.set_cksum(); + let mut data = bytes.as_slice(); + builder.append_data(&mut header, archive_path, &mut data)?; + Ok(()) +} + +fn append_library_zip_entry( + writer: &mut ZipWriter>>, + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, + archive_path: &Path, +) -> anyhow::Result<()> { + let metadata = fs::metadata(&target.path)?; + if metadata.is_dir() { + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + writer.add_directory(zip_archive_entry_name(archive_path, true)?, options)?; + for entry in fs::read_dir(&target.path)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + let child_uri = format!("{}/{}", target.uri, name); + let child_target = library_target(data_dir, principal_id, &child_uri)?; + let child_archive_path = archive_path.join(safe_archive_name(&name)); + append_library_zip_entry( + writer, + data_dir, + principal_id, + &child_target, + &child_archive_path, + )?; + } + return Ok(()); + } + let bytes = read_library_file_bytes(data_dir, principal_id, target)?; + let options = zip_file_options_for_entry(archive_path, bytes.len()); + writer.start_file(zip_archive_entry_name(archive_path, false)?, options)?; + writer.write_all(&bytes)?; + Ok(()) +} + +fn zip_file_options_for_entry(path: &Path, size: usize) -> zip::write::SimpleFileOptions { + let method = if should_store_zip_entry(path, size) { + zip::CompressionMethod::Stored + } else { + zip::CompressionMethod::Deflated + }; + zip::write::SimpleFileOptions::default().compression_method(method) +} + +fn should_store_zip_entry(path: &Path, size: usize) -> bool { + if size < 1024 { + return true; + } + let Some(extension) = path.extension().and_then(|value| value.to_str()) else { + return false; + }; + matches!( + extension.to_ascii_lowercase().as_str(), + "zip" + | "gz" + | "tgz" + | "7z" + | "rar" + | "xz" + | "bz2" + | "zst" + | "lz4" + | "mp4" + | "mov" + | "mkv" + | "webm" + | "mp3" + | "aac" + | "ogg" + | "flac" + | "jpg" + | "jpeg" + | "png" + | "gif" + | "webp" + | "avif" + | "pdf" + ) +} + +fn zip_archive_entry_name(path: &Path, directory: bool) -> anyhow::Result { + let mut parts = Vec::new(); + for component in path.components() { + match component { + Component::Normal(name) => { + let name = name + .to_str() + .ok_or_else(|| anyhow!("library ZIP archive entry path must be UTF-8"))?; + if !name.is_empty() { + parts.push(name); + } + } + Component::CurDir => {} + _ => bail!("library ZIP archive entry path must be relative and safe"), + } + } + let mut name = parts.join("/"); + if name.is_empty() { + bail!("library ZIP archive entry path must not be empty"); + } + if directory && !name.ends_with('/') { + name.push('/'); + } + Ok(name) +} + +fn safe_archive_name(name: &str) -> String { + let sanitized: String = name + .chars() + .map(|ch| match ch { + '/' | '\\' | ':' | '\0' => '_', + _ => ch, + }) + .collect(); + let sanitized = sanitized.trim().trim_matches('.').to_string(); + if sanitized.is_empty() { + "Library".to_string() + } else { + sanitized + } +} + +fn is_runtime_private_uri(localhost_root: &str, uri: &str) -> bool { + uri == format!("{localhost_root}/.AppData") + || uri + .strip_prefix(&format!("{localhost_root}/.AppData/")) + .is_some() + || is_trash_uri(localhost_root, uri) +} + +fn record_is_published(record: &LibraryPublishRecord) -> bool { + record.unpublished_at.is_none() + && record + .availability + .get("status") + .and_then(Value::as_str) + .map(|status| status != "local_unpinned") + .unwrap_or(true) +} + +fn default_publish_content_security() -> Value { + json!({ + "schema": "elastos.library.published-content-security/v1", + "source_storage": "unknown", + "published_payload": "plain_content", + "key_release_required": false, + "status": "not_required_for_plain_published_content", + "required_providers": protected_content_provider_requirements(false), + }) +} + +fn default_share_key_release() -> Value { + json!({ + "schema": "elastos.library.key-release/v1", + "required": false, + "status": "not_required_for_plain_published_content", + "required_providers": protected_content_provider_requirements(false), + "next": "Protected encrypted-content sharing requires drm/rights/key/decrypt providers before content is opened." + }) +} + +fn protected_content_provider_requirements(required: bool) -> Value { + let status = if required { + "required_for_encrypted_recipient_payload" + } else { + "not_required_for_plain_published_content" + }; + json!({ + "schema": "elastos.library.protected-content-provider-requirements/v1", + "required": required, + "status": status, + "providers": [ + { + "id": "drm-provider", + "scheme": "drm", + "role": "protected-content open orchestration", + "operation": "open", + "required": required + }, + { + "id": "rights-provider", + "scheme": "rights", + "role": "recipient rights/ACL decision", + "operation": "has_access_by_content_id", + "required": required + }, + { + "id": "key-provider", + "scheme": "key", + "role": "recipient-scoped key release", + "operation": "release", + "required": required + }, + { + "id": "decrypt-provider", + "scheme": "decrypt", + "role": "viewer-scoped decrypt/render session", + "operation": "open_session", + "required": required + } + ], + "authority_boundary": "Library records grants; drm/rights/key/decrypt providers enforce protected-content access without exposing raw CEKs or broad plaintext authority." + }) +} + +async fn attach_protected_content_provider_status(registry: &ProviderRegistry, data: &mut Value) { + let status = protected_content_provider_status(registry).await; + if let Some(object) = data.as_object_mut() { + object.insert("protected_content".to_string(), status.clone()); + if let Some(published) = object.get_mut("published").and_then(Value::as_object_mut) { + published.insert("protected_content".to_string(), status); + } + } +} + +async fn protected_content_provider_status(registry: &ProviderRegistry) -> Value { + let providers = [ + protected_content_provider_runtime_status(registry, "drm-provider", "drm", "open").await, + protected_content_provider_runtime_status( + registry, + "rights-provider", + "rights", + "has_access_by_content_id", + ) + .await, + protected_content_provider_runtime_status(registry, "key-provider", "key", "release").await, + protected_content_provider_runtime_status( + registry, + "decrypt-provider", + "decrypt", + "open_session", + ) + .await, + ]; + let available_count = providers + .iter() + .filter(|provider| { + provider + .get("available") + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .count(); + let configured_count = providers + .iter() + .filter(|provider| { + provider + .get("configured") + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .count(); + let provider_chain_ready = configured_count == providers.len(); + json!({ + "schema": "elastos.library.protected-content-provider-status/v1", + "authority_boundary": "Apps receive provider readiness and receipts only; drm/rights/key/decrypt providers retain protected-content open, dDRM, key-release, and decrypt authority.", + "available_provider_count": available_count, + "configured_provider_count": configured_count, + "required_provider_count": providers.len(), + "providers": providers, + "encrypted_recipient_sharing": { + "schema": "elastos.library.encrypted-recipient-sharing-readiness/v1", + "providers_ready": provider_chain_ready, + "protected_content_fixture_ready": true, + "production_encrypted_publish_mode_ready": false, + "status": if provider_chain_ready { + "provider_chain_ready" + } else { + "blocked_until_drm_rights_key_decrypt_providers_configured" + }, + "required_published_payload": "encrypted_recipient_content", + "next": if provider_chain_ready { + "Use protected_content_fixture for non-production receipt-chain tests. Production encrypted_recipient_content still requires real dDRM/dKMS payload encryption." + } else { + "Configure drm/rights/key/decrypt providers before encrypted recipient sharing can release keys." + } + } + }) +} + +async fn protected_content_provider_runtime_status( + registry: &ProviderRegistry, + id: &str, + scheme: &str, + primary_operation: &str, +) -> Value { + if registry.get(scheme).await.is_none() { + return json!({ + "id": id, + "scheme": scheme, + "primary_operation": primary_operation, + "available": false, + "configured": false, + "status": "provider_not_registered", + "next": format!("{id} must be installed and registered on the Runtime provider plane.") + }); + } + match registry.send_raw(scheme, &json!({ "op": "status" })).await { + Ok(response) => { + let data = response + .get("data") + .filter(|_| response.get("status").and_then(Value::as_str) == Some("ok")) + .unwrap_or(&response); + let configured = data + .get("configured") + .and_then(Value::as_bool) + .unwrap_or(false); + json!({ + "id": id, + "scheme": scheme, + "primary_operation": primary_operation, + "available": true, + "configured": configured, + "provider": data.get("provider").and_then(Value::as_str).unwrap_or(scheme), + "version": data.get("version").cloned().unwrap_or(Value::Null), + "contract_schema": data + .get("contract") + .and_then(|contract| contract.get("schema")) + .cloned() + .unwrap_or(Value::Null), + "supported_operations": data + .get("supported_operations") + .cloned() + .unwrap_or_else(|| json!([])), + "blocked_authority": data + .get("blocked_authority") + .cloned() + .unwrap_or_else(|| json!([])), + "status": if configured { + "configured" + } else { + "provider_registered_not_configured" + }, + "next": if configured { + "Provider is configured; encrypted publish mode still controls whether Library requests key release." + } else { + "Provider is installed but still fail-closed until its backend policy/key/decrypt configuration is complete." + } + }) + } + Err(err) => json!({ + "id": id, + "scheme": scheme, + "primary_operation": primary_operation, + "available": false, + "configured": false, + "status": "provider_status_unavailable", + "error": err.to_string(), + "next": format!("Inspect {id} registration and provider health.") + }), + } +} + +fn published_content_security( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, +) -> anyhow::Result { + let source_storage = if crate::auth::load_principal_root_protection( + data_dir, + principal_id, + &target.localhost_root, + )? + .is_some() + { + "protected_principal_root" + } else { + "plain_localhost_root" + }; + Ok(json!({ + "schema": "elastos.library.published-content-security/v1", + "object_uri": target.uri, + "source_storage": source_storage, + "published_payload": "plain_content", + "key_release_required": false, + "status": "not_required_for_plain_published_content", + "required_providers": protected_content_provider_requirements(false), + "next": "Publishing currently materializes a plain content payload through content-provider. Encrypted recipient payloads require drm/rights/key/decrypt providers and encrypted-content publish mode." + })) +} + +fn protected_content_fixture_security( + data_dir: &Path, + principal_id: &str, + target: &LibraryTarget, + payload_cid: &str, +) -> anyhow::Result { + let source_storage = if crate::auth::load_principal_root_protection( + data_dir, + principal_id, + &target.localhost_root, + )? + .is_some() + { + "protected_principal_root" + } else { + "plain_localhost_root" + }; + let policy_hash = format!( + "sha256:{}", + hex::encode(Sha256::digest(format!("{}:{payload_cid}:view", target.uri))) + ); + let sealed_object = SealedObjectV1 { + schema: SEALED_OBJECT_SCHEMA.to_string(), + payload_cid: payload_cid.to_string(), + rights_policy_cid: payload_cid.to_string(), + availability_receipt_cid: payload_cid.to_string(), + key_envelope: KeyEnvelopeV1 { + scheme: "elastos-pq-hybrid-threshold-v0".to_string(), + kid: format!("kid:library:{}", &policy_hash["sha256:".len()..24]), + wrapped_cek: format!( + "fixture-wrapped:{}", + hex::encode(Sha256::digest(payload_cid.as_bytes())) + ), + policy_hash, + algorithms: KeyEnvelopeAlgorithmsV1 { + cipher: DEFAULT_PROTECTED_CONTENT_CIPHER.to_string(), + signature: DEFAULT_PROTECTED_CONTENT_SIGNATURES + .iter() + .map(|algorithm| algorithm.to_string()) + .collect(), + kem: DEFAULT_PROTECTED_CONTENT_KEMS + .iter() + .map(|algorithm| algorithm.to_string()) + .collect(), + share_scheme: DEFAULT_PROTECTED_CONTENT_SHARE_SCHEME.to_string(), + }, + }, + viewer: ViewerRequirementV1 { + required_interface: "elastos.viewer/document@1".to_string(), + }, + }; + Ok(json!({ + "schema": "elastos.library.published-content-security/v1", + "object_uri": target.uri, + "source_storage": source_storage, + "published_payload": "protected_content_fixture", + "payload_cid": payload_cid, + "sealed_cid": null, + "sealed_object": sealed_object, + "key_release_required": true, + "status": "protected_content_fixture_requires_provider_receipt_chain", + "production_encryption": false, + "required_providers": protected_content_provider_requirements(true), + "authority_boundary": "This fixture proves the Runtime protected-content receipt chain. It does not claim production dDRM/dKMS encryption.", + "next": "Replace protected_content_fixture with production encrypted_recipient_content once real rights, dKMS, decrypt, and storage policies are configured." + })) +} + +fn protected_content_fixture_publish_request( + principal_id: &str, + target: &LibraryTarget, + sealed_object: &SealedObjectV1, + availability: Option<&Value>, +) -> anyhow::Result { + let sealed_data = + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(sealed_object)?); + Ok(json!({ + "op": "publish", + "kind": "directory", + "object_kind": "sealed", + "files": [ + { + "path": "sealed.json", + "data": sealed_data + } + ], + "links": [ + { + "rel": "availability.receipt", + "cid": sealed_object.availability_receipt_cid + }, + { + "rel": "payload", + "cid": sealed_object.payload_cid + }, + { + "rel": "provenance", + "cid": sealed_object.payload_cid + }, + { + "rel": "rights.policy", + "cid": sealed_object.rights_policy_cid + } + ], + "pin": true, + "publisher_did": principal_id, + "metadata": { + "source_uri": target.uri, + "availability": availability.cloned().unwrap_or_else(|| json!({"status": "unknown"})), + "fixture": "protected-content-receipt-chain" + } + })) +} + +fn protected_content_sealed_object_from_security( + content_security: &Value, +) -> anyhow::Result { + let sealed = content_security + .get("sealed_object") + .cloned() + .ok_or_else(|| anyhow!("protected content security missing sealed_object"))?; + serde_json::from_value(sealed).context("protected content sealed_object is invalid") +} + +fn normalized_key_release_policy( + policy: Option<&str>, + content_security: &Value, +) -> anyhow::Result { + let policy = policy + .map(str::trim) + .filter(|policy| !policy.is_empty()) + .unwrap_or("auto"); + let content_requires_key_release = content_security + .get("key_release_required") + .and_then(Value::as_bool) + .unwrap_or(false); + match policy { + "auto" if content_requires_key_release => { + protected_content_key_release_policy(content_security) + } + "auto" | "none" | "plain_published_content" => Ok(json!({ + "schema": "elastos.library.key-release/v1", + "required": false, + "status": content_security + .get("status") + .and_then(Value::as_str) + .unwrap_or("not_required_for_plain_published_content"), + "published_payload": content_security + .get("published_payload") + .and_then(Value::as_str) + .unwrap_or("plain_content"), + "source_storage": content_security + .get("source_storage") + .and_then(Value::as_str) + .unwrap_or("unknown"), + "required_providers": protected_content_provider_requirements(false), + "next": "No recipient key release is needed for the current plain published payload. Encrypted recipient payloads require drm/rights/key/decrypt providers." + })), + "recipient_key_release" | "protected_content" | "encrypted_recipient" + if content_requires_key_release => + { + protected_content_key_release_policy(content_security) + } + "recipient_key_release" | "protected_content" | "encrypted_recipient" => bail!( + "recipient key release requires drm/rights/key/decrypt providers and encrypted-content publish mode" + ), + _ => bail!("unsupported library key_release_policy"), + } +} + +fn protected_content_key_release_policy(content_security: &Value) -> anyhow::Result { + let sealed_object = protected_content_sealed_object_from_security(content_security)?; + Ok(json!({ + "schema": "elastos.library.key-release/v1", + "required": true, + "status": "provider_receipt_chain_required", + "published_payload": content_security + .get("published_payload") + .and_then(Value::as_str) + .unwrap_or("protected_content"), + "payload_cid": sealed_object.payload_cid, + "sealed_cid": content_security + .get("sealed_cid") + .cloned() + .unwrap_or(Value::Null), + "viewer_interface": sealed_object.viewer.required_interface, + "key_envelope": sealed_object.key_envelope, + "required_providers": protected_content_provider_requirements(true), + "authority_boundary": "Library records the recipient grant; drm/rights/key/decrypt providers must issue receipts before a viewer opens protected content.", + "next": "Runtime shared_access must invoke drm, rights, key, and decrypt providers before returning a protected viewer session." + })) +} + +fn record_availability_label(record: Option<&LibraryPublishRecord>) -> String { + let Some(record) = record else { + return "local-only".to_string(); + }; + record + .availability + .get("status") + .and_then(Value::as_str) + .unwrap_or(if record_is_published(record) { + "published" + } else { + "local_unpinned" + }) + .to_string() +} + +fn normalized_share_recipients(recipients: &[String]) -> anyhow::Result> { + let mut normalized = BTreeSet::new(); + for recipient in recipients { + let recipient = recipient.trim(); + if recipient.is_empty() { + continue; + } + if recipient.len() > 256 || recipient.chars().any(char::is_control) { + bail!("library share recipient is invalid"); + } + if !(recipient.starts_with("did:") + || recipient.starts_with("person:") + || recipient.starts_with("principal:") + || recipient.contains('@')) + { + bail!("library share recipient must be a DID, principal/person id, or address"); + } + normalized.insert(recipient.to_string()); + } + Ok(normalized.into_iter().collect()) +} + +fn normalized_share_policy(policy: Option<&str>, public_link: bool) -> anyhow::Result { + let default_policy = if public_link { + "public_link" + } else { + "recipient_scoped" + }; + let policy = policy + .map(str::trim) + .filter(|policy| !policy.is_empty()) + .unwrap_or(default_policy); + match policy { + "public_link" if public_link => Ok(policy.to_string()), + "recipient_scoped" if !public_link => Ok(policy.to_string()), + "public_link" => bail!("public_link share policy must not include recipients"), + "recipient_scoped" => bail!("recipient_scoped share policy requires recipients"), + _ => bail!("unsupported library share policy"), + } +} + +fn share_remote_enforcement_contract(policy: &str, key_release: &Value) -> Value { + let key_release_required = key_release + .get("required") + .and_then(Value::as_bool) + .unwrap_or(false); + let recipient_proof_required = policy == "recipient_scoped"; + json!({ + "schema": "elastos.library.remote-access-policy/v1", + "policy": policy, + "provider_gate": "object-provider shared_access", + "recipient_proof_required": recipient_proof_required, + "key_release_required": key_release_required, + "key_release_status": key_release + .get("status") + .and_then(Value::as_str) + .unwrap_or("unknown"), + "required_providers": protected_content_provider_requirements(key_release_required), + "provider_invocation": { + "drm": "drm-provider.open", + "rights": "rights-provider.has_access_by_content_id", + "key": "key-provider.release", + "decrypt": "decrypt-provider.open_session", + "transport": "Carrier provider invocation when encrypted payloads are enabled" + }, + "plain_content_fetch": !key_release_required, + "status": if key_release_required { + "blocked_until_drm_rights_key_decrypt_providers" + } else if recipient_proof_required { + "recipient_proof_enforced_by_runtime" + } else { + "public_link_ready" + }, + "next": if key_release_required { + "Attach drm/rights/key/decrypt providers before releasing encrypted payload keys." + } else if recipient_proof_required { + "Remote recipients must present a Runtime recipient proof before object-provider returns the shared open contract." + } else { + "Published plain content is available to holders of the content URI." + } + }) +} + +fn share_grant( + recipient: &str, + cid: &str, + uri: &str, + policy: &str, + key_release: Value, + created_at: u64, +) -> LibraryShareGrant { + let digest = Sha256::digest(format!("{recipient}:{cid}:{policy}:{created_at}")); + LibraryShareGrant { + schema: "elastos.library.share-grant/v1".to_string(), + grant_id: format!("share:{}", hex::encode(&digest[..16])), + recipient: recipient.to_string(), + uri: uri.to_string(), + cid: cid.to_string(), + policy: policy.to_string(), + key_release, + created_at, + } +} + +fn shared_access_receipt( + record: &LibraryPublishRecord, + recipient: &str, + recipient_proof: Option<&Value>, +) -> anyhow::Result { + let policy = record.share_policy.as_deref().unwrap_or("public_link"); + match policy { + "public_link" => Ok(json!({ + "schema": "elastos.library.shared-access.receipt/v1", + "policy": "public_link", + "recipient": recipient.trim(), + "grant_id": null, + "content_security": record.content_security.clone(), + "key_release": default_share_key_release(), + "recipient_proof": shared_access_recipient_proof_state(recipient.trim(), false, "not_required_for_public_link", None, None), + "decision": shared_access_decision("public_link", recipient.trim(), None, true, "public_link"), + "open": shared_access_open_contract(record, "public_link", recipient.trim(), None, &default_share_key_release(), false) + })), + "recipient_scoped" => { + let normalized = normalized_share_recipients(&[recipient.to_string()])?; + let recipient = normalized + .first() + .ok_or_else(|| anyhow!("library shared_access recipient is required"))?; + let grant = record + .share_grants + .iter() + .find(|grant| grant.recipient == *recipient && grant.policy == "recipient_scoped") + .ok_or_else(|| anyhow!("library shared_access recipient is not authorized"))?; + let proof_state = validate_shared_access_recipient_proof(recipient, recipient_proof)?; + Ok(json!({ + "schema": "elastos.library.shared-access.receipt/v1", + "policy": "recipient_scoped", + "recipient": recipient, + "grant_id": grant.grant_id, + "content_security": record.content_security.clone(), + "key_release": grant.key_release.clone(), + "recipient_proof": proof_state, + "decision": shared_access_decision("recipient_scoped", recipient, Some(&grant.grant_id), true, "recipient grant and Runtime recipient proof matched"), + "open": shared_access_open_contract(record, "recipient_scoped", recipient, Some(&grant.grant_id), &grant.key_release, true) + })) + } + other => bail!("unsupported library share policy: {other}"), + } +} + +fn validate_shared_access_recipient_proof( + recipient: &str, + proof: Option<&Value>, +) -> anyhow::Result { + let proof = proof.ok_or_else(|| { + anyhow!( + "library shared_access requires Runtime recipient_proof for recipient_scoped policy" + ) + })?; + let schema = proof + .get("schema") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("library shared_access recipient_proof requires schema"))?; + if schema != "elastos.library.recipient-proof/v1" { + bail!("library shared_access recipient_proof schema is unsupported"); + } + let source = proof + .get("source") + .and_then(Value::as_str) + .unwrap_or("unknown"); + if source != "runtime-launch-grant" { + bail!("library shared_access recipient_proof source is unsupported"); + } + let proof_binding_id = proof + .get("proof_binding_id") + .and_then(Value::as_str) + .ok_or_else(|| { + anyhow!("library shared_access recipient_proof requires proof_binding_id") + })?; + if !proof_binding_id.starts_with("proof:passkey:") { + bail!("library shared_access recipient_proof requires passkey proof binding"); + } + let claimed = proof + .get("recipient") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("library shared_access recipient_proof requires recipient"))?; + let normalized_requested = normalized_share_recipients(&[recipient.to_string()])?; + let normalized_claimed = normalized_share_recipients(&[claimed.to_string()])?; + if normalized_requested.first() != normalized_claimed.first() { + bail!("library shared_access recipient_proof recipient mismatch"); + } + let session_id = proof + .get("session_id") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()); + Ok(shared_access_recipient_proof_state( + normalized_requested + .first() + .map(String::as_str) + .unwrap_or(recipient), + true, + source, + Some(proof_binding_id), + session_id, + )) +} + +fn shared_access_recipient_proof_state( + recipient: &str, + verified: bool, + source: &str, + proof_binding_id: Option<&str>, + session_id: Option<&str>, +) -> Value { + json!({ + "schema": "elastos.library.recipient-proof-state/v1", + "recipient": recipient, + "verified": verified, + "source": source, + "proof_binding_id": proof_binding_id, + "session_id": session_id, + }) +} + +fn shared_access_decision( + policy: &str, + recipient: &str, + grant_id: Option<&str>, + allowed: bool, + reason: &str, +) -> Value { + json!({ + "schema": "elastos.library.access-decision/v1", + "policy": policy, + "recipient": recipient, + "grant_id": grant_id, + "allowed": allowed, + "reason": reason, + }) +} + +fn shared_access_open_contract( + record: &LibraryPublishRecord, + policy: &str, + recipient: &str, + grant_id: Option<&str>, + key_release: &Value, + recipient_proof_verified: bool, +) -> Value { + let key_release_required = key_release + .get("required") + .and_then(Value::as_bool) + .unwrap_or(false); + json!({ + "schema": "elastos.library.shared-open/v1", + "uri": format!("elastos://{}", record.cid), + "cid": record.cid, + "policy": policy, + "recipient": recipient, + "grant_id": grant_id, + "provider": "content-provider", + "transport": "runtime-provider-fetch", + "published_payload": record + .content_security + .get("published_payload") + .and_then(Value::as_str) + .unwrap_or("plain_content"), + "recipient_proof_verified": recipient_proof_verified, + "key_release_required": key_release_required, + "drm_provider_required": key_release_required, + "rights_provider_required": key_release_required, + "key_provider_required": key_release_required, + "decrypt_provider_required": key_release_required, + "required_providers": protected_content_provider_requirements(key_release_required), + "status": if key_release_required { + "blocked_until_drm_rights_key_decrypt_providers" + } else { + "ready_for_plain_content_fetch" + }, + "remote_enforcement": share_remote_enforcement_contract(policy, key_release), + }) +} + +fn library_roots(data_dir: &Path, principal_id: &str) -> Vec { + let root = crate::auth::principal_localhost_root(principal_id); + let mut roots: Vec<_> = [ + ("home", "Home", root.clone(), "principal-root"), + ("desktop", "Desktop", format!("{root}/Desktop"), "directory"), + ( + "documents", + "Documents", + format!("{root}/Documents"), + "directory", + ), + ( + "pictures", + "Pictures", + format!("{root}/Pictures"), + "directory", + ), + ("videos", "Videos", format!("{root}/Videos"), "directory"), + ( + "downloads", + "Downloads", + format!("{root}/Downloads"), + "directory", + ), + ("public", "Public", format!("{root}/Public"), "directory"), + ( + "webspaces", + "Spaces", + "localhost://WebSpaces".to_string(), + "webspace-root", + ), + ] + .into_iter() + .map(|(id, label, uri, kind)| LibraryRoot { + schema: LIBRARY_ROOT_SCHEMA, + id, + label, + uri, + kind, + metadata: None, + }) + .collect(); + let trash_uri = format!("{root}/.Trash"); + let (empty, item_count) = trash_root_state(data_dir, principal_id, &trash_uri); + roots.push(LibraryRoot { + schema: LIBRARY_ROOT_SCHEMA, + id: "trash", + label: "Trash", + uri: trash_uri, + kind: "directory", + metadata: Some(json!({ + "schema": "elastos.library.trash-root/v1", + "empty": empty, + "item_count": item_count, + })), + }); + roots +} + +fn trash_root_state(data_dir: &Path, principal_id: &str, trash_uri: &str) -> (bool, usize) { + let Ok(target) = library_target(data_dir, principal_id, trash_uri) else { + return (true, 0); + }; + let Ok(entries) = fs::read_dir(&target.path) else { + return (true, 0); + }; + let count = entries.filter_map(Result::ok).count(); + (count == 0, count) +} + +fn move_library_object( + data_dir: &Path, + principal_id: &str, + from_uri: &str, + to_uri: &str, +) -> anyhow::Result<()> { + let from = library_target(data_dir, principal_id, from_uri)?; + let to = library_target(data_dir, principal_id, to_uri)?; + if !from.path.exists() { + bail!("library source object not found"); + } + if to.path.exists() { + bail!("library destination already exists"); + } + if from.path.is_dir() { + move_library_directory(data_dir, principal_id, &from, &to)?; + } else { + move_library_file(data_dir, principal_id, &from, &to)?; + } + Ok(()) +} + +fn copy_library_object( + data_dir: &Path, + principal_id: &str, + from_uri: &str, + to_uri: &str, +) -> anyhow::Result<()> { + let from = library_target(data_dir, principal_id, from_uri)?; + let to = library_target(data_dir, principal_id, to_uri)?; + if !from.path.exists() { + bail!("library source object not found"); + } + if to.path.exists() { + bail!("library destination already exists"); + } + if from.path.is_dir() { + copy_library_directory(data_dir, principal_id, &from, &to)?; + } else { + copy_library_file(data_dir, principal_id, &from, &to)?; + } + Ok(()) +} + +fn copy_library_directory( + data_dir: &Path, + principal_id: &str, + from: &LibraryTarget, + to: &LibraryTarget, +) -> anyhow::Result<()> { + fs::create_dir_all(&to.path)?; + for entry in fs::read_dir(&from.path)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + let child_from_uri = format!("{}/{}", from.uri, name); + let child_to_uri = format!("{}/{}", to.uri, name); + let child_from = library_target(data_dir, principal_id, &child_from_uri)?; + let child_to = library_target(data_dir, principal_id, &child_to_uri)?; + if child_from.path.is_dir() { + copy_library_directory(data_dir, principal_id, &child_from, &child_to)?; + } else { + copy_library_file(data_dir, principal_id, &child_from, &child_to)?; + } + } + Ok(()) +} + +fn copy_library_file( + data_dir: &Path, + principal_id: &str, + from: &LibraryTarget, + to: &LibraryTarget, +) -> anyhow::Result<()> { + let bytes = read_library_file_bytes(data_dir, principal_id, from)?; + if let Some(parent) = to.path.parent() { + fs::create_dir_all(parent)?; + } + crate::auth::write_principal_root_object( + data_dir, + principal_id, + &to.localhost_root, + &to.uri, + &to.path, + &bytes, + )?; + Ok(()) +} + +fn move_library_directory( + data_dir: &Path, + principal_id: &str, + from: &LibraryTarget, + to: &LibraryTarget, +) -> anyhow::Result<()> { + fs::create_dir_all(&to.path)?; + for entry in fs::read_dir(&from.path)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + let child_from_uri = format!("{}/{}", from.uri, name); + let child_to_uri = format!("{}/{}", to.uri, name); + let child_from = library_target(data_dir, principal_id, &child_from_uri)?; + let child_to = library_target(data_dir, principal_id, &child_to_uri)?; + if child_from.path.is_dir() { + move_library_directory(data_dir, principal_id, &child_from, &child_to)?; + } else { + move_library_file(data_dir, principal_id, &child_from, &child_to)?; + } + } + fs::remove_dir(&from.path)?; + Ok(()) +} + +fn move_library_file( + data_dir: &Path, + principal_id: &str, + from: &LibraryTarget, + to: &LibraryTarget, +) -> anyhow::Result<()> { + let bytes = read_library_file_bytes(data_dir, principal_id, from)?; + if let Some(parent) = to.path.parent() { + fs::create_dir_all(parent)?; + } + crate::auth::write_principal_root_object( + data_dir, + principal_id, + &to.localhost_root, + &to.uri, + &to.path, + &bytes, + )?; + fs::remove_file(&from.path)?; + Ok(()) +} + +fn check_revision( + data_dir: &Path, + principal_id: &str, + uri: &str, + expected: Option<&str>, +) -> anyhow::Result<()> { + let Some(expected) = expected.filter(|value| !value.trim().is_empty()) else { + return Ok(()); + }; + let object = library_object(data_dir, principal_id, uri)?; + if object.revision != expected { + bail!("library object revision precondition failed"); + } + Ok(()) +} + +fn child_uri(parent_uri: &str, name: &str) -> anyhow::Result { + let name = clean_object_name(name)?; + Ok(format!("{}/{}", parent_uri.trim_end_matches('/'), name)) +} + +fn unique_child_uri( + data_dir: &Path, + principal_id: &str, + parent_uri: &str, + name: &str, +) -> anyhow::Result { + let mut candidate = child_uri(parent_uri, name)?; + if !library_target(data_dir, principal_id, &candidate)? + .path + .exists() + { + return Ok(candidate); + } + let timestamp = now_ts(); + let (stem, ext) = name.rsplit_once('.').unwrap_or((name, "")); + for index in 0..1_000 { + let suffix = if index == 0 { + timestamp.to_string() + } else { + format!("{timestamp}-{index}") + }; + let fallback = if ext.is_empty() { + format!("{stem} ({suffix})") + } else { + format!("{stem} ({suffix}).{ext}") + }; + candidate = child_uri(parent_uri, &fallback)?; + if !library_target(data_dir, principal_id, &candidate)? + .path + .exists() + { + return Ok(candidate); + } + } + bail!("failed to allocate unique Library object name") +} + +fn clean_object_name(name: &str) -> anyhow::Result<&str> { + let name = name.trim(); + if name.is_empty() + || name.contains('/') + || name.contains('\\') + || name.contains('\0') + || name == "." + || name == ".." + { + bail!("invalid library object name"); + } + Ok(name) +} + +fn library_uri_parent(uri: &str) -> anyhow::Result<&str> { + uri.rsplit_once('/') + .map(|(parent, _)| parent) + .filter(|parent| !parent.is_empty()) + .ok_or_else(|| anyhow!("library object URI has no parent")) +} + +fn is_trash_uri(localhost_root: &str, uri: &str) -> bool { + is_trash_root_uri(localhost_root, uri) || is_trash_child_uri(localhost_root, uri) +} + +fn is_trash_root_uri(localhost_root: &str, uri: &str) -> bool { + uri == format!("{localhost_root}/.Trash") +} + +fn is_trash_child_uri(localhost_root: &str, uri: &str) -> bool { + uri.strip_prefix(&format!("{localhost_root}/.Trash/")) + .is_some_and(|rest| !rest.is_empty()) +} + +fn restore_uri_from_trash_record( + data_dir: &Path, + principal_id: &str, + trash_target: &LibraryTarget, + trash_record: Option<&LibraryTrashRecord>, +) -> anyhow::Result { + let record = + trash_record.ok_or_else(|| anyhow!("library Trash restore metadata is missing"))?; + let original_uri = clean_library_uri(&trash_target.localhost_root, &record.original_uri)?; + let parent_uri = library_uri_parent(&original_uri)?; + let name = original_uri + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or(record.original_name.as_str()); + unique_child_uri(data_dir, principal_id, parent_uri, name) +} + +fn trash_record_uri(localhost_root: &str, trash_uri: &str) -> String { + let digest = hex::encode(Sha256::digest(trash_uri.as_bytes())); + format!("{localhost_root}/.AppData/LocalHost/.Runtime/Library/Trash/{digest}.json") +} + +fn read_trash_record( + data_dir: &Path, + principal_id: &str, + trash_uri: &str, +) -> anyhow::Result { + let root = crate::auth::principal_localhost_root(principal_id); + let record_uri = trash_record_uri(&root, trash_uri); + let record_path = rooted_localhost_fs_path(data_dir, &record_uri) + .ok_or_else(|| anyhow!("invalid library Trash record path"))?; + let bytes = crate::auth::read_principal_root_object( + data_dir, + principal_id, + &root, + &record_uri, + &record_path, + )?; + Ok(serde_json::from_slice(&bytes)?) +} + +fn write_trash_record( + data_dir: &Path, + principal_id: &str, + record: &LibraryTrashRecord, +) -> anyhow::Result<()> { + let root = crate::auth::principal_localhost_root(principal_id); + let record_uri = trash_record_uri(&root, &record.trash_uri); + let record_path = rooted_localhost_fs_path(data_dir, &record_uri) + .ok_or_else(|| anyhow!("invalid library Trash record path"))?; + if let Some(parent) = record_path.parent() { + fs::create_dir_all(parent)?; + } + let bytes = serde_json::to_vec_pretty(record)?; + crate::auth::write_principal_root_object( + data_dir, + principal_id, + &root, + &record_uri, + &record_path, + &bytes, + ) +} + +fn remove_trash_record(data_dir: &Path, principal_id: &str, trash_uri: &str) -> anyhow::Result<()> { + let root = crate::auth::principal_localhost_root(principal_id); + let record_uri = trash_record_uri(&root, trash_uri); + let record_path = rooted_localhost_fs_path(data_dir, &record_uri) + .ok_or_else(|| anyhow!("invalid library Trash record path"))?; + match fs::remove_file(record_path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err.into()), + } +} + +fn publish_record_uri(localhost_root: &str, object_uri: &str) -> String { + let digest = hex::encode(Sha256::digest(object_uri.as_bytes())); + format!("{localhost_root}/.AppData/LocalHost/.Runtime/Library/Published/{digest}.json") +} + +fn read_publish_record( + data_dir: &Path, + principal_id: &str, + object_uri: &str, +) -> anyhow::Result { + let root = crate::auth::principal_localhost_root(principal_id); + let record_uri = publish_record_uri(&root, object_uri); + let record_path = rooted_localhost_fs_path(data_dir, &record_uri) + .ok_or_else(|| anyhow!("invalid library publish record path"))?; + let bytes = crate::auth::read_principal_root_object( + data_dir, + principal_id, + &root, + &record_uri, + &record_path, + )?; + Ok(serde_json::from_slice(&bytes)?) +} + +fn write_publish_record( + data_dir: &Path, + principal_id: &str, + record: &LibraryPublishRecord, +) -> anyhow::Result<()> { + let root = crate::auth::principal_localhost_root(principal_id); + let record_uri = publish_record_uri(&root, &record.object_uri); + let record_path = rooted_localhost_fs_path(data_dir, &record_uri) + .ok_or_else(|| anyhow!("invalid library publish record path"))?; + if let Some(parent) = record_path.parent() { + fs::create_dir_all(parent)?; + } + let bytes = serde_json::to_vec_pretty(record)?; + crate::auth::write_principal_root_object( + data_dir, + principal_id, + &root, + &record_uri, + &record_path, + &bytes, + ) +} + +fn library_events( + data_dir: &Path, + principal_id: &str, + uri_filter: Option<&str>, + since: Option, + limit: Option, +) -> anyhow::Result> { + let root = crate::auth::principal_localhost_root(principal_id); + let uri_filter = uri_filter + .map(|uri| clean_library_uri(&root, uri)) + .transpose()?; + let limit = limit.unwrap_or(64).clamp(1, MAX_LIBRARY_EVENTS); + let mut events = read_library_events(data_dir, principal_id)?; + events.retain(|event| { + since.map(|since| event.at > since).unwrap_or(true) + && uri_filter + .as_deref() + .map(|uri| library_event_matches_uri(event, uri)) + .unwrap_or(true) + }); + if events.len() > limit { + let keep_from = events.len() - limit; + events.drain(0..keep_from); + } + Ok(events) +} + +fn append_library_event( + data_dir: &Path, + principal_id: &str, + op: &str, + uri: &str, + details: Value, +) -> anyhow::Result { + let mut events = read_library_events(data_dir, principal_id)?; + let event = LibraryEvent { + schema: LIBRARY_EVENT_SCHEMA.to_string(), + event_id: library_event_id(op, uri, &details), + op: op.to_string(), + uri: uri.to_string(), + at: now_ts(), + details, + }; + events.push(event.clone()); + if events.len() > MAX_LIBRARY_EVENTS { + let keep_from = events.len() - MAX_LIBRARY_EVENTS; + events.drain(0..keep_from); + } + write_library_events(data_dir, principal_id, &events)?; + library_event_notifier().notify_waiters(); + Ok(event) +} + +fn read_library_events(data_dir: &Path, principal_id: &str) -> anyhow::Result> { + let root = crate::auth::principal_localhost_root(principal_id); + let event_uri = library_event_log_uri(&root); + let event_path = rooted_localhost_fs_path(data_dir, &event_uri) + .ok_or_else(|| anyhow!("invalid library event log path"))?; + if !event_path.exists() { + return Ok(Vec::new()); + } + let bytes = crate::auth::read_principal_root_object( + data_dir, + principal_id, + &root, + &event_uri, + &event_path, + )?; + let text = String::from_utf8(bytes).context("library event log must be utf-8 jsonl")?; + text.lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).context("invalid library event entry")) + .collect() +} + +fn write_library_events( + data_dir: &Path, + principal_id: &str, + events: &[LibraryEvent], +) -> anyhow::Result<()> { + let root = crate::auth::principal_localhost_root(principal_id); + let event_uri = library_event_log_uri(&root); + let event_path = rooted_localhost_fs_path(data_dir, &event_uri) + .ok_or_else(|| anyhow!("invalid library event log path"))?; + if let Some(parent) = event_path.parent() { + fs::create_dir_all(parent)?; + } + let mut bytes = Vec::new(); + for event in events { + serde_json::to_writer(&mut bytes, event)?; + bytes.push(b'\n'); + } + crate::auth::write_principal_root_object( + data_dir, + principal_id, + &root, + &event_uri, + &event_path, + &bytes, + ) +} + +fn library_event_log_uri(localhost_root: &str) -> String { + format!("{localhost_root}/.AppData/LocalHost/.Runtime/Library/events.jsonl") +} + +fn library_event_id(op: &str, uri: &str, details: &Value) -> String { + let mut hasher = Sha256::new(); + hasher.update(now_nanos().to_be_bytes()); + hasher.update(op.as_bytes()); + hasher.update(uri.as_bytes()); + hasher.update(details.to_string().as_bytes()); + let digest = hasher.finalize(); + format!("library:event:{}", hex::encode(&digest[..16])) +} + +fn library_event_matches_uri(event: &LibraryEvent, uri: &str) -> bool { + uri_matches_filter(&event.uri, uri) + || [ + "old_uri", + "original_uri", + "source_uri", + "trash_uri", + "target_uri", + ] + .into_iter() + .any(|field| { + event + .details + .get(field) + .and_then(Value::as_str) + .map(|value| uri_matches_filter(value, uri)) + .unwrap_or(false) + }) + || event + .details + .get("object") + .and_then(|object| object.get("uri")) + .and_then(Value::as_str) + .map(|value| uri_matches_filter(value, uri)) + .unwrap_or(false) +} + +fn uri_matches_filter(uri: &str, filter: &str) -> bool { + uri == filter + || uri + .strip_prefix(filter.trim_end_matches('/')) + .is_some_and(|rest| rest.starts_with('/')) +} + +fn directory_revision(path: &Path, uri: &str) -> anyhow::Result { + let mut hasher = Sha256::new(); + hasher.update(uri.as_bytes()); + if path.exists() { + for entry in fs::read_dir(path)? { + let entry = entry?; + hasher.update(entry.file_name().to_string_lossy().as_bytes()); + let metadata = entry.metadata()?; + hasher.update(metadata.len().to_be_bytes()); + if let Some(modified) = system_time_secs(metadata.modified().ok()) { + hasher.update(modified.to_be_bytes()); + } + } + } + Ok(format!("rev:{}", hex::encode(hasher.finalize()))) +} + +fn system_time_secs(time: Option) -> Option { + time.and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()) +} + +fn now_ts() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn now_nanos() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() +} + +fn mime_for_name(name: &str) -> &'static str { + let lower = name.to_lowercase(); + if lower.ends_with(".md") || lower.ends_with(".txt") { + "text/plain" + } else if lower.ends_with(".html") { + "text/html" + } else if lower.ends_with(".json") { + "application/json" + } else if lower.ends_with(".png") { + "image/png" + } else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { + "image/jpeg" + } else if lower.ends_with(".gif") { + "image/gif" + } else if lower.ends_with(".pdf") { + "application/pdf" + } else if lower.ends_with(".tar") { + "application/x-tar" + } else if lower.ends_with(".tar.gz") || lower.ends_with(".tgz") { + "application/gzip" + } else if lower.ends_with(".zip") { + "application/zip" + } else if lower.ends_with(".mp4") { + "video/mp4" + } else if lower.ends_with(".mp3") { + "audio/mpeg" + } else { + "application/octet-stream" + } +} + +fn viewer_options_for_name(data_dir: &Path, name: &str) -> Vec { + viewer_ids_for_name(name) + .into_iter() + .filter_map(|id| installed_viewer_option(data_dir, id)) + .collect() +} + +fn viewer_ids_for_name(name: &str) -> Vec<&'static str> { + let lower = name.to_lowercase(); + if archive_family_for_name(&lower).is_some() { + vec!["archive-manager"] + } else if lower.ends_with(".md") || lower.ends_with(".txt") { + vec!["documents"] + } else if lower.ends_with(".png") + || lower.ends_with(".jpg") + || lower.ends_with(".jpeg") + || lower.ends_with(".gif") + { + vec!["image-viewer"] + } else if lower.ends_with(".mp4") { + vec!["video-viewer"] + } else if lower.ends_with(".pdf") { + vec!["documents"] + } else if lower.ends_with(".gba") || lower.ends_with(".gb") || lower.ends_with(".gbc") { + vec!["gba-emulator"] + } else { + Vec::new() + } +} + +fn installed_viewer_option(data_dir: &Path, id: &str) -> Option { + crate::api::browser_capsules::list_launchable_browser_capsules(data_dir) + .into_iter() + .find(|capsule| capsule.name == id && capsule.role == elastos_common::CapsuleRole::Viewer) + .map(|capsule| LibraryViewerOption { + id: capsule.name.clone(), + label: viewer_label(&capsule.name).to_string(), + description: capsule.description, + default: true, + }) +} + +fn viewer_label(id: &str) -> &str { + match id { + "documents" => "Documents", + "image-viewer" => "Image Viewer", + "video-viewer" => "Video Viewer", + "gba-emulator" => "GBA Emulator", + "archive-manager" => "Archive", + _ => id, + } +} + +fn provider_ok(data: Value) -> Value { + json!({ + "status": "ok", + "data": data, + }) +} + +fn provider_error(code: &str, message: &str) -> Value { + json!({ + "status": "error", + "code": code, + "message": message, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn object_provider_exposes_object_scheme_only() { + let registry = Arc::new(ProviderRegistry::new()); + let provider = ObjectProvider::new(PathBuf::new(), Arc::downgrade(®istry)); + let schemes = provider.schemes(); + + assert!(schemes.contains(&"object")); + assert!(!schemes.contains(&"library")); + assert_eq!(provider.name(), "object-provider"); + } +} diff --git a/elastos/crates/elastos-server/src/main.rs b/elastos/crates/elastos-server/src/main.rs index 271d6ca7..5c89740c 100755 --- a/elastos/crates/elastos-server/src/main.rs +++ b/elastos/crates/elastos-server/src/main.rs @@ -778,6 +778,140 @@ pub(crate) enum SiteCommand { #[derive(Subcommand)] pub(crate) enum WebspaceCommand { + /// Show the persistent WebSpace mount table and built-in mounts + Mounts { + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Show registered external WebSpace resolver adapters + Adapters { + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Register or update an external WebSpace resolver adapter + RegisterAdapter { + /// Resolver/provider identifier used by mounted WebSpaces + resolver: String, + /// Human label for operator UX + #[arg(long)] + label: Option, + /// Endpoint URI or provider-qualified route; credentials are redacted in status output + #[arg(long)] + endpoint_uri: Option, + /// Provider id that owns this adapter, when available + #[arg(long)] + provider: Option, + /// Adapter state: configured, connected, unavailable, or disabled + #[arg(long)] + state: Option, + /// Adapter capability label, repeatable; defaults to metadata_index + #[arg(long = "capability")] + capabilities: Vec, + /// Mark new mounts through this adapter mutable by default + #[arg(long)] + mutable_default: bool, + /// Operator-facing description + #[arg(long)] + description: Option, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Remove an external WebSpace resolver adapter registration + UnregisterAdapter { + /// Resolver/provider identifier to remove + resolver: String, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Record a safe external WebSpace resolver adapter health check + CheckAdapter { + /// Resolver/provider identifier to check + resolver: String, + /// Health result: ok, failed, skipped, or unknown + #[arg(long)] + result: Option, + /// Optional adapter state override: configured, connected, unavailable, or disabled + #[arg(long)] + state: Option, + /// Short opaque failure code; credentials/messages are rejected + #[arg(long)] + error_code: Option, + /// Adapter capability label, repeatable; updates capabilities when supplied + #[arg(long = "capability")] + capabilities: Vec, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Show WebSpace resolver metadata health without exposing provider credentials + Health { + /// Optional WebSpace moniker to inspect + moniker: Option, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Persistently mount an external namespace under localhost://WebSpaces/ + Mount { + /// Local WebSpace moniker, for example Google + moniker: String, + /// External target URI, for example google://drive or https://example.com + target_uri: String, + /// Optional namespace URI override, for example google:// + #[arg(long)] + namespace_uri: Option, + /// Resolver/provider identifier required for live access + #[arg(long)] + resolver: Option, + /// Operator-facing description + #[arg(long)] + description: Option, + /// Mark the mount mutable. Content writes still require provider-to-provider support. + #[arg(long)] + mutable: bool, + /// Cache policy label, for example metadata-only + #[arg(long)] + cache_policy: Option, + /// Sync policy label, for example manual + #[arg(long)] + sync_policy: Option, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Remove a persistent WebSpace mount + Unmount { + /// Local WebSpace moniker to remove + moniker: String, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Replace resolver-discovered child metadata for a mounted WebSpace + Index { + /// Local WebSpace moniker to index + moniker: String, + /// JSON file containing an array of { path, kind, target_uri?, resolver_state?, readonly?, description? } + entries_json: PathBuf, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Refresh resolver metadata for a WebSpace handle, optionally replacing its resolver index + Refresh { + /// Moniker or handle path to refresh, for example: Google + target: String, + /// Optional JSON file containing a replacement resolver index + #[arg(long)] + entries_json: Option, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, /// List the currently known WebSpace monikers or the typed children under a mounted handle List { /// Optional moniker or handle path (for example: Elastos or Elastos/peer) @@ -794,6 +928,74 @@ pub(crate) enum WebspaceCommand { #[arg(long)] json: bool, }, + /// Persist or inspect the current provider-owned object head for a WebSpace handle + Head { + /// Moniker or handle path, for example: Elastos/content/ + target: String, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Show metadata-cache status for a WebSpace handle + CacheStatus { + /// Moniker or handle path, for example: Google/Drive/file.pdf + target: String, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Refresh provider-owned metadata cache state for a WebSpace handle + Cache { + /// Moniker or handle path, for example: Google/Drive/file.pdf + target: String, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Show sync/dirty status for a WebSpace handle + SyncStatus { + /// Moniker or handle path, for example: Google/Drive/file.pdf + target: String, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Mark provider-owned WebSpace metadata/fork head as synced + Sync { + /// Moniker or handle path, for example: ProjectFork + target: String, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, + /// Create a mutable metadata fork of a WebSpace handle under a new moniker + Fork { + /// Source moniker or handle path to fork + source: String, + /// New local WebSpace moniker + moniker: String, + /// Optional target URI override for the fork + #[arg(long)] + target_uri: Option, + /// Optional resolver/provider override + #[arg(long)] + resolver: Option, + /// Operator-facing description + #[arg(long)] + description: Option, + /// Keep the fork readonly instead of mutable + #[arg(long)] + readonly: bool, + /// Cache policy label, for example metadata-only + #[arg(long)] + cache_policy: Option, + /// Sync policy label, for example manual + #[arg(long)] + sync_policy: Option, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, } #[derive(Subcommand)] diff --git a/elastos/crates/elastos-server/src/publish.rs b/elastos/crates/elastos-server/src/publish.rs index 1865ac20..a1d653cd 100644 --- a/elastos/crates/elastos-server/src/publish.rs +++ b/elastos/crates/elastos-server/src/publish.rs @@ -679,6 +679,7 @@ fn publish_profile_capsules(profile: &str, available: &[String]) -> anyhow::Resu "drm-provider".to_string(), "ipfs-provider".to_string(), "key-provider".to_string(), + "object-provider".to_string(), "localhost-provider".to_string(), "rights-provider".to_string(), "tunnel-provider".to_string(), @@ -1584,6 +1585,7 @@ mod tests { assert!(selected.contains(&"chain-provider".to_string())); assert!(selected.contains(&"wallet-provider".to_string())); + assert!(selected.contains(&"object-provider".to_string())); assert!(selected.contains(&"drm-provider".to_string())); assert!(selected.contains(&"rights-provider".to_string())); assert!(selected.contains(&"key-provider".to_string())); diff --git a/elastos/crates/elastos-server/src/server_infra.rs b/elastos/crates/elastos-server/src/server_infra.rs index 6a0a76b3..756c3545 100644 --- a/elastos/crates/elastos-server/src/server_infra.rs +++ b/elastos/crates/elastos-server/src/server_infra.rs @@ -4,8 +4,11 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use elastos_common::localhost::{ensure_file_backed_roots, file_backed_prefixes}; +use elastos_runtime::provider::{ + ProviderInvocation, ProviderInvocationTransport, ProviderTransfer, +}; use elastos_runtime::{capability, content, namespace, primitives, provider, session}; -use elastos_server::content::ContentProvider; +use elastos_server::content::{ContentProvider, ContentProviderExternalConfigs}; use elastos_server::documents::DocumentsProvider; use elastos_server::sources::{default_data_dir, local_session_owner}; use elastos_server::{api, fetcher, ownership}; @@ -24,6 +27,18 @@ pub(crate) struct ServerInfrastructure { pub(crate) host_helpers: Vec, } +const CONTENT_REPAIR_SCHEDULER_ENV: &str = "ELASTOS_CONTENT_REPAIR_SCHEDULER"; +const CONTENT_REPAIR_SCHEDULER_INTERVAL_ENV: &str = "ELASTOS_CONTENT_REPAIR_INTERVAL_SECS"; +const CONTENT_REPAIR_SCHEDULER_LIMIT_ENV: &str = "ELASTOS_CONTENT_REPAIR_LIMIT"; +const CONTENT_REPAIR_SCHEDULER_MAX_ATTEMPTS_ENV: &str = "ELASTOS_CONTENT_REPAIR_MAX_ATTEMPTS"; +const CONTENT_REPAIR_SCHEDULER_FAILURE_BUDGET_ENV: &str = "ELASTOS_CONTENT_REPAIR_FAILURE_BUDGET"; +const CONTENT_REPAIR_SCHEDULER_INCLUDE_HEALTHY_ENV: &str = "ELASTOS_CONTENT_REPAIR_INCLUDE_HEALTHY"; +const CONTENT_REPAIR_SCHEDULER_MIN_INTERVAL_SECS: u64 = 60; +const CONTENT_REPAIR_SCHEDULER_DEFAULT_INTERVAL_SECS: u64 = 15 * 60; +const CONTENT_REPAIR_SCHEDULER_DEFAULT_LIMIT: u64 = 10; +const CONTENT_REPAIR_SCHEDULER_DEFAULT_MAX_ATTEMPTS: u64 = 3; +const CONTENT_REPAIR_SCHEDULER_DEFAULT_FAILURE_BUDGET: u64 = 5; + pub(crate) async fn setup_server_infrastructure() -> anyhow::Result { setup_server_infrastructure_impl(true).await } @@ -69,9 +84,21 @@ async fn setup_server_infrastructure_impl( ensure_file_backed_roots(&data_dir).ok(); let provider_registry = Arc::new(provider::ProviderRegistry::new()); let mut managed_host_processes = Vec::new(); - let content_provider = Arc::new(ContentProvider::new( + let mut external_availability_registered = false; + let content_provider = Arc::new(ContentProvider::new_with_external_configs( data_dir.clone(), Arc::downgrade(&provider_registry), + ContentProviderExternalConfigs { + operator_alert_sink: content_operator_alert_sink_config_from_env(), + storage_market_admission: content_storage_market_admission_config_from_env(), + external_repair_fleet: content_external_repair_fleet_config_from_env(), + federated_operator_alert_exchange: + content_federated_operator_alert_exchange_config_from_env(), + federated_quota_ledger_exchange: + content_federated_quota_ledger_exchange_config_from_env(), + federated_abuse_control_exchange: + content_federated_abuse_control_exchange_config_from_env(), + }, )); provider_registry.register(content_provider.clone()).await; if let Err(err) = provider_registry @@ -87,6 +114,7 @@ async fn setup_server_infrastructure_impl( ))) .await; let device_key = elastos_identity::load_or_create_device_key(&data_dir)?; + let device_key_hex = hex::encode(device_key.as_ref()); let mut provider_cid = "sha256:unavailable".to_string(); let verify_provider_binary = |name: &str, path: &std::path::Path| -> anyhow::Result<()> { let checksum = crate::setup::verify_installed_component_binary(&data_dir, name, path)?; @@ -115,8 +143,6 @@ async fn setup_server_infrastructure_impl( hex::encode(elastos_runtime::signature::hash_content(&provider_bytes)) ); - let device_key_hex = hex::encode(device_key.as_ref()); - let config = provider::BridgeProviderConfig { base_path: data_dir.to_string_lossy().to_string(), allowed_paths: file_backed_prefixes(), @@ -172,26 +198,6 @@ async fn setup_server_infrastructure_impl( } } - if let Some(path) = crate::find_installed_provider_binary("webspace-provider") { - if let Err(e) = verify_provider_binary("webspace-provider", &path) { - tracing::warn!( - "Skipping webspace-provider due to verification failure: {}", - e - ); - } else { - match provider::ProviderBridge::spawn(&path, Default::default()).await { - Ok(bridge) => { - let provider: Arc = Arc::new( - provider::CapsuleProvider::with_scheme(Arc::new(bridge), "webspace"), - ); - provider_registry.register(provider).await; - tracing::info!("webspace-provider capsule from {}", path.display()); - } - Err(e) => tracing::warn!("Failed to spawn webspace-provider: {}", e), - } - } - } - let mut llama_endpoint: Option = None; if let Some(path) = crate::find_installed_provider_binary("llama-provider") { let mut llama_extra = serde_json::Map::new(); @@ -297,29 +303,6 @@ async fn setup_server_infrastructure_impl( } } - if let Some(path) = crate::find_installed_provider_binary("ipfs-provider") { - if let Err(e) = verify_provider_binary("ipfs-provider", &path) { - tracing::warn!("Skipping ipfs-provider due to verification failure: {}", e); - } else { - match provider::ProviderBridge::spawn(&path, Default::default()).await { - Ok(bridge) => { - let bridge = Arc::new(bridge); - let ipfs_provider: Arc = Arc::new( - provider::CapsuleProvider::with_scheme(Arc::clone(&bridge), "ipfs"), - ); - if let Err(e) = provider_registry - .register_sub_provider("ipfs", ipfs_provider) - .await - { - tracing::warn!("Failed to register elastos://ipfs sub-provider: {}", e); - } - tracing::info!("ipfs-provider capsule from {}", path.display()); - } - Err(e) => tracing::debug!("ipfs-provider unavailable: {}", e), - } - } - } - if let Some(availability_config) = availability_provider_config_from_env() { if let Some(path) = crate::find_installed_provider_binary("availability-provider") { if let Err(e) = verify_provider_binary("availability-provider", &path) { @@ -347,6 +330,8 @@ async fn setup_server_infrastructure_impl( "Failed to register elastos://availability sub-provider: {}", e ); + } else { + external_availability_registered = true; } tracing::info!("availability-provider capsule from {}", path.display()); } @@ -361,6 +346,169 @@ async fn setup_server_infrastructure_impl( } } + if let Some(path) = crate::find_installed_provider_binary("content-block-graph-provider") { + if let Err(e) = verify_provider_binary("content-block-graph-provider", &path) { + tracing::warn!( + "Skipping content-block-graph-provider due to verification failure: {}", + e + ); + } else { + let block_graph_config = provider::BridgeProviderConfig { + base_path: data_dir.to_string_lossy().to_string(), + extra: serde_json::json!({ + "backend": "kubo_coord" + }), + ..Default::default() + }; + match provider::ProviderBridge::spawn(&path, block_graph_config).await { + Ok(bridge) => { + let block_graph_provider: Arc = Arc::new( + provider::CapsuleProvider::with_scheme(Arc::new(bridge), "block-graph"), + ); + if let Err(e) = provider_registry + .register_sub_provider("block-graph", block_graph_provider) + .await + { + tracing::warn!( + "Failed to register elastos://block-graph sub-provider: {}", + e + ); + } + tracing::info!( + "content-block-graph-provider capsule from {}", + path.display() + ); + } + Err(e) => tracing::warn!("Failed to spawn content-block-graph-provider: {}", e), + } + } + } else { + tracing::warn!( + "content-block-graph-provider binary is not installed; arbitrary DAG repair will fail closed" + ); + } + + if let Some(path) = crate::find_installed_provider_binary("object-provider") { + if let Err(e) = verify_provider_binary("object-provider", &path) { + tracing::warn!( + "Skipping {} due to verification failure: {}", + "object-provider", + e + ); + } else { + let object_config = provider::BridgeProviderConfig { + base_path: data_dir.to_string_lossy().to_string(), + allowed_paths: file_backed_prefixes(), + read_only: false, + encryption_key: device_key_hex.clone(), + ..Default::default() + }; + match provider::ProviderBridge::spawn(&path, object_config).await { + Ok(bridge) => { + let bridge = Arc::new(bridge); + let object_provider: Arc = Arc::new( + provider::CapsuleProvider::with_scheme(bridge.clone(), "object"), + ); + provider_registry.register(object_provider).await; + tracing::info!( + "object-provider capsule from {} registered as object provider", + path.display() + ); + } + Err(e) => tracing::warn!("Failed to spawn object-provider: {}", e), + } + } + } else { + tracing::warn!( + "object-provider binary is not installed; Library object operations will fail closed" + ); + } + + if let Some(path) = crate::find_installed_provider_binary("webspace-provider") { + if let Err(e) = verify_provider_binary("webspace-provider", &path) { + tracing::warn!( + "Skipping webspace-provider due to verification failure: {}", + e + ); + } else { + let webspace_config = provider::BridgeProviderConfig { + base_path: data_dir.to_string_lossy().to_string(), + read_only: false, + ..Default::default() + }; + match provider::ProviderBridge::spawn(&path, webspace_config).await { + Ok(bridge) => { + let provider: Arc = Arc::new( + provider::CapsuleProvider::with_scheme(Arc::new(bridge), "webspace"), + ); + provider_registry.register(provider).await; + tracing::info!("webspace-provider capsule from {}", path.display()); + } + Err(e) => tracing::warn!("Failed to spawn webspace-provider: {}", e), + } + } + } else { + tracing::warn!( + "webspace-provider binary is not installed; WebSpace roots will fail closed" + ); + } + + if let Some(path) = crate::find_installed_provider_binary("operator-drive-adapter") { + if let Err(e) = verify_provider_binary("operator-drive-adapter", &path) { + tracing::warn!( + "Skipping operator-drive-adapter due to verification failure: {}", + e + ); + } else { + let adapter_config = provider::BridgeProviderConfig { + base_path: data_dir.to_string_lossy().to_string(), + read_only: false, + extra: operator_drive_adapter_config_from_env(&data_dir) + .unwrap_or(serde_json::Value::Null), + ..Default::default() + }; + match provider::ProviderBridge::spawn(&path, adapter_config).await { + Ok(bridge) => { + let provider: Arc = + Arc::new(provider::CapsuleProvider::with_scheme( + Arc::new(bridge), + "operator-drive-adapter", + )); + provider_registry.register(provider).await; + tracing::info!("operator-drive-adapter capsule from {}", path.display()); + } + Err(e) => tracing::warn!("Failed to spawn operator-drive-adapter: {}", e), + } + } + } + + if let Some(path) = crate::find_installed_provider_binary("ipfs-provider") { + if let Err(e) = verify_provider_binary("ipfs-provider", &path) { + tracing::warn!("Skipping ipfs-provider due to verification failure: {}", e); + } else { + match provider::ProviderBridge::spawn(&path, Default::default()).await { + Ok(bridge) => { + let bridge = Arc::new(bridge); + let ipfs_provider: Arc = Arc::new( + provider::CapsuleProvider::with_scheme(Arc::clone(&bridge), "ipfs"), + ); + if let Err(e) = provider_registry + .register_sub_provider("ipfs", ipfs_provider) + .await + { + tracing::warn!("Failed to register elastos://ipfs sub-provider: {}", e); + } + tracing::info!("ipfs-provider capsule from {}", path.display()); + } + Err(e) => tracing::warn!("ipfs-provider unavailable: {}", e), + } + } + } else { + tracing::warn!( + "ipfs-provider binary is not installed; elastos://content publish/fetch will fail closed" + ); + } + if let Some(path) = crate::find_installed_provider_binary("chain-provider") { if let Err(e) = verify_provider_binary("chain-provider", &path) { tracing::warn!("Skipping chain-provider due to verification failure: {}", e); @@ -621,14 +769,20 @@ async fn setup_server_infrastructure_impl( // Identity is DID (derived from device_key), not raw device_key. let (carrier_signing_key, carrier_did) = elastos_identity::derive_did(&device_key); { - match elastos_server::carrier::start_carrier_node( + match elastos_server::carrier::start_carrier_node_with_registry( &carrier_signing_key, &carrier_did, data_dir.clone(), + Some(Arc::downgrade(&provider_registry)), ) .await { Ok(carrier_node) => { + provider_registry + .set_carrier_invoker(Arc::new( + elastos_server::carrier::CarrierProviderInvoker::new(), + )) + .await; let gossip_provider: Arc = Arc::new(elastos_server::carrier::CarrierGossipProvider::new( carrier_node.gossip_state.clone(), @@ -639,6 +793,22 @@ async fn setup_server_infrastructure_impl( { tracing::warn!("Failed to register Carrier gossip provider: {}", e); } + if !external_availability_registered { + let availability_provider: Arc = + Arc::new( + elastos_server::carrier::CarrierAvailabilityProvider::with_provider_registry_data_dir_and_peer_attestation_exchange_config( + carrier_node.gossip_state.clone(), + Arc::downgrade(&provider_registry), + data_dir.clone(), + carrier_peer_attestation_exchange_config_from_env(), + )); + if let Err(e) = provider_registry + .register_sub_provider("availability", availability_provider) + .await + { + tracing::warn!("Failed to register Carrier availability provider: {}", e); + } + } // Hold the carrier node alive. Dropping it kills the endpoint. tokio::spawn(async move { let _node = carrier_node; @@ -654,6 +824,8 @@ async fn setup_server_infrastructure_impl( } } + maybe_spawn_content_repair_scheduler(provider_registry.clone()); + let namespace_path = data_dir.join("namespaces"); std::fs::create_dir_all(&namespace_path).ok(); let resolver_config = content::ResolverConfig { @@ -885,6 +1057,369 @@ fn availability_provider_config_from_env() -> Option { })) } +fn content_operator_alert_sink_config_from_env() -> Option { + if let Ok(raw) = std::env::var("ELASTOS_CONTENT_OPERATOR_ALERT_CONFIG") { + match serde_json::from_str::(&raw) { + Ok(value) => return Some(value), + Err(err) => { + tracing::warn!( + "Ignoring invalid ELASTOS_CONTENT_OPERATOR_ALERT_CONFIG JSON: {}", + err + ); + return None; + } + } + } + + let url = std::env::var("ELASTOS_CONTENT_OPERATOR_ALERT_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())?; + let mut config = serde_json::json!({ "url": url }); + if let Ok(value) = std::env::var("ELASTOS_CONTENT_OPERATOR_ALERT_AUTHORIZATION") { + let value = value.trim(); + if !value.is_empty() { + config["authorization"] = serde_json::Value::String(value.to_string()); + } + } + if let Ok(value) = std::env::var("ELASTOS_CONTENT_OPERATOR_ALERT_TIMEOUT_SECS") { + if let Ok(timeout_secs) = value.trim().parse::() { + config["timeout_secs"] = serde_json::Value::from(timeout_secs); + } + } + Some(config) +} + +fn content_federated_operator_alert_exchange_config_from_env() -> Option { + if let Ok(raw) = std::env::var("ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_CONFIG") { + match serde_json::from_str::(&raw) { + Ok(value) => return Some(value), + Err(err) => { + tracing::warn!( + "Ignoring invalid ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_CONFIG JSON: {}", + err + ); + return None; + } + } + } + + let url = std::env::var("ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())?; + let mut config = serde_json::json!({ "url": url }); + if let Ok(value) = + std::env::var("ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_AUTHORIZATION") + { + let value = value.trim(); + if !value.is_empty() { + config["authorization"] = serde_json::Value::String(value.to_string()); + } + } + if let Ok(value) = + std::env::var("ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_TIMEOUT_SECS") + { + if let Ok(timeout_secs) = value.trim().parse::() { + config["timeout_secs"] = serde_json::Value::from(timeout_secs); + } + } + Some(config) +} + +fn content_federated_quota_ledger_exchange_config_from_env() -> Option { + if let Ok(raw) = std::env::var("ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_CONFIG") { + match serde_json::from_str::(&raw) { + Ok(value) => return Some(value), + Err(err) => { + tracing::warn!( + "Ignoring invalid ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_CONFIG JSON: {}", + err + ); + return None; + } + } + } + + let url = std::env::var("ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())?; + let mut config = serde_json::json!({ "url": url }); + if let Ok(value) = + std::env::var("ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_AUTHORIZATION") + { + let value = value.trim(); + if !value.is_empty() { + config["authorization"] = serde_json::Value::String(value.to_string()); + } + } + if let Ok(value) = std::env::var("ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_TIMEOUT_SECS") + { + if let Ok(timeout_secs) = value.trim().parse::() { + config["timeout_secs"] = serde_json::Value::from(timeout_secs); + } + } + Some(config) +} + +fn content_federated_abuse_control_exchange_config_from_env() -> Option { + if let Ok(raw) = std::env::var("ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_CONFIG") { + match serde_json::from_str::(&raw) { + Ok(value) => return Some(value), + Err(err) => { + tracing::warn!( + "Ignoring invalid ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_CONFIG JSON: {}", + err + ); + return None; + } + } + } + + let url = std::env::var("ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())?; + let mut config = serde_json::json!({ "url": url }); + if let Ok(value) = + std::env::var("ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_AUTHORIZATION") + { + let value = value.trim(); + if !value.is_empty() { + config["authorization"] = serde_json::Value::String(value.to_string()); + } + } + if let Ok(value) = + std::env::var("ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_TIMEOUT_SECS") + { + if let Ok(timeout_secs) = value.trim().parse::() { + config["timeout_secs"] = serde_json::Value::from(timeout_secs); + } + } + Some(config) +} + +fn content_storage_market_admission_config_from_env() -> Option { + if let Ok(raw) = std::env::var("ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_CONFIG") { + match serde_json::from_str::(&raw) { + Ok(value) => return Some(value), + Err(err) => { + tracing::warn!( + "Ignoring invalid ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_CONFIG JSON: {}", + err + ); + return None; + } + } + } + + let url = std::env::var("ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())?; + let mut config = serde_json::json!({ "url": url }); + if let Ok(value) = std::env::var("ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_AUTHORIZATION") { + let value = value.trim(); + if !value.is_empty() { + config["authorization"] = serde_json::Value::String(value.to_string()); + } + } + if let Ok(value) = std::env::var("ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_TIMEOUT_SECS") { + if let Ok(timeout_secs) = value.trim().parse::() { + config["timeout_secs"] = serde_json::Value::from(timeout_secs); + } + } + Some(config) +} + +fn content_external_repair_fleet_config_from_env() -> Option { + if let Ok(raw) = std::env::var("ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_CONFIG") { + match serde_json::from_str::(&raw) { + Ok(value) => return Some(value), + Err(err) => { + tracing::warn!( + "Ignoring invalid ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_CONFIG JSON: {}", + err + ); + return None; + } + } + } + + let url = std::env::var("ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())?; + let mut config = serde_json::json!({ "url": url }); + if let Ok(value) = std::env::var("ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_AUTHORIZATION") { + let value = value.trim(); + if !value.is_empty() { + config["authorization"] = serde_json::Value::String(value.to_string()); + } + } + if let Ok(value) = std::env::var("ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_TIMEOUT_SECS") { + if let Ok(timeout_secs) = value.trim().parse::() { + config["timeout_secs"] = serde_json::Value::from(timeout_secs); + } + } + Some(config) +} + +fn carrier_peer_attestation_exchange_config_from_env() -> Option { + if let Ok(raw) = std::env::var("ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_CONFIG") { + match serde_json::from_str::(&raw) { + Ok(value) => return Some(value), + Err(err) => { + tracing::warn!( + "Ignoring invalid ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_CONFIG JSON: {}", + err + ); + return None; + } + } + } + + let url = std::env::var("ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty())?; + let mut config = serde_json::json!({ "url": url }); + if let Ok(value) = std::env::var("ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_AUTHORIZATION") { + let value = value.trim(); + if !value.is_empty() { + config["authorization"] = serde_json::Value::String(value.to_string()); + } + } + if let Ok(value) = std::env::var("ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_TIMEOUT_SECS") { + if let Ok(timeout_secs) = value.trim().parse::() { + config["timeout_secs"] = serde_json::Value::from(timeout_secs); + } + } + Some(config) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ContentRepairSchedulerConfig { + interval_secs: u64, + limit: u64, + max_attempts: u64, + failure_budget: u64, + include_healthy_check: bool, +} + +fn content_repair_scheduler_config_from_env() -> Option { + if !env_flag_enabled(CONTENT_REPAIR_SCHEDULER_ENV) { + return None; + } + Some(ContentRepairSchedulerConfig { + interval_secs: env_u64( + CONTENT_REPAIR_SCHEDULER_INTERVAL_ENV, + CONTENT_REPAIR_SCHEDULER_DEFAULT_INTERVAL_SECS, + ) + .max(CONTENT_REPAIR_SCHEDULER_MIN_INTERVAL_SECS), + limit: env_u64( + CONTENT_REPAIR_SCHEDULER_LIMIT_ENV, + CONTENT_REPAIR_SCHEDULER_DEFAULT_LIMIT, + ) + .clamp(1, 100), + max_attempts: env_u64( + CONTENT_REPAIR_SCHEDULER_MAX_ATTEMPTS_ENV, + CONTENT_REPAIR_SCHEDULER_DEFAULT_MAX_ATTEMPTS, + ) + .clamp(1, 25), + failure_budget: env_u64( + CONTENT_REPAIR_SCHEDULER_FAILURE_BUDGET_ENV, + CONTENT_REPAIR_SCHEDULER_DEFAULT_FAILURE_BUDGET, + ) + .clamp(1, 100), + include_healthy_check: env_flag_enabled(CONTENT_REPAIR_SCHEDULER_INCLUDE_HEALTHY_ENV), + }) +} + +fn env_flag_enabled(name: &str) -> bool { + std::env::var(name) + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(false) +} + +fn env_u64(name: &str, default: u64) -> u64 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(default) +} + +fn maybe_spawn_content_repair_scheduler(registry: Arc) { + let Some(config) = content_repair_scheduler_config_from_env() else { + tracing::debug!( + "{} is disabled; content repair worker remains manual/operator-triggered", + CONTENT_REPAIR_SCHEDULER_ENV + ); + return; + }; + tracing::info!( + "content repair scheduler enabled: interval={}s limit={} max_attempts={} failure_budget={} include_healthy_check={}", + config.interval_secs, + config.limit, + config.max_attempts, + config.failure_budget, + config.include_healthy_check, + ); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(config.interval_secs)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + interval.tick().await; + match invoke_content_repair_worker(®istry, config).await { + Ok(response) => { + let data = response.get("data").unwrap_or(&response); + tracing::debug!( + "content repair scheduler run completed: checked={} repaired={} failed={} skipped={}", + data.get("checked").and_then(|value| value.as_u64()).unwrap_or(0), + data.get("repaired").and_then(|value| value.as_u64()).unwrap_or(0), + data.get("failed").and_then(|value| value.as_u64()).unwrap_or(0), + data.get("skipped").and_then(|value| value.as_u64()).unwrap_or(0), + ); + } + Err(err) => { + tracing::warn!("content repair scheduler run failed: {}", err); + } + } + } + }); +} + +async fn invoke_content_repair_worker( + registry: &provider::ProviderRegistry, + config: ContentRepairSchedulerConfig, +) -> Result { + registry + .invoke_provider(ProviderInvocation { + source: "content-provider".to_string(), + target: "content".to_string(), + op: "repair_worker".to_string(), + request: serde_json::json!({ + "op": "repair_worker", + "force": false, + "include_healthy_check": config.include_healthy_check, + "limit": config.limit, + "max_attempts": config.max_attempts, + "failure_budget": config.failure_budget, + }), + transfer: ProviderTransfer::Json, + range: None, + progress: None, + transport: ProviderInvocationTransport::Local, + }) + .await +} + fn exit_provider_config_from_env(data_dir: &std::path::Path) -> Option { provider_config_from_env_or_file( data_dir, @@ -901,6 +1436,14 @@ fn browser_engine_adapter_config_from_env(data_dir: &std::path::Path) -> Option< ) } +fn operator_drive_adapter_config_from_env(data_dir: &std::path::Path) -> Option { + provider_config_from_env_or_file( + data_dir, + "ELASTOS_OPERATOR_DRIVE_ADAPTER_CONFIG", + "operator-drive-adapter.json", + ) +} + fn browser_local_exit_config_from_env(data_dir: &std::path::Path) -> Option { provider_config_from_env_or_file( data_dir, @@ -1007,6 +1550,369 @@ mod tests { assert_eq!(config["targets"][0]["authorization"], "Bearer secret"); } + #[test] + fn content_operator_alert_sink_config_uses_explicit_json() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_OPERATOR_ALERT_CONFIG", + "ELASTOS_CONTENT_OPERATOR_ALERT_URL", + ]); + std::env::set_var( + "ELASTOS_CONTENT_OPERATOR_ALERT_CONFIG", + r#"{"url":"https://alerts.example.invalid/content","authorization":"Bearer secret","timeout_secs":7}"#, + ); + + let config = content_operator_alert_sink_config_from_env().unwrap(); + assert_eq!(config["url"], "https://alerts.example.invalid/content"); + assert_eq!(config["authorization"], "Bearer secret"); + assert_eq!(config["timeout_secs"], 7); + } + + #[test] + fn content_operator_alert_sink_config_uses_env_endpoint() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_OPERATOR_ALERT_CONFIG", + "ELASTOS_CONTENT_OPERATOR_ALERT_URL", + "ELASTOS_CONTENT_OPERATOR_ALERT_AUTHORIZATION", + "ELASTOS_CONTENT_OPERATOR_ALERT_TIMEOUT_SECS", + ]); + std::env::set_var( + "ELASTOS_CONTENT_OPERATOR_ALERT_URL", + "http://127.0.0.1:9799/content-alerts", + ); + std::env::set_var( + "ELASTOS_CONTENT_OPERATOR_ALERT_AUTHORIZATION", + "Bearer local", + ); + std::env::set_var("ELASTOS_CONTENT_OPERATOR_ALERT_TIMEOUT_SECS", "9"); + + let config = content_operator_alert_sink_config_from_env().unwrap(); + assert_eq!(config["url"], "http://127.0.0.1:9799/content-alerts"); + assert_eq!(config["authorization"], "Bearer local"); + assert_eq!(config["timeout_secs"], 9); + } + + #[test] + fn content_federated_operator_alert_exchange_config_uses_explicit_json() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_CONFIG", + "ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_URL", + ]); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_CONFIG", + r#"{"url":"https://alerts.example.invalid/exchange","authorization":"Bearer exchange","timeout_secs":8}"#, + ); + + let config = content_federated_operator_alert_exchange_config_from_env().unwrap(); + assert_eq!(config["url"], "https://alerts.example.invalid/exchange"); + assert_eq!(config["authorization"], "Bearer exchange"); + assert_eq!(config["timeout_secs"], 8); + } + + #[test] + fn content_federated_operator_alert_exchange_config_uses_env_endpoint() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_CONFIG", + "ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_URL", + "ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_AUTHORIZATION", + "ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_TIMEOUT_SECS", + ]); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_URL", + "http://127.0.0.1:9799/alerts/exchange", + ); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_AUTHORIZATION", + "Bearer local-exchange", + ); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_OPERATOR_ALERT_EXCHANGE_TIMEOUT_SECS", + "10", + ); + + let config = content_federated_operator_alert_exchange_config_from_env().unwrap(); + assert_eq!(config["url"], "http://127.0.0.1:9799/alerts/exchange"); + assert_eq!(config["authorization"], "Bearer local-exchange"); + assert_eq!(config["timeout_secs"], 10); + } + + #[test] + fn content_federated_quota_ledger_exchange_config_uses_explicit_json() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_CONFIG", + "ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_URL", + ]); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_CONFIG", + r#"{"url":"https://quota.example.invalid/exchange","authorization":"Bearer quota","timeout_secs":8}"#, + ); + + let config = content_federated_quota_ledger_exchange_config_from_env().unwrap(); + assert_eq!(config["url"], "https://quota.example.invalid/exchange"); + assert_eq!(config["authorization"], "Bearer quota"); + assert_eq!(config["timeout_secs"], 8); + } + + #[test] + fn content_federated_quota_ledger_exchange_config_uses_env_endpoint() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_CONFIG", + "ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_URL", + "ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_AUTHORIZATION", + "ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_TIMEOUT_SECS", + ]); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_URL", + "http://127.0.0.1:9799/quota/exchange", + ); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_AUTHORIZATION", + "Bearer local-quota", + ); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_QUOTA_LEDGER_EXCHANGE_TIMEOUT_SECS", + "10", + ); + + let config = content_federated_quota_ledger_exchange_config_from_env().unwrap(); + assert_eq!(config["url"], "http://127.0.0.1:9799/quota/exchange"); + assert_eq!(config["authorization"], "Bearer local-quota"); + assert_eq!(config["timeout_secs"], 10); + } + + #[test] + fn content_federated_abuse_control_exchange_config_uses_explicit_json() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_CONFIG", + "ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_URL", + ]); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_CONFIG", + r#"{"url":"https://abuse.example.invalid/exchange","authorization":"Bearer abuse","timeout_secs":8}"#, + ); + + let config = content_federated_abuse_control_exchange_config_from_env().unwrap(); + assert_eq!(config["url"], "https://abuse.example.invalid/exchange"); + assert_eq!(config["authorization"], "Bearer abuse"); + assert_eq!(config["timeout_secs"], 8); + } + + #[test] + fn content_federated_abuse_control_exchange_config_uses_env_endpoint() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_CONFIG", + "ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_URL", + "ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_AUTHORIZATION", + "ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_TIMEOUT_SECS", + ]); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_URL", + "http://127.0.0.1:9799/abuse/exchange", + ); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_AUTHORIZATION", + "Bearer local-abuse", + ); + std::env::set_var( + "ELASTOS_CONTENT_FEDERATED_ABUSE_CONTROL_EXCHANGE_TIMEOUT_SECS", + "10", + ); + + let config = content_federated_abuse_control_exchange_config_from_env().unwrap(); + assert_eq!(config["url"], "http://127.0.0.1:9799/abuse/exchange"); + assert_eq!(config["authorization"], "Bearer local-abuse"); + assert_eq!(config["timeout_secs"], 10); + } + + #[test] + fn content_storage_market_admission_config_uses_explicit_json() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_CONFIG", + "ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_URL", + ]); + std::env::set_var( + "ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_CONFIG", + r#"{"url":"https://market.example.invalid/admission","authorization":"Bearer market","timeout_secs":8}"#, + ); + + let config = content_storage_market_admission_config_from_env().unwrap(); + assert_eq!(config["url"], "https://market.example.invalid/admission"); + assert_eq!(config["authorization"], "Bearer market"); + assert_eq!(config["timeout_secs"], 8); + } + + #[test] + fn content_storage_market_admission_config_uses_env_endpoint() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_CONFIG", + "ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_URL", + "ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_AUTHORIZATION", + "ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_TIMEOUT_SECS", + ]); + std::env::set_var( + "ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_URL", + "http://127.0.0.1:9799/market/admission", + ); + std::env::set_var( + "ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_AUTHORIZATION", + "Bearer local-market", + ); + std::env::set_var( + "ELASTOS_CONTENT_STORAGE_MARKET_ADMISSION_TIMEOUT_SECS", + "11", + ); + + let config = content_storage_market_admission_config_from_env().unwrap(); + assert_eq!(config["url"], "http://127.0.0.1:9799/market/admission"); + assert_eq!(config["authorization"], "Bearer local-market"); + assert_eq!(config["timeout_secs"], 11); + } + + #[test] + fn content_external_repair_fleet_config_uses_explicit_json() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_CONFIG", + "ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_URL", + ]); + std::env::set_var( + "ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_CONFIG", + r#"{"url":"https://repair.example.invalid/dispatch","authorization":"Bearer fleet","timeout_secs":8}"#, + ); + + let config = content_external_repair_fleet_config_from_env().unwrap(); + assert_eq!(config["url"], "https://repair.example.invalid/dispatch"); + assert_eq!(config["authorization"], "Bearer fleet"); + assert_eq!(config["timeout_secs"], 8); + } + + #[test] + fn content_external_repair_fleet_config_uses_env_endpoint() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_CONFIG", + "ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_URL", + "ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_AUTHORIZATION", + "ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_TIMEOUT_SECS", + ]); + std::env::set_var( + "ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_URL", + "http://127.0.0.1:9799/repair/dispatch", + ); + std::env::set_var( + "ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_AUTHORIZATION", + "Bearer local-fleet", + ); + std::env::set_var("ELASTOS_CONTENT_EXTERNAL_REPAIR_FLEET_TIMEOUT_SECS", "11"); + + let config = content_external_repair_fleet_config_from_env().unwrap(); + assert_eq!(config["url"], "http://127.0.0.1:9799/repair/dispatch"); + assert_eq!(config["authorization"], "Bearer local-fleet"); + assert_eq!(config["timeout_secs"], 11); + } + + #[test] + fn carrier_peer_attestation_exchange_config_uses_explicit_json() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_CONFIG", + "ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_URL", + ]); + std::env::set_var( + "ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_CONFIG", + r#"{"url":"https://attest.example.invalid/exchange","authorization":"Bearer attest","timeout_secs":8}"#, + ); + + let config = carrier_peer_attestation_exchange_config_from_env().unwrap(); + assert_eq!(config["url"], "https://attest.example.invalid/exchange"); + assert_eq!(config["authorization"], "Bearer attest"); + assert_eq!(config["timeout_secs"], 8); + } + + #[test] + fn carrier_peer_attestation_exchange_config_uses_env_endpoint() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + "ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_CONFIG", + "ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_URL", + "ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_AUTHORIZATION", + "ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_TIMEOUT_SECS", + ]); + std::env::set_var( + "ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_URL", + "http://127.0.0.1:9799/peer-attestation/exchange", + ); + std::env::set_var( + "ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_AUTHORIZATION", + "Bearer local-attest", + ); + std::env::set_var( + "ELASTOS_CARRIER_PEER_ATTESTATION_EXCHANGE_TIMEOUT_SECS", + "10", + ); + + let config = carrier_peer_attestation_exchange_config_from_env().unwrap(); + assert_eq!( + config["url"], + "http://127.0.0.1:9799/peer-attestation/exchange" + ); + assert_eq!(config["authorization"], "Bearer local-attest"); + assert_eq!(config["timeout_secs"], 10); + } + + #[test] + fn content_repair_scheduler_is_opt_in() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + CONTENT_REPAIR_SCHEDULER_ENV, + CONTENT_REPAIR_SCHEDULER_INTERVAL_ENV, + CONTENT_REPAIR_SCHEDULER_LIMIT_ENV, + CONTENT_REPAIR_SCHEDULER_MAX_ATTEMPTS_ENV, + CONTENT_REPAIR_SCHEDULER_FAILURE_BUDGET_ENV, + CONTENT_REPAIR_SCHEDULER_INCLUDE_HEALTHY_ENV, + ]); + + assert!(content_repair_scheduler_config_from_env().is_none()); + } + + #[test] + fn content_repair_scheduler_config_clamps_operator_env() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&[ + CONTENT_REPAIR_SCHEDULER_ENV, + CONTENT_REPAIR_SCHEDULER_INTERVAL_ENV, + CONTENT_REPAIR_SCHEDULER_LIMIT_ENV, + CONTENT_REPAIR_SCHEDULER_MAX_ATTEMPTS_ENV, + CONTENT_REPAIR_SCHEDULER_FAILURE_BUDGET_ENV, + CONTENT_REPAIR_SCHEDULER_INCLUDE_HEALTHY_ENV, + ]); + std::env::set_var(CONTENT_REPAIR_SCHEDULER_ENV, "true"); + std::env::set_var(CONTENT_REPAIR_SCHEDULER_INTERVAL_ENV, "5"); + std::env::set_var(CONTENT_REPAIR_SCHEDULER_LIMIT_ENV, "1000"); + std::env::set_var(CONTENT_REPAIR_SCHEDULER_MAX_ATTEMPTS_ENV, "999"); + std::env::set_var(CONTENT_REPAIR_SCHEDULER_FAILURE_BUDGET_ENV, "999"); + std::env::set_var(CONTENT_REPAIR_SCHEDULER_INCLUDE_HEALTHY_ENV, "yes"); + + let config = content_repair_scheduler_config_from_env().unwrap(); + assert_eq!( + config.interval_secs, + CONTENT_REPAIR_SCHEDULER_MIN_INTERVAL_SECS + ); + assert_eq!(config.limit, 100); + assert_eq!(config.max_attempts, 25); + assert_eq!(config.failure_budget, 100); + assert!(config.include_healthy_check); + } + #[test] fn exit_provider_config_prefers_explicit_json() { let _lock = ENV_LOCK.lock().unwrap(); @@ -1041,6 +1947,27 @@ mod tests { ); } + #[test] + fn operator_drive_adapter_config_prefers_explicit_json() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvGuard::new(&["ELASTOS_OPERATOR_DRIVE_ADAPTER_CONFIG"]); + std::env::set_var( + "ELASTOS_OPERATOR_DRIVE_ADAPTER_CONFIG", + r#"{"operator_endpoint":{"url":"http://127.0.0.1:9797/operator-drive","authorization":"Bearer secret"}}"#, + ); + + let data_dir = crate::sources::default_data_dir(); + let config = operator_drive_adapter_config_from_env(&data_dir).unwrap(); + assert_eq!( + config["operator_endpoint"]["url"], + "http://127.0.0.1:9797/operator-drive" + ); + assert_eq!( + config["operator_endpoint"]["authorization"], + "Bearer secret" + ); + } + #[test] fn browser_local_exit_config_prefers_explicit_json() { let _lock = ENV_LOCK.lock().unwrap(); diff --git a/elastos/crates/elastos-server/src/webspace_cmd.rs b/elastos/crates/elastos-server/src/webspace_cmd.rs index ac3ac8aa..2fb377f3 100644 --- a/elastos/crates/elastos-server/src/webspace_cmd.rs +++ b/elastos/crates/elastos-server/src/webspace_cmd.rs @@ -1,8 +1,10 @@ +use std::fs; use std::path::PathBuf; use serde::{Deserialize, Serialize}; use elastos_runtime::provider::{BridgeProviderConfig, ProviderBridge}; +use elastos_server::sources::default_data_dir; use crate::WebspaceCommand; @@ -13,9 +15,18 @@ struct WebSpaceHandle { namespace_uri: Option, target_uri: Option, resolver_state: String, + resolver: String, + cache_policy: String, + sync_policy: String, + readonly: bool, kind: String, traversable: bool, + object_id: String, + head_id: String, + cache_state: String, + sync_state: String, description: String, + forked_from: Option, next_step: Option, } @@ -25,6 +36,94 @@ struct DirEntry { is_file: bool, is_dir: bool, size: u64, + #[serde(default)] + resolver: Option, + #[serde(default)] + cache_policy: Option, + #[serde(default)] + sync_policy: Option, + #[serde(default)] + object_id: Option, + #[serde(default)] + head_id: Option, + #[serde(default)] + cache_state: Option, + #[serde(default)] + sync_state: Option, + #[serde(default)] + target_uri: Option, + #[serde(default)] + readonly: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct WebSpaceMount { + moniker: String, + target_uri: String, + namespace_uri: Option, + resolver: String, + readonly: bool, + cache_policy: String, + sync_policy: String, + description: String, + #[serde(default)] + forked_from: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct WebSpaceMounts { + schema: String, + mounts: Vec, + user_mounts: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +struct WebSpaceAdapterTable { + schema: String, + builtin: serde_json::Value, + adapters: Vec, + configured_adapter_count: usize, + connected_adapter_count: usize, + #[serde(default)] + checked_adapter_count: usize, + note: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct WebSpaceIndexInput { + path: String, + kind: String, + #[serde(default)] + target_uri: Option, + #[serde(default)] + resolver_state: Option, + #[serde(default)] + readonly: Option, + #[serde(default)] + description: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct WebSpaceHead { + handle_uri: String, + target_uri: Option, + moniker: String, + resolver: String, + object_id: String, + head_id: String, + revision: String, + readonly: bool, + cache_policy: String, + sync_policy: String, + cache_state: String, + sync_state: String, + forked_from: Option, + created_at: u64, + updated_at: u64, + last_cached_at: Option, + last_synced_at: Option, + dirty: bool, + status: String, } struct WebSpaceBridge { @@ -62,6 +161,289 @@ impl WebSpaceBridge { parse_webspace_list_response(resp, "list") } + async fn mounts(&self) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "mounts", + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider mounts error: {}", e))?; + parse_webspace_mounts_response(resp, "mounts") + } + + async fn adapters(&self) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "adapters", + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider adapters error: {}", e))?; + serde_json::from_value(parse_webspace_value_response(resp, "adapters")?) + .map_err(|e| anyhow::anyhow!("Invalid webspace-provider adapters response: {}", e)) + } + + #[allow(clippy::too_many_arguments)] + async fn register_adapter( + &self, + resolver: String, + label: Option, + endpoint_uri: Option, + provider: Option, + state: Option, + capabilities: Vec, + mutable_default: bool, + description: Option, + ) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "register_adapter", + "resolver": resolver, + "label": label, + "endpoint_uri": endpoint_uri, + "provider": provider, + "state": state, + "capabilities": capabilities, + "readonly_default": !mutable_default, + "description": description, + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider register_adapter error: {}", e))?; + parse_webspace_value_response(resp, "register_adapter") + } + + async fn unregister_adapter(&self, resolver: String) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "unregister_adapter", + "resolver": resolver, + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider unregister_adapter error: {}", e))?; + parse_webspace_value_response(resp, "unregister_adapter") + } + + async fn check_adapter( + &self, + resolver: String, + result: Option, + state: Option, + error_code: Option, + capabilities: Vec, + ) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "check_adapter", + "resolver": resolver, + "result": result, + "state": state, + "error_code": error_code, + "capabilities": capabilities, + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider check_adapter error: {}", e))?; + parse_webspace_value_response(resp, "check_adapter") + } + + async fn health(&self, moniker: Option) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "health", + "moniker": moniker, + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider health error: {}", e))?; + parse_webspace_value_response(resp, "health") + } + + #[allow(clippy::too_many_arguments)] + async fn mount( + &self, + moniker: String, + target_uri: String, + namespace_uri: Option, + resolver: Option, + description: Option, + mutable: bool, + cache_policy: Option, + sync_policy: Option, + ) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "mount", + "moniker": moniker, + "target_uri": target_uri, + "namespace_uri": namespace_uri, + "resolver": resolver, + "description": description, + "readonly": !mutable, + "cache_policy": cache_policy, + "sync_policy": sync_policy, + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider mount error: {}", e))?; + parse_webspace_value_response(resp, "mount") + } + + async fn unmount(&self, moniker: String) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "unmount", + "moniker": moniker, + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider unmount error: {}", e))?; + parse_webspace_value_response(resp, "unmount") + } + + async fn index( + &self, + moniker: String, + entries: Vec, + ) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "index", + "moniker": moniker, + "entries": entries, + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider index error: {}", e))?; + parse_webspace_value_response(resp, "index") + } + + async fn refresh( + &self, + target: String, + entries: Option>, + ) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "refresh", + "path": rooted_webspace_path(&target), + "entries": entries, + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider refresh error: {}", e))?; + parse_webspace_value_response(resp, "refresh") + } + + async fn head(&self, target: &str) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "head", + "path": rooted_webspace_path(target), + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider head error: {}", e))?; + serde_json::from_value(parse_webspace_value_response(resp, "head")?) + .map_err(|e| anyhow::anyhow!("Invalid webspace-provider head response: {}", e)) + } + + async fn cache(&self, target: &str) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "cache", + "path": rooted_webspace_path(target), + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider cache error: {}", e))?; + parse_webspace_value_response(resp, "cache") + } + + async fn cache_status(&self, target: &str) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "cache_status", + "path": rooted_webspace_path(target), + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider cache_status error: {}", e))?; + parse_webspace_value_response(resp, "cache_status") + } + + async fn sync(&self, target: &str) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "sync", + "path": rooted_webspace_path(target), + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider sync error: {}", e))?; + parse_webspace_value_response(resp, "sync") + } + + async fn sync_status(&self, target: &str) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "sync_status", + "path": rooted_webspace_path(target), + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider sync_status error: {}", e))?; + parse_webspace_value_response(resp, "sync_status") + } + + #[allow(clippy::too_many_arguments)] + async fn fork( + &self, + source: String, + moniker: String, + target_uri: Option, + resolver: Option, + description: Option, + readonly: bool, + cache_policy: Option, + sync_policy: Option, + ) -> anyhow::Result { + let resp = self + .bridge + .send_raw(&serde_json::json!({ + "op": "fork", + "source_uri": rooted_webspace_path(&source), + "moniker": moniker, + "target_uri": target_uri, + "resolver": resolver, + "description": description, + "readonly": readonly, + "cache_policy": cache_policy, + "sync_policy": sync_policy, + "token": "", + })) + .await + .map_err(|e| anyhow::anyhow!("webspace-provider fork error: {}", e))?; + parse_webspace_value_response(resp, "fork") + } + async fn shutdown(&self) -> anyhow::Result<()> { self.bridge .send_raw(&serde_json::json!({ "op": "shutdown" })) @@ -75,6 +457,390 @@ pub(crate) async fn run(cmd: WebspaceCommand) -> anyhow::Result<()> { let bridge = spawn_webspace_bridge().await?; let result = match cmd { + WebspaceCommand::Mounts { json } => { + let mounts = bridge.mounts().await?; + if json { + println!("{}", serde_json::to_string_pretty(&mounts)?); + } else { + println!("WebSpace mounts:"); + for handle in mounts.mounts { + println!( + " - {} -> {} [{}; cache {}; sync {}]", + handle.moniker, + handle.target_uri.as_deref().unwrap_or("(resolver-owned)"), + handle.resolver, + handle.cache_policy, + handle.sync_policy + ); + } + if mounts.user_mounts.is_empty() { + println!("Persistent custom mounts: none"); + } else { + println!("Persistent custom mounts: {}", mounts.user_mounts.len()); + } + } + Ok(()) + } + WebspaceCommand::Adapters { json } => { + let adapters = bridge.adapters().await?; + if json { + println!("{}", serde_json::to_string_pretty(&adapters)?); + } else { + println!("WebSpace adapters:"); + println!( + " - builtin [{}]", + adapters + .builtin + .get("state") + .and_then(|value| value.as_str()) + .unwrap_or("connected") + ); + for adapter in &adapters.adapters { + println!( + " - {} [{}]{}{}{}", + adapter + .get("resolver") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown)"), + adapter + .get("state") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + adapter + .get("health") + .and_then(|value| value.get("status")) + .and_then(|value| value.as_str()) + .map(|health| format!(" health={health}")) + .unwrap_or_default(), + adapter + .get("provider") + .and_then(|value| value.as_str()) + .map(|provider| format!(" provider={provider}")) + .unwrap_or_default(), + adapter + .get("endpoint_uri") + .and_then(|value| value.as_str()) + .map(|endpoint| format!(" endpoint={endpoint}")) + .unwrap_or_default() + ); + } + println!( + "Configured external adapters: {} (connected: {})", + adapters.configured_adapter_count, adapters.connected_adapter_count + ); + println!( + "Checked external adapters: {}", + adapters.checked_adapter_count + ); + if let Some(note) = adapters.note.as_deref() { + println!("Note: {note}"); + } + } + Ok(()) + } + WebspaceCommand::RegisterAdapter { + resolver, + label, + endpoint_uri, + provider, + state, + capabilities, + mutable_default, + description, + json, + } => { + let receipt = bridge + .register_adapter( + resolver, + label, + endpoint_uri, + provider, + state, + capabilities, + mutable_default, + description, + ) + .await?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + let adapter = receipt.get("adapter").unwrap_or(&serde_json::Value::Null); + println!( + "Registered adapter {} [{}]", + adapter + .get("resolver") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown)"), + adapter + .get("state") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + ); + if let Some(endpoint) = adapter.get("endpoint_uri").and_then(|value| value.as_str()) + { + println!("Endpoint: {endpoint}"); + } + } + Ok(()) + } + WebspaceCommand::UnregisterAdapter { resolver, json } => { + let receipt = bridge.unregister_adapter(resolver).await?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + let adapter = receipt.get("adapter").unwrap_or(&serde_json::Value::Null); + println!( + "Unregistered adapter {}", + adapter + .get("resolver") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown)") + ); + } + Ok(()) + } + WebspaceCommand::CheckAdapter { + resolver, + result, + state, + error_code, + capabilities, + json, + } => { + let receipt = bridge + .check_adapter(resolver, result, state, error_code, capabilities) + .await?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + let adapter = receipt.get("adapter").unwrap_or(&serde_json::Value::Null); + let health = adapter.get("health").unwrap_or(&serde_json::Value::Null); + println!( + "Checked adapter {} [{}; health {}]", + adapter + .get("resolver") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown)"), + adapter + .get("state") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + health + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + ); + if let Some(next) = health.get("next").and_then(|value| value.as_str()) { + println!("Next: {next}"); + } + if let Some(note) = receipt.get("note").and_then(|value| value.as_str()) { + println!("Note: {note}"); + } + } + Ok(()) + } + WebspaceCommand::Health { moniker, json } => { + let report = bridge.health(moniker).await?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!( + "WebSpace health: {}", + report + .get("state") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + ); + println!( + "Mounts: {} (dirty heads: {}, live adapters: {})", + report + .get("mount_count") + .and_then(|value| value.as_u64()) + .unwrap_or(0), + report + .get("dirty_head_count") + .and_then(|value| value.as_u64()) + .unwrap_or(0), + report + .get("live_adapter_count") + .and_then(|value| value.as_u64()) + .unwrap_or(0) + ); + for mount in report + .get("mounts") + .and_then(|value| value.as_array()) + .into_iter() + .flatten() + { + println!( + " - {} [{}; resolver {}; adapter {}]", + mount + .get("moniker") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown)"), + mount + .get("state") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + mount + .get("resolver") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + mount + .get("adapter_state") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + ); + } + if let Some(note) = report.get("note").and_then(|value| value.as_str()) { + println!("Note: {note}"); + } + } + Ok(()) + } + WebspaceCommand::Mount { + moniker, + target_uri, + namespace_uri, + resolver, + description, + mutable, + cache_policy, + sync_policy, + json, + } => { + let receipt = bridge + .mount( + moniker, + target_uri, + namespace_uri, + resolver, + description, + mutable, + cache_policy, + sync_policy, + ) + .await?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + let mount = receipt.get("mount").unwrap_or(&serde_json::Value::Null); + println!( + "Mounted {} -> {}", + mount + .get("moniker") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown)"), + mount + .get("target_uri") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown)") + ); + } + Ok(()) + } + WebspaceCommand::Unmount { moniker, json } => { + let receipt = bridge.unmount(moniker).await?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + let mount = receipt.get("mount").unwrap_or(&serde_json::Value::Null); + println!( + "Unmounted {}", + mount + .get("moniker") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown)") + ); + } + Ok(()) + } + WebspaceCommand::Index { + moniker, + entries_json, + json, + } => { + let bytes = fs::read(&entries_json).map_err(|err| { + anyhow::anyhow!( + "failed to read WebSpace index file {}: {}", + entries_json.display(), + err + ) + })?; + let entries: Vec = + serde_json::from_slice(&bytes).map_err(|err| { + anyhow::anyhow!( + "invalid WebSpace index file {}: {}", + entries_json.display(), + err + ) + })?; + let receipt = bridge.index(moniker, entries).await?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + println!( + "Indexed {} entries for {}", + receipt + .get("entry_count") + .and_then(|value| value.as_u64()) + .unwrap_or(0), + receipt + .get("moniker") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown)") + ); + } + Ok(()) + } + WebspaceCommand::Refresh { + target, + entries_json, + json, + } => { + let entries = if let Some(entries_json) = entries_json { + let bytes = fs::read(&entries_json).map_err(|err| { + anyhow::anyhow!( + "failed to read WebSpace index file {}: {}", + entries_json.display(), + err + ) + })?; + Some( + serde_json::from_slice::>(&bytes).map_err(|err| { + anyhow::anyhow!( + "invalid WebSpace index file {}: {}", + entries_json.display(), + err + ) + })?, + ) + } else { + None + }; + let receipt = bridge.refresh(target, entries).await?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + println!( + "Refreshed {}", + receipt + .get("handle_uri") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown handle)") + ); + if let Some(count) = receipt + .get("index_entry_count") + .and_then(|value| value.as_u64()) + { + println!("Index entries: {count}"); + } + if let Some(note) = receipt.get("note").and_then(|value| value.as_str()) { + println!("Note: {note}"); + } + } + Ok(()) + } WebspaceCommand::Resolve { target, json } => { let handle = bridge.resolve(&target).await?; if json { @@ -88,6 +854,13 @@ pub(crate) async fn run(cmd: WebspaceCommand) -> anyhow::Result<()> { handle.namespace_uri.as_deref().unwrap_or("(not mapped)") ); println!("State: {}", handle.resolver_state); + println!("Resolver: {}", handle.resolver); + println!("Cache: {}", handle.cache_policy); + println!("CacheState: {}", handle.cache_state); + println!("Sync: {}", handle.sync_policy); + println!("SyncState: {}", handle.sync_state); + println!("Object: {}", handle.object_id); + println!("Head: {}", handle.head_id); println!("Kind: {}", handle.kind); println!( "Contract: {}", @@ -101,6 +874,7 @@ pub(crate) async fn run(cmd: WebspaceCommand) -> anyhow::Result<()> { "Target: {}", handle.target_uri.as_deref().unwrap_or("(resolver-owned)") ); + println!("Readonly: {}", if handle.readonly { "yes" } else { "no" }); println!( "Traverse: {}", if handle.traversable { "yes" } else { "no" } @@ -112,6 +886,177 @@ pub(crate) async fn run(cmd: WebspaceCommand) -> anyhow::Result<()> { } Ok(()) } + WebspaceCommand::Head { target, json } => { + let head = bridge.head(&target).await?; + if json { + println!("{}", serde_json::to_string_pretty(&head)?); + } else { + println!("Handle: {}", head.handle_uri); + println!("Object: {}", head.object_id); + println!("Head: {}", head.head_id); + println!("Revision: {}", head.revision); + println!("Resolver: {}", head.resolver); + println!("Cache: {} ({})", head.cache_policy, head.cache_state); + println!("Sync: {} ({})", head.sync_policy, head.sync_state); + println!("Dirty: {}", if head.dirty { "yes" } else { "no" }); + println!("Status: {}", head.status); + if let Some(target_uri) = head.target_uri.as_deref() { + println!("Target: {target_uri}"); + } + if let Some(forked_from) = head.forked_from.as_deref() { + println!("ForkedFrom:{forked_from}"); + } + } + Ok(()) + } + WebspaceCommand::Cache { target, json } => { + let receipt = bridge.cache(&target).await?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + println!( + "Cached metadata for {}", + receipt + .get("handle_uri") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown handle)") + ); + if let Some(note) = receipt.get("note").and_then(|value| value.as_str()) { + println!("Note: {note}"); + } + } + Ok(()) + } + WebspaceCommand::CacheStatus { target, json } => { + let status = bridge.cache_status(&target).await?; + if json { + println!("{}", serde_json::to_string_pretty(&status)?); + } else { + println!( + "Cache: {} ({})", + status + .get("policy") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + status + .get("state") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + ); + println!( + "Content cached: {}", + status + .get("content_cached") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + ); + if let Some(note) = status.get("note").and_then(|value| value.as_str()) { + println!("Note: {note}"); + } + } + Ok(()) + } + WebspaceCommand::Sync { target, json } => { + let receipt = bridge.sync(&target).await?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + println!( + "Synced metadata for {}", + receipt + .get("handle_uri") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown handle)") + ); + if let Some(note) = receipt.get("note").and_then(|value| value.as_str()) { + println!("Note: {note}"); + } + } + Ok(()) + } + WebspaceCommand::SyncStatus { target, json } => { + let status = bridge.sync_status(&target).await?; + if json { + println!("{}", serde_json::to_string_pretty(&status)?); + } else { + println!( + "Sync: {} ({})", + status + .get("policy") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + status + .get("state") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + ); + println!( + "Dirty: {}", + status + .get("dirty") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + ); + if let Some(note) = status.get("note").and_then(|value| value.as_str()) { + println!("Note: {note}"); + } + } + Ok(()) + } + WebspaceCommand::Fork { + source, + moniker, + target_uri, + resolver, + description, + readonly, + cache_policy, + sync_policy, + json, + } => { + let receipt = bridge + .fork( + source, + moniker, + target_uri, + resolver, + description, + readonly, + cache_policy, + sync_policy, + ) + .await?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + let mount = receipt.get("mount").unwrap_or(&serde_json::Value::Null); + let head = receipt.get("head").unwrap_or(&serde_json::Value::Null); + println!( + "Forked {} -> {}", + mount + .get("forked_from") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown source)"), + mount + .get("moniker") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown mount)") + ); + println!( + "Head: {} [{}]", + head.get("head_id") + .and_then(|value| value.as_str()) + .unwrap_or("(unknown head)"), + head.get("sync_state") + .and_then(|value| value.as_str()) + .unwrap_or("unknown") + ); + if let Some(next_step) = receipt.get("next_step").and_then(|value| value.as_str()) { + println!("Next: {next_step}"); + } + } + Ok(()) + } WebspaceCommand::List { path, json } => { let entries = bridge.list(path.as_deref()).await?; if json { @@ -133,7 +1078,21 @@ pub(crate) async fn run(cmd: WebspaceCommand) -> anyhow::Result<()> { } else { "entry" }; - println!(" - [{}] {}", kind, entry.name); + println!( + " - [{}] {}{}{}", + kind, + entry.name, + entry + .target_uri + .as_deref() + .map(|target| format!(" -> {target}")) + .unwrap_or_default(), + entry + .cache_state + .as_deref() + .map(|state| format!(" [{state}]")) + .unwrap_or_default() + ); } } Ok(()) @@ -146,7 +1105,12 @@ pub(crate) async fn run(cmd: WebspaceCommand) -> anyhow::Result<()> { async fn spawn_webspace_bridge() -> anyhow::Result { let binary = resolve_webspace_provider_binary()?; - let bridge = ProviderBridge::spawn(&binary, BridgeProviderConfig::default()) + let config = BridgeProviderConfig { + base_path: default_data_dir().to_string_lossy().to_string(), + read_only: false, + ..BridgeProviderConfig::default() + }; + let bridge = ProviderBridge::spawn(&binary, config) .await .map_err(|e| anyhow::anyhow!("Failed to spawn webspace-provider: {}", e))?; Ok(WebSpaceBridge { bridge }) @@ -207,6 +1171,35 @@ fn parse_webspace_list_response( .map_err(|e| anyhow::anyhow!("Invalid webspace-provider {} response: {}", op, e)) } +fn parse_webspace_mounts_response( + resp: serde_json::Value, + op: &str, +) -> anyhow::Result { + serde_json::from_value(parse_webspace_value_response(resp, op)?) + .map_err(|e| anyhow::anyhow!("Invalid webspace-provider {} response: {}", op, e)) +} + +fn parse_webspace_value_response( + resp: serde_json::Value, + op: &str, +) -> anyhow::Result { + if let Some("error") = resp.get("status").and_then(|v| v.as_str()) { + let code = resp + .get("code") + .and_then(|v| v.as_str()) + .unwrap_or("unknown_error"); + let message = resp + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + anyhow::bail!("webspace-provider {} failed [{}]: {}", op, code, message); + } + + resp.get("data") + .cloned() + .ok_or_else(|| anyhow::anyhow!("webspace-provider {} response missing data", op)) +} + fn is_handle_path(target: &str) -> bool { target.starts_with("localhost://") || target.contains('/') } diff --git a/scripts/build.sh b/scripts/build.sh index f1dccff3..48581675 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -45,7 +45,7 @@ show_help() { echo "" echo -e "${BOLD}Capsule locations:${NC}" echo " Core capsules: elastos/capsules/ + provider capsules" - echo " App capsules: capsules/ (chat, did-provider, chain-provider, wallet-provider, drm-provider, rights-provider, key-provider, decrypt-provider, availability-provider, ai-provider, llama-provider, agent, ipfs-provider, site-provider, tunnel-provider)" + echo " App capsules: capsules/ (chat, did-provider, chain-provider, wallet-provider, object-provider, content-block-graph-provider, drm-provider, rights-provider, key-provider, decrypt-provider, availability-provider, ai-provider, llama-provider, agent, ipfs-provider, site-provider, tunnel-provider)" echo " Data capsules: capsules/ (gba-emulator, gba-ucity)" echo " Data capsules don't need building — they're static assets." echo "" @@ -103,11 +103,14 @@ APP_CAPSULES=( "did-provider:capsules/did-provider" "chain-provider:capsules/chain-provider" "wallet-provider:capsules/wallet-provider" + "object-provider:capsules/object-provider" + "content-block-graph-provider:capsules/content-block-graph-provider" "drm-provider:capsules/drm-provider" "rights-provider:capsules/rights-provider" "key-provider:capsules/key-provider" "decrypt-provider:capsules/decrypt-provider" "availability-provider:capsules/availability-provider" + "operator-drive-adapter:capsules/operator-drive-adapter" "ai-provider:capsules/ai-provider" "llama-provider:capsules/llama-provider" "agent:capsules/agent" @@ -277,6 +280,6 @@ fi echo "" echo -e "${GREEN}Done.${NC}" if [ "$BUILD_ALL" = false ]; then - echo -e "${DIM}Run with --all to also build chat, did-provider, chain-provider, wallet-provider, drm-provider, rights-provider, key-provider, decrypt-provider, availability-provider, ai-provider, llama-provider, agent, ipfs-provider.${NC}" + echo -e "${DIM}Run with --all to also build chat, did-provider, chain-provider, wallet-provider, object-provider, content-block-graph-provider, drm-provider, rights-provider, key-provider, decrypt-provider, availability-provider, operator-drive-adapter, ai-provider, llama-provider, agent, ipfs-provider.${NC}" fi echo "" diff --git a/scripts/check-wci-alignment.sh b/scripts/check-wci-alignment.sh index d8f6ebbb..a9899f42 100755 --- a/scripts/check-wci-alignment.sh +++ b/scripts/check-wci-alignment.sh @@ -169,7 +169,8 @@ check_required 'edge_release_channel_path' elastos/crates/elastos-server/src/sit check_required 'publisher_release_manifest_path' elastos/crates/elastos-server/src/api/gateway_site.rs 'gateway must read release manifests from Publisher state' check_required 'publish_to_content_availability' elastos/crates/elastos-server/src/gateway_cmd.rs 'public gateway publish must route through content availability' check_forbidden_in_path 'publish_to_ipfs|no CID in ipfs-provider response' elastos/crates/elastos-server/src/gateway_cmd.rs 'public gateway publish must not bind directly to ipfs-provider' -check_required 'send_raw\("availability"' elastos/crates/elastos-server/src/content.rs 'content provider must keep the internal availability provider seam' +check_required 'ProviderInvocation' elastos/crates/elastos-server/src/content.rs 'content provider must use the provider invocation envelope' +check_required '"availability",' elastos/crates/elastos-server/src/content.rs 'content provider must keep the internal availability provider seam' check_required '"availability",' elastos/crates/elastos-runtime/src/provider/registry.rs 'provider registry must reserve elastos://availability for the availability provider' check_required '"wallet",' elastos/crates/elastos-runtime/src/provider/registry.rs 'provider registry must reserve elastos://wallet for the wallet provider' check_required '"drm",' elastos/crates/elastos-runtime/src/provider/registry.rs 'provider registry must reserve elastos://drm for the protected-content provider' @@ -431,13 +432,14 @@ ordinary_roles = {"app", "viewer", "content"} # System is the runtime-owned approval/diagnostic surface. Dedicated wallet # connector capsules and the Browser shell are privileged adapter UIs, not # general app authority. -ordinary_capsules_with_privileged_authority_ui = {"system", "wallet-metamask", "wallet-unisat", "wallet", "wallet-walletconnect", "browser"} +ordinary_capsules_with_privileged_authority_ui = {"system", "wallet-metamask", "wallet-unisat", "wallet", "wallet-walletconnect", "browser", "library"} system_only_elastos_backends = { "elacity", "elacity-sdk", "gateway", "chain", "wallet", + "library", "ipfs", "ipfs-cluster", "ipfs-provider", @@ -540,6 +542,7 @@ for path in manifest_paths: "elastos://exit": "raw Browser Exit provider namespace", "elastos://browser-engine": "raw Browser Engine provider namespace", "elastos://wallet": "raw wallet provider namespace", + "elastos://object": "raw object provider namespace", "elastos://ipfs": "raw IPFS backend namespace", "elastos://availability": "raw availability backend namespace", "elastos://drm": "raw protected-content backend namespace", @@ -554,6 +557,7 @@ for path in manifest_paths: "browser-stream-bridge": "raw Browser Engine byte transport bridge", "browser-local-exit": "raw Browser local Exit daemon", "wallet-provider": "raw wallet backend provider", + "object-provider": "raw object backend provider", "ipfs-provider": "raw IPFS backend provider", "availability-provider": "raw availability backend provider", "drm-provider": "raw protected-content backend provider", @@ -627,6 +631,7 @@ required = { "browser-stream-bridge", "browser-local-exit", "webspace-provider", + "object-provider", "home-cli", "home", "system", @@ -654,6 +659,7 @@ required_demo = { "browser-stream-bridge", "browser-local-exit", "webspace-provider", + "object-provider", "home-cli", "home", "system", diff --git a/scripts/home-camofox-smoke.mjs b/scripts/home-camofox-smoke.mjs index ba651630..08f71007 100755 --- a/scripts/home-camofox-smoke.mjs +++ b/scripts/home-camofox-smoke.mjs @@ -2064,6 +2064,8 @@ async function main() { assert(desktopTargets.includes("inbox"), "Inbox should be exposed as a Home desktop shortcut", desktopTargets); assert(launcherTargets.includes("library"), "Library should be exposed in the Home launcher", launcherTargets); assert(desktopTargets.includes("library"), "Library should be exposed as a Home desktop shortcut", desktopTargets); + assert(launcherTargets.includes("archive-manager"), "Archive should be exposed in the Home launcher", launcherTargets); + assert(desktopTargets.includes("archive-manager"), "Archive should be exposed as a Home desktop shortcut", desktopTargets); assert(state.launcherHeading === "Open", "Home launcher heading should use plain product wording", state); assert(state.launcherSearchPlaceholder === "Search Home", "Home launcher search should use plain product wording", state); assert(!state.launcherGroupHeadings.includes("All Apps"), "Home launcher should not describe mixed targets as apps", state); @@ -2084,6 +2086,11 @@ async function main() { "Home launcher did not label library as Library", state, ); + assert( + state.launcherCards.some((card) => card.target === "archive-manager" && card.title === "Archive"), + "Home launcher did not label Archive as Archive", + state, + ); assert( state.launcherCards.some((card) => card.target === "inbox" && card.title === "Inbox"), "Home launcher did not label inbox as Inbox", @@ -2099,6 +2106,11 @@ async function main() { "Home desktop did not label library as Library", state, ); + assert( + state.desktopShortcuts.some((shortcut) => shortcut.target === "archive-manager" && shortcut.label === "Archive"), + "Home desktop did not label Archive as Archive", + state, + ); assert( state.desktopShortcuts.some((shortcut) => shortcut.target === "inbox" && shortcut.label === "Inbox"), "Home desktop did not label inbox as Inbox", @@ -2106,6 +2118,44 @@ async function main() { ); }); + await runCase("archive-launch-routes-to-library", async (tabId) => { + await openShellTarget(tabId, "archive-manager"); + const archiveReady = await waitFor(async () => { + const state = await shellState(tabId); + const archiveWindow = state.windows.find((window) => window.target === "archive-manager"); + if (!archiveWindow || archiveWindow.title !== "Archive") { + return false; + } + const archive = await frameState(tabId, "archive-manager", `({ + ok: true, + title: doc.title, + openButton: !!doc.querySelector("#open-existing-archive"), + })`); + return archive.ok && archive.title === "Archive - ElastOS" && archive.openButton; + }, 12000, 500); + assert(archiveReady, "Archive launcher did not open the Archive window", await shellState(tabId)); + + const clicked = await clickInFrame(tabId, "archive-manager", "#open-existing-archive"); + assert(clicked.ok, "Archive open-existing action was not clickable", clicked); + const openedLibrary = await waitFor(async () => { + const state = await shellState(tabId); + return state.windows.some((window) => window.target === "library" && window.title === "Library"); + }, 12000, 500); + assert(openedLibrary, "Home rejected Archive open-target handoff to Library", await shellState(tabId)); + const pickerMode = await frameState(tabId, "library", `({ + mode: new URL(win.location.href).searchParams.get("mode") || "", + returnTarget: new URL(win.location.href).searchParams.get("returnTarget") || "", + pickerButton: doc.querySelector("#picker-action-button")?.textContent?.trim() || "", + })`); + assert( + pickerMode.mode === "archive-open" && + pickerMode.returnTarget === "archive-manager" && + pickerMode.pickerButton === "Open in Archive", + "Archive handoff must open Library as an Archive picker, not as a generic Library window", + pickerMode, + ); + }); + await runCase("desktop-context-menu-toggles-icons", async (tabId) => { let opened = await dispatchDesktopBackgroundContextMenu(tabId); assert(opened.ok, "desktop background context menu did not dispatch", opened); diff --git a/scripts/home-entropy-check.mjs b/scripts/home-entropy-check.mjs index f09dd93c..8758215c 100755 --- a/scripts/home-entropy-check.mjs +++ b/scripts/home-entropy-check.mjs @@ -656,7 +656,6 @@ const lightTokenFiles = [ "capsules/system/browser/style.css", "capsules/documents/index.html", "capsules/inbox/index.html", - "capsules/library/index.html", ]; const lightTokens = new Map([ @@ -793,6 +792,7 @@ const browserCapsulesApi = read( const viewerGatewayApi = read( "elastos/crates/elastos-server/src/api/viewer_gateway.rs", ); +const archivePolicyDoc = read("docs/ARCHIVE_POLICY.md"); const documentsProvider = read( "elastos/crates/elastos-server/src/documents.rs", ); @@ -829,6 +829,7 @@ const notifications = read( const carrierBridge = read( "elastos/crates/elastos-server/src/carrier_bridge.rs", ); +const carrierRuntime = read("elastos/crates/elastos-server/src/carrier.rs"); const runtimeCore = read("elastos/crates/elastos-server/src/runtime.rs"); const runtimeControl = read( "elastos/crates/elastos-server/src/runtime_control.rs", @@ -1026,7 +1027,7 @@ const gatewayBrowserRouteTests = read( ); const debugPolicy = read("DEBUG.md"); const gbaScript = read("scripts/gba.sh"); -const homeAssetVersion = "home-20260526d"; +const homeAssetVersion = "home-20260607d"; assertUsersSelfReferencesAreApproved(); assert( shellIndex.includes('role="listbox"'), @@ -1100,10 +1101,11 @@ assert( "Home PWA manifest must include a 512px install icon", ); assert( - shellServiceWorker.includes( - `elastos-home-${homeAssetVersion.slice("home-".length)}`, - ), - "Home service worker cache key must match the browser asset version", + shellServiceWorker.includes('const CACHE_PREFIX = "elastos-home-";') && + shellServiceWorker.includes("caches.delete(key)") && + shellServiceWorker.includes("self.registration.unregister()") && + shellServiceWorker.includes('self.addEventListener("fetch", () => {});'), + "Home service worker must actively remove old Home caches and unregister instead of preserving stale shell assets", ); assert( !shellServiceWorker.includes("elastos-home-shell"), @@ -1182,6 +1184,14 @@ assert( shellJs.includes("SHELL_MESSAGE_OPEN_TARGET_SOURCES"), "Home open-target messages must stay source-gated", ); +assert( + shellJs.includes('"archive-manager": new Set(["library"])'), + "Home must allow Archive to route users into Library for open/create archive journeys", +); +assert( + shellJs.includes('library: new Set(["archive-manager", "documents", "library"])'), + "Home must allow Library to open Archive for archive files while keeping the source-gated policy explicit", +); assert( shellIndex.includes(`shell.js?v=${homeAssetVersion}`), "Home entry module must cache-bust after shell browser changes", @@ -1210,6 +1220,15 @@ assert( shellSurface.includes(`shell-windows.js?v=${homeAssetVersion}`), "Home shell-surface must import the same shell-windows module instance as shell.js", ); +assert( + shellCore.includes('"archive-manager": "Archive"') && + shellCore.includes('"archive-manager": new Set(["Archive Manager"])') && + shellCore.includes("export function canonicalTargetTitle") && + shellCore.includes("title: canonicalTargetTitle(target?.target, target?.title)") && + shellCore.includes("STALE_TARGET_TITLES[targetId]?.has(nextLabel)") && + shellWindows.includes("canonicalTargetTitle(launched.target, launched.title)"), + "Home must canonicalize Archive labels/window titles and drop stale generated Archive Manager desktop labels", +); assert( !shellWindows.includes("allowfullscreen") && shellWindows.includes('const COMMON_IFRAME_ALLOW = ["autoplay", "fullscreen"]') && @@ -1323,8 +1342,8 @@ assert( "Home must source-gate capsule-to-capsule picker returns", ); assert( - shellJs.includes('library: new Set(["chat-room"])'), - "Home must allow Library picker results to return to Chat Room only", + shellJs.includes('library: new Set(["archive-manager", "chat-room"])'), + "Home must allow Library picker results to return only to Archive and Chat Room", ); assert( shellJs.includes('"home:close-self"'), @@ -1927,8 +1946,71 @@ for (const component of ["home", "system", "documents", "library", "inbox"]) { } const documents = read("capsules/documents/index.html"); +const archiveManager = read("capsules/archive-manager/index.html"); +const archiveManagerManifest = read("capsules/archive-manager/capsule.json"); const inbox = read("capsules/inbox/index.html"); -const library = read("capsules/library/index.html"); +const libraryIndex = read("capsules/library/index.html"); +const libraryCss = read("capsules/library/library.css"); +const libraryApp = read("capsules/library/src/app.js"); +const libraryActions = read("capsules/library/src/actions.js"); +const libraryApi = read("capsules/library/src/api.js"); +const libraryDialog = read("capsules/library/src/dialog.js"); +const libraryEditor = read("capsules/library/src/editor.js"); +const libraryEvents = read("capsules/library/src/events.js"); +const libraryMenu = read("capsules/library/src/menu.js"); +const libraryModel = read("capsules/library/src/model.js"); +const libraryNavigation = read("capsules/library/src/navigation.js"); +const libraryPreview = read("capsules/library/src/preview.js"); +const libraryRealtime = read("capsules/library/src/realtime.js"); +const libraryRender = read("capsules/library/src/render.js"); +const librarySelection = read("capsules/library/src/selection.js"); +const libraryState = read("capsules/library/src/state.js"); +const libraryUploads = read("capsules/library/src/uploads.js"); +const library = readAll([ + "capsules/library/index.html", + "capsules/library/library.css", + "capsules/library/src/app.js", + "capsules/library/src/actions.js", + "capsules/library/src/api.js", + "capsules/library/src/dialog.js", + "capsules/library/src/editor.js", + "capsules/library/src/events.js", + "capsules/library/src/menu.js", + "capsules/library/src/model.js", + "capsules/library/src/navigation.js", + "capsules/library/src/preview.js", + "capsules/library/src/realtime.js", + "capsules/library/src/render.js", + "capsules/library/src/selection.js", + "capsules/library/src/state.js", + "capsules/library/src/uploads.js", +]); +const objectProviderManifest = read("capsules/object-provider/capsule.json"); +const objectProviderImpl = read("elastos/crates/elastos-server/src/library.rs"); +const gatewayProviderProxy = read("elastos/crates/elastos-server/src/api/gateway_provider_proxy.rs"); +const libraryGatewayTests = read("elastos/crates/elastos-server/src/api/gateway_tests/library.rs"); +const retiredObjectProviderMarkers = { + oldBinary: ["library", "provider"].join("-"), + oldNamespace: ["elastos://", "library"].join(""), + oldSchemeRegistration: [ + 'CapsuleProvider::with_scheme(bridge, "', + "library", + '")', + ].join(""), +}; +const contentProvider = read("elastos/crates/elastos-server/src/content.rs"); +const contentCmd = read("elastos/crates/elastos-server/src/content_cmd.rs"); +const availabilityProvider = read("capsules/availability-provider/src/main.rs"); +const webspaceProvider = read("capsules/webspace-provider/src/main.rs"); +const webspaceCmd = read("elastos/crates/elastos-server/src/webspace_cmd.rs"); +const serverMain = read("elastos/crates/elastos-server/src/main.rs"); +const providerRegistry = read("elastos/crates/elastos-runtime/src/provider/registry.rs"); +const libraryDesktopIcon = read("capsules/library/icons/sidebar-folder-desktop.svg"); +const libraryMenuSmoke = read("scripts/library-menu-smoke.mjs"); +const libraryPerformanceSmoke = read("scripts/library-performance-smoke.mjs"); +const libraryLiveSmoke = read("scripts/library-live-smoke.sh"); +const fileManagerMigrationDoc = read("docs/FILE_MANAGER_MIGRATION.md"); +const todayLibraryTracker = read("TODAY.md"); const chatStyle = read("capsules/chat-room/browser/style.css"); const gba = read("capsules/gba-emulator/index.html"); const gbaStyle = read("capsules/gba-emulator/style.css"); @@ -2169,28 +2251,1031 @@ assert( "Inbox mobile panels must use compact Home-aligned spacing", ); assert( - library.includes("home:open-target"), + libraryApi.includes("home:open-target"), "Library opens must use Home orchestration", ); +{ + const openObjectStart = libraryActions.indexOf("async function openObject(object)"); + const viewerIndex = libraryActions.indexOf("const viewer = viewerOptions(object)[0];", openObjectStart); + const previewIndex = libraryActions.indexOf("if (canPreviewObject(object))", openObjectStart); + assert( + openObjectStart >= 0 && viewerIndex > openObjectStart && previewIndex > viewerIndex, + "Library double-click must prefer the installed default viewer before falling back to internal preview", + ); +} assert( - library.includes("home:deliver-to-target"), + libraryApi.includes("home:deliver-to-target"), "Library picker returns must use Home orchestration", ); assert( - library.includes("home:close-self"), + libraryApi.includes("home:close-self"), "Library picker must close itself through Home after a successful attach", ); assert( - library.includes("chat-room:attach-library-item"), + libraryActions.includes("chat-room:attach-library-item"), "Library must return selected documents using the Chat Room attach contract", ); assert( - library.includes("Publish the document before attaching it."), - "Library must fail clearly when a draft is selected for Chat Room attachment", + libraryActions.includes("Publish this object before attaching it."), + "Library must fail clearly when a local-only object is selected for Chat Room attachment", +); +assert( + libraryActions.includes("publishedCid(object)") && + libraryActions.includes("object.published") && + libraryActions.includes("elastos://"), + "Library attach mode must return published elastos:// object URIs", ); assert( - library.includes("data-attach-uri"), - "Library attach mode must expose published URI selection state", + libraryApp.includes('desktop: "icons/sidebar-folder-desktop.svg"') && + libraryApp.includes('"icons/trash.svg"') && + libraryApp.includes('"icons/trash-full.svg"'), + "Library sidebar must expose Desktop and visible provider-backed Trash with empty/full icons", +); +assert( + libraryDesktopIcon.includes('width="12px"') && libraryDesktopIcon.includes('height="12px"'), + "Library Desktop sidebar icon must match the compact PC2 sidebar icon sizing", +); +assert( + gatewayApi.includes("HOME_SYSTEM_DESKTOP_OBJECT_SCHEMA") && + gatewayApi.includes("home_trash_desktop_object") && + gatewayApi.includes('"system_kind": "trash"') && + shellCore.includes('targetId === "trash-full"') && + shellSurface.includes("function isTrashDesktopObject") && + shellSurface.includes("Open Trash") && + shellSurface.includes('action: "empty-trash"') && + libraryApp.includes('state.initialAction === "empty-trash"'), + "Home desktop must expose provider-backed Trash as a PC2-style system desktop object", +); +assert( + objectProviderManifest.includes('"role": "provider"') && + objectProviderManifest.includes('"name": "object-provider"') && + objectProviderManifest.includes('"provides": "elastos://object/*"') && + objectProviderManifest.includes('"resource": "elastos://object/*"') && + !objectProviderManifest.includes(retiredObjectProviderMarkers.oldNamespace) && + objectProviderManifest.includes('"audit_events"') && + ["publish", "unpublish", "repair", "share", "events"].every((op) => + objectProviderManifest.includes(`"${op}"`), + ), + "Object provider must be the only provider capsule authority metadata for every routed operation", +); +assert( + serverInfra.includes('find_installed_provider_binary("object-provider")') && + !serverInfra.includes( + `find_installed_provider_binary("${retiredObjectProviderMarkers.oldBinary}")`, + ) && + serverInfra.includes('CapsuleProvider::with_scheme(bridge.clone(), "object")') && + !serverInfra.includes(retiredObjectProviderMarkers.oldSchemeRegistration) && + !serverInfra.includes("ObjectProvider::new("), + "Runtime server must register object-provider only, without the retired object provider alias", +); +assert( + objectProviderImpl.includes('("desktop", "Desktop", format!("{root}/Desktop"), "directory")') && + objectProviderImpl.includes('id: "trash"') && + objectProviderImpl.includes('label: "Trash"') && + objectProviderImpl.includes('format!("{root}/.Trash")') && + objectProviderImpl.includes('"elastos.library.trash-root/v1"'), + "Object provider roots must expose Desktop and visible provider-backed Trash", +); +assert( + gatewayApi.includes('"/api/provider/object/upload"') && + gatewayApi.includes('"/api/provider/object/upload/start"') && + gatewayApi.includes('"/api/provider/object/upload/:upload_id/chunk"') && + gatewayApi.includes('"/api/provider/object/upload/:upload_id/finish"') && + gatewayApi.includes("LIBRARY_UPLOAD_CHUNK_MAX_BYTES") && + gatewayApi.includes("pub(super) async fn gateway_library_upload") && + gatewayApi.includes("pub(super) async fn gateway_library_upload_start") && + gatewayApi.includes("pub(super) async fn gateway_library_upload_chunk") && + gatewayApi.includes("pub(super) async fn gateway_library_upload_finish") && + gatewayApi.includes("elastos.object.upload-session/v1") && + gatewayApi.includes("http-chunk-session") && + gatewayApi.includes("client_waits_for_chunk_ack") && + gatewayApi.includes("LIBRARY_TRANSFER_RECEIPT_SCHEMA") && + gatewayApi.includes("x-elastos-transfer-receipt") && + objectProviderImpl.includes("pub fn handle_library_upload_bytes(") && + objectProviderImpl.includes("fn write_library_file_bytes("), + "Object-provider raw browser uploads must route through Runtime auth/audit, emit transfer receipts, and use the shared object-provider byte-write helper", +); +assert( + gatewayApi.includes('"/api/provider/object/download/raw"') && + gatewayApi.includes("pub(super) async fn gateway_library_download") && + gatewayApi.includes("LIBRARY_TRANSFER_RECEIPT_SCHEMA") && + gatewayApi.includes("x-elastos-transfer-receipt") && + gatewayApi.includes("fn library_download_byte_range(") && + gatewayApi.includes("StatusCode::PARTIAL_CONTENT") && + gatewayApi.includes("StatusCode::RANGE_NOT_SATISFIABLE") && + gatewayApi.includes("LibraryArchiveFormat::parse") && + gatewayApi.includes('"archive" => archive_format_value = Some(value.into_owned())') && + objectProviderImpl.includes("pub(crate) enum LibraryArchiveFormat") && + objectProviderImpl.includes("pub(crate) fn handle_library_download_bytes_with_format(") && + objectProviderImpl.includes("pub(crate) async fn handle_library_download_bytes_runtime(") && + objectProviderImpl.includes("archive_format: LibraryArchiveFormat") && + objectProviderImpl.includes("async fn webspace_download_bytes(") && + objectProviderImpl.includes("fn library_download_object(") && + libraryActions.includes("downloadObjectRaw({ uri: object.uri })") && + libraryActions.includes('downloadObjectRaw({ uri: object.uri, archive: "zip" })') && + !libraryActions.includes('providerApi("download"') && + !libraryActions.includes("base64ToBlob"), + "Library raw browser downloads must route through Runtime auth/audit and the shared Library/WebSpace byte-read helpers without JSON/base64 app fallbacks", +); +assert( + gatewayApi.includes('"compress_archive"') && + objectProviderImpl.includes("CompressArchive {") && + objectProviderImpl.includes("fn compress_library_archive(") && + objectProviderImpl.includes("fn archive_library_single_zip(") && + objectProviderImpl.includes("archive_library_selection_zip(data_dir, principal_id, &targets)") && + objectProviderImpl.includes('capabilities.push("compress_archive")') && + libraryActions.includes('providerApi("compress_archive"') && + libraryActions.includes("async function compressObjectToZip(object)") && + libraryActions.includes("async function compressSelectedObjectsToZip()") && + libraryApp.includes('menuAction("Compress to ZIP"') && + libraryApp.includes('menuAction("Compress Selected to ZIP"') && + libraryState.includes('"compress_archive"'), + "Library Compress to ZIP must be provider-owned, capability-gated, cache-invalidating, and available for single objects and same-folder selections", +); +assert( + archiveManagerManifest.includes('"name": "archive-manager"') && + archiveManagerManifest.includes('"role": "viewer"') && + archiveManager.includes("Archive - ElastOS") && + archiveManager.includes("/api/viewers/archive-manager/library-object") && + archiveManager.includes('url.searchParams.set("stat_only", "true")') && + archiveManager.includes('url.searchParams.set("entries", "true")') && + archiveManager.includes("/api/viewers/archive-manager/library-roots") && + archiveManager.includes('url.searchParams.set("preview_entry", path)') && + archiveManager.includes('aria-label="Archive contents"') && + archiveManager.includes("Open archive") && + archiveManager.includes("New ZIP") && + archiveManager.includes('id="open-archive-button"') && + archiveManager.includes('id="new-archive-button"') && + !archiveManager.includes('class="archive-mark"') && + !archiveManager.includes("Work with archives from Library.") && + !archiveManager.includes("Archive never edits storage directly") && + !archiveManager.includes("Safety details") && + !archiveManager.includes("Technical details") && + !archiveManager.includes("Runtime and Library services") && + !archiveManager.includes("Entry listing unavailable") && + !archiveManager.includes("Choose a Library destination") && + !archiveManager.includes("viewers:") && + archiveManager.includes('mode: intent === "create" ? "archive-create" : "archive-open"') && + archiveManager.includes('returnTarget: "archive-manager"') && + archiveManager.includes('archive:open-library-object') && + archiveManager.includes("async function openLibraryObject(object)") && + archiveManager.includes('new URL("/apps/library/", window.location.origin)') && + archiveManager.includes("Extract selected") && + archiveManager.includes("Extract all") && + archiveManager.includes("Select visible") && + !archiveManager.includes("Cancel pending") && + !archiveManager.includes("Runtime Boundary") && + archiveManager.includes("handleEntryKeyboard") && + archiveManager.includes("Preview loaded through Runtime.") && + archiveManager.includes("async function extractSelectedEntries()") && + archiveManager.includes("async function extractAllEntries()") && + archiveManager.includes("async function selectPreviewEntry(path)") && + archiveManager.includes("renderEntries()") && + objectProviderImpl.includes("LIBRARY_ARCHIVE_ENTRIES_SCHEMA") && + objectProviderImpl.includes("LIBRARY_ARCHIVE_EXTRACT_ENTRIES_SCHEMA") && + objectProviderImpl.includes("LIBRARY_ARCHIVE_PREVIEW_ENTRY_SCHEMA") && + objectProviderImpl.includes("MAX_ARCHIVE_LIST_ENTRIES") && + objectProviderImpl.includes("MAX_ARCHIVE_PREVIEW_BYTES") && + objectProviderImpl.includes("ArchiveEntries {") && + objectProviderImpl.includes("ArchivePreviewEntry {") && + objectProviderImpl.includes("ArchiveExtractEntries {") && + objectProviderImpl.includes("fn library_archive_entries(") && + objectProviderImpl.includes("fn archive_preview_entry(") && + objectProviderImpl.includes("fn archive_preview_entry_for_object(") && + objectProviderImpl.includes("fn preview_zip_archive_entry(") && + objectProviderImpl.includes("fn preview_tar_archive_entry(") && + objectProviderImpl.includes("fn extract_library_archive_entries(") && + objectProviderImpl.includes("fn extract_archive_entries_to_webspace_destination(") && + objectProviderImpl.includes("fn ensure_webspace_archive_write_allowed(") && + objectProviderImpl.includes("fn webspace_archive_object(") && + objectProviderImpl.includes("resolver_target_redacted") && + objectProviderImpl.includes("enum ArchiveConflictPolicy") && + objectProviderImpl.includes("fn normalized_archive_entry_path(") && + objectProviderImpl.includes('vec!["archive-manager"]') && + objectProviderImpl.includes('"archive-manager" => "Archive"') && + gatewayApi.includes('name == "archive-manager"') && + viewerGatewayApi.includes("stat_only") && + viewerGatewayApi.includes("entries: bool") && + viewerGatewayApi.includes("preview_entry: Option") && + viewerGatewayApi.includes('"archive_entries"') && + viewerGatewayApi.includes('"archive_preview_entry"') && + viewerGatewayApi.includes('"archive_extract_entries"') && + viewerGatewayApi.includes("viewer_library_roots_get") && + viewerGatewayApi.includes("viewer_library_object_post") && + viewerGatewayApi.includes("ensure_viewer_can_view_library_object") && + viewerGatewayApi.includes("handle_object_provider_runtime_request") && + viewerGatewayApi.includes("viewer supports Library object metadata only") && + viewerGatewayApi.includes("viewer does not support Library object writes") && + libraryActions.includes("query.archiveSupport = JSON.stringify") && + libraryActions.includes("contentCid(object)") && + libraryActions.includes("function deliverArchiveObject(object)") && + libraryActions.includes('isArchiveObject(object) && openWithViewer(object, "archive-manager")') && + libraryActions.includes('type: "archive:open-library-object"') && + !libraryActions.includes("function archiveLibraryObjectPayload(") && + libraryModel.includes("export function isArchiveObject(object)") && + libraryModel.includes("export function archiveLibraryObjectPayload(object)") && + libraryModel.includes("metadata?.archive_support") && + libraryModel.includes("isArchiveName(name)") && + libraryState.includes('"archive-open"') && + libraryState.includes('"archive-create"') && + !libraryState.includes("archiveMode") && + libraryApp.includes("function completeArchivePicker()") && + !libraryApp.includes("function archiveLibraryObjectPayload(") && + !libraryApp.includes("function pickerInstruction()") && + !libraryApp.includes("Choose an archive, then double-click it or press Open in Archive.") && + !libraryApp.includes("Select one item, or several same-folder items, then press Create ZIP.") && + libraryApp.includes('type: "archive:open-library-object"') && + libraryMenuSmoke.includes("Legacy.7z") && + libraryMenuSmoke.includes("Loose.zip") && + libraryMenuSmoke.includes('message?.type === "home:deliver-to-target"') && + libraryMenuSmoke.includes("archive_entries") && + libraryMenuSmoke.includes("archive_preview_entry") && + libraryMenuSmoke.includes("archive_extract_entries") && + libraryMenuSmoke.includes("Nested/deep.txt") && + libraryMenuSmoke.includes("#destination-roots") && + archiveManager.includes("function isWritableDestinationRoot(root)") && + archiveManager.includes('root.kind === "webspace-root") return false') && + libraryMenuSmoke.includes("#entry-preview") && + libraryMenuSmoke.includes("#select-all-safe") && + libraryMenuSmoke.includes("#extract-all") && + libraryMenuSmoke.includes("#open-existing-archive") && + libraryMenuSmoke.includes("#make-new-archive") && + libraryMenuSmoke.includes('get("mode") === "archive-open"') && + libraryMenuSmoke.includes('get("mode") === "archive-create"') && + libraryMenuSmoke.includes('get("returnTarget") === "archive-manager"') && + !libraryMenuSmoke.includes("#cancel-extract") && + libraryMenuSmoke.includes("#extract-status") && + libraryMenuSmoke.includes("policy_gated_unsupported_archive_family") && + libraryMenuSmoke.includes('message?.target === "archive-manager"') && + shellJs.includes('library: new Set(["archive-manager", "chat-room"])') && + libraryGatewayTests.includes("/api/viewers/archive-manager/library-object?uri=") && + libraryGatewayTests.includes("test_library_provider_lists_supported_archive_entries_through_viewer_route") && + libraryGatewayTests.includes("test_library_provider_lists_unsafe_archive_entries_as_blocked") && + libraryGatewayTests.includes("test_library_provider_selectively_extracts_archive_entries_through_viewer_route") && + libraryGatewayTests.includes("test_library_provider_selective_extract_blocks_unsafe_entries") && + libraryGatewayTests.includes("test_library_gateway_lists_external_webspace_archive_entries_without_resolver_leak") && + libraryGatewayTests.includes("test_library_gateway_imports_external_webspace_archive_entries_to_local_library") && + libraryGatewayTests.includes("test_library_gateway_webspace_archive_writeback_requires_mutable_write_adapter") && + libraryGatewayTests.includes("elastos.library.archive-preview-entry/v1") && + libraryGatewayTests.includes("/api/viewers/archive-manager/library-roots") && + libraryGatewayTests.includes("/api/provider/object/archive_preview_entry") && + libraryGatewayTests.includes("resolver_target_redacted") && + libraryGatewayTests.includes("mutable destination Space") && + libraryGatewayTests.includes("elastos.library.archive-entries/v1") && + libraryGatewayTests.includes("elastos.library.archive-extract-entries/v1") && + libraryGatewayTests.includes("StatusCode::FORBIDDEN") && + archivePolicyDoc.includes("No generic non-tar/non-zip family is approved in this branch") && + archivePolicyDoc.includes(".7z") && + archivePolicyDoc.includes(".rar") && + archivePolicyDoc.includes("Unsupported families remain visible as policy-gated archives"), + "Archive must provide an installed viewer shell, stat-only/archive-entry/preview/root viewer routes, supported-family browsing, preview, selected/all extraction, policy-gated unsupported archive UX, conflict receipts, policy documentation, and no direct viewer provider access or unsafe extraction", +); +assert( + gatewayApi.includes("HOME_DESKTOP_OBJECTS_SCHEMA") && + gatewayApi.includes("home_desktop_objects_summary") && + gatewayApi.includes("home_desktop_events_signature") && + gatewayApi.includes("is_home_desktop_object_layout_entry") && + gatewayApi.includes('entry.strip_prefix("object:")') && + gatewayApi.includes('format!("{root}/.Trash")') && + gatewayApi.includes('registry.send_raw("object", &request).await') && + gatewayApi.includes('"op": "list"') && + gatewayApi.includes('"op": "events"') && + gatewayApi.includes('"uri": uri'), + "Home desktop must project localhost://Users/self/Desktop through the registered object provider, not direct filesystem access or server-local helpers", +); +assert( + shellCore.includes("function desktopObjects(summary)") && + shellCore.includes("function desktopObjectEntryId(object)") && + shellCore.includes("function desktopLayoutEntries(summary)"), + "Home layout state must treat Library Desktop objects as first-class desktop entries", +); +assert( + shellSurface.includes("attachDesktopObjectInteractions") && + shellSurface.includes("export function openSelectedDesktopEntry()") && + shellSurface.includes('openTarget("library", { query: { uri: object.uri } })') && + shellSurface.includes("desktopObjectViewer(object)") && + shellSurface.includes('openTarget(viewer,') && + shellSurface.includes('objectUri: object.uri') && + shellSurface.includes("function desktopObjectContextMenuItems(target)") && + shellSurface.includes('action: "open-desktop-object-new-window"') && + shellSurface.includes('action: "download-desktop-object"') && + shellSurface.includes('action: "properties-desktop-object"') && + shellSurface.includes("function libraryActionForObject(object, action)") && + shellSurface.includes('action === "download-desktop-object" ? "download" : "properties"') && + shellJs.includes("openSelectedDesktopEntry()") && + !shellJs.includes("openTarget(shellState.selectedDesktopTargetId)"), + "Home desktop objects must open and expose PC2-style object actions through Library/Documents orchestration", +); +assert( + libraryState.includes("initialObjectUri: queryParams.get(\"objectUri\")") && + libraryState.includes("initialAction: queryParams.get(\"action\")") && + libraryState.includes("initialActionHandled: false") && + libraryApp.includes("async function runInitialObjectAction()") && + libraryApp.includes("const object = objectByUri(state.initialObjectUri)") && + libraryApp.includes("state.currentObject?.uri === state.initialObjectUri") && + libraryApp.includes('state.initialAction === "properties"') && + libraryApp.includes('state.initialAction === "download"') && + libraryApp.includes("await downloadObject(object)"), + "Library must accept Home-delegated object actions by launch query without moving object-provider authority into Home", +); +assert( + shellJs.includes("desktopObjectsChanged(previous, summary)") && + shellJs.includes('kind === "home.desktop.changed"'), + "Home shell must refresh when Library Desktop objects change", +); +assert( + libraryApp.includes('menuAction("Open With", null') && + libraryApp.includes("children: viewers.map") && + libraryApp.includes('menuAction("Sort By", null') && + libraryApp.includes('menuAction("Name"') && + libraryApp.includes('menuAction("Date Modified"') && + libraryApp.includes('menuAction("New", null') && + libraryApp.includes('menuAction("Folder"') && + libraryApp.includes('menuAction("Show Hidden"') && + libraryApp.includes("library.showHidden") && + libraryApp.includes('menuAction("Paste Into Folder"') && + libraryApp.includes('menuAction("Properties"') && + libraryApp.includes('menuAction("Delete"') && + libraryApp.includes('if (canPasteInto(state.currentUri)) actions.push(menuAction("Paste"') && + libraryMenu.includes('if (!menuActions.length)') && + !libraryApp.includes('menuAction("Undo"') && + !libraryApp.includes("Copy Runtime URI") && + !libraryApp.includes("Get Info") && + !/[⌘⌫⇧]/.test(library) && + !/(Apple|Finder|-apple|BlinkMac|SF Pro)/.test(library), + "Library context menus must follow PC2 Explorer labels without platform-branded shortcuts", +); +assert( + libraryDialog.includes("properties-card") && + libraryDialog.includes("window-item-properties") && + libraryDialog.includes("item-props-tabview") && + libraryDialog.includes("item-props-tab-btn") && + libraryDialog.includes("item-props-tab-content") && + libraryDialog.includes("item-props-tbl") && + libraryDialog.includes('data-tab="general"') && + libraryDialog.includes('data-tab="runtime"') && + libraryDialog.includes("propertiesPanel(\"general\"") && + libraryDialog.includes("propertiesPanel(\"runtime\"") && + libraryDialog.includes("propertiesPanel(\"archive\"") && + libraryDialog.includes("smartWebIdentity") && + libraryDialog.includes("safeAvailabilitySummary") && + libraryDialog.includes("safePublishReceiptSummary") && + libraryDialog.includes("safeShareReceiptSummary") && + libraryDialog.includes("Published CID") && + libraryDialog.includes("Published Link") && + libraryDialog.includes("publishedCid(object)") && + libraryDialog.includes("propertiesVisibilitySummary") && + libraryDialog.includes("copyableValue(identity.contentId") && + libraryDialog.includes("data-prop-copy") && + libraryDialog.includes("props-copy-btn") && + libraryDialog.includes("item-prop-badge") && + libraryDialog.includes("Availability Summary") && + libraryDialog.includes("Publish Receipt Summary") && + libraryDialog.includes("Share Receipt Summary") && + !libraryDialog.includes("Availability Receipt") && + !libraryDialog.includes("Share Receipt") && + !libraryDialog.includes("item-props-tab-btn-versions") && + libraryDialog.includes("Resolver Target") && + libraryCss.includes(".window-item-properties") && + libraryCss.includes(".item-props-tab-content-selected") && + libraryCss.includes(".item-prop-label") && + libraryCss.includes(".item-prop-val") && + libraryCss.includes(".props-copy-btn") && + libraryCss.includes(".item-prop-badge") && + !libraryCss.includes(".properties-hero") && + !libraryCss.includes(".properties-icon"), + "Library Properties must render the PC2-style tabbed property table instead of a wide diagnostic metadata grid", +); +assert( + objectProviderImpl.includes("published_cid: Option") && + objectProviderImpl.includes("fn raw_sha256_cid(") && + objectProviderImpl.includes("\"current_cid\"") && + libraryApp.includes("publishedCid(object)") && + libraryApp.includes("Copy Published Link") && + libraryApp.includes("Copy Content CID") && + libraryActions.includes("publishedCid(object)") && + libraryMenuSmoke.includes("SMOKE_LOCAL_CONTENT_CID") && + libraryMenuSmoke.includes("SMOKE_PUBLISHED_CID"), + "Library object model must separate current file-byte content_cid from published_cid/public elastos:// links", +); +assert( + fileManagerMigrationDoc.includes("Local mutable storage") && + fileManagerMigrationDoc.includes("CID is content identity, not a storage-location guarantee") && + fileManagerMigrationDoc.includes("Private files are SmartWeb object heads") && + todayLibraryTracker.includes("Library object identity is split deliberately") && + todayLibraryTracker.includes("current immutable raw-byte `content_cid`") && + todayLibraryTracker.includes("public `elastos://` links use `published_cid`"), + "Docs must explain that local files are provider-owned mutable storage with CIDs, while published_cid is the public SmartWeb availability identity", +); +assert( + libraryApi.includes("/api/provider/object/upload") && + libraryApi.includes("/api/provider/object/upload/start") && + libraryApi.includes("/api/provider/object/upload/${encodeURIComponent(uploadId)}/chunk") && + libraryApi.includes("CHUNKED_UPLOAD_THRESHOLD_BYTES") && + libraryApi.includes("CHUNKED_UPLOAD_BYTES") && + libraryApi.includes("uploadFailureMessage") && + libraryApi.includes("public gateway body-size limit") && + !libraryApi.includes("/api/provider/library/upload"), + "Library upload must use object-provider upload only, use chunk sessions for large files, and explain edge-proxy 413 body-size failures", +); +assert( + library.includes("elements.statusText.classList.toggle(\"hidden\", !text)") && + libraryMenuSmoke.includes("public gateway body-size limit"), + "Library status messages, including upload body-size failures, must be visible to users", +); +assert( + libraryMenuSmoke.includes("/api/provider/object/") && + libraryMenuSmoke.includes("/api/provider/object/upload/start") && + libraryMenuSmoke.includes("LargeVideo.mp4") && + libraryMenuSmoke.includes("http-chunk-session") && + !libraryMenuSmoke.includes("/api/provider/library/"), + "Library menu smoke must exercise canonical object-provider routes without retired library-provider fallback paths", +); +assert( + !library.includes("moveObjectWithPrompt") && + !library.includes("copyObjectWithPrompt") && + !library.includes("window.prompt") && + !library.includes("window.confirm") && + !library.includes("Restore to URI") && + !library.includes("repairSelectedObjects") && + !library.includes("Repair availability"), + "Library must not ship browser-native prompts/confirms, raw create/move/copy/restore prompts, or repair-only implementation leftovers", +); +assert( + !libraryIndex.includes('id="selection-actions"') && + !library.includes("renderSelectionActions") && + !library.includes("data-selection-action"), + "Library must keep actions in the Explorer right-click menu, not a disruptive selection strip", +); +assert( + libraryApp.includes("function activeRootForUri(uri)") && + libraryApp.includes(".sort((left, right) => right.uri.length - left.uri.length)") && + libraryApp.includes('sidebar: document.querySelector(".sidebar")') && + libraryApp.includes("function orderRoots(roots)") && + libraryApp.includes("function reorderPlace(sourceRootId, targetRootId") && + libraryApp.includes("localStorage.setItem(\"library.sidebarOrder\"") && + libraryState.includes("sidebarOrder: readStoredStringArray(storage.getItem(\"library.sidebarOrder\"))") && + libraryApp.includes("function showPlaceMenu(uri, x, y)") && + libraryApp.includes('menuAction("Open in New Window", () => openTarget("library", { uri: root.uri }))') && + libraryEvents.includes('elements.sidebar?.addEventListener("contextmenu"') && + libraryEvents.includes('elements.places.addEventListener("contextmenu"') && + libraryEvents.includes('application/x-elastos-library-root-id') && + libraryEvents.includes("markPlaceDropTarget(elements, button, event)") && + libraryEvents.includes("showPlaceMenu(button.dataset.uri, event.clientX, event.clientY)") && + libraryEvents.includes("event.stopPropagation();\n hideMenu();") && + libraryCss.includes(".place.window-sidebar-item-dragging") && + libraryCss.includes(".place[data-drop-position=\"before\"]"), + "Library sidebar must suppress browser right-click on chrome, expose Open/Open in New Window on place items, persist user root ordering, and mark only the most specific active place", +); +assert( + libraryRender.includes('const badgesMarkup = badges ? `${badges}` : "";') && + libraryCss.includes("grid-template-rows: 45px auto 18px;") && + libraryCss.includes('.content[data-view="grid"] .badges') && + libraryCss.includes('.content[data-view="list"] .badges') && + !libraryCss.includes(".badges {\n position: absolute") && + !libraryCss.includes(".content[data-view=\"list\"] .badges {\n display: none;"), + "Library Published/blocked/trash badges must be layout participants and remain visible in list view instead of overlaying icons or disappearing", +); +assert( + libraryRender.includes('elements.content.dataset.empty = "true"') && + libraryRender.includes('class="empty-inner"') && + libraryCss.includes('.content[data-empty="true"]') && + libraryRender.includes("No objects in this space") && + libraryRender.includes("Localhost is your signed local object space") && + libraryRender.includes("Elastos and mounted spaces resolve through providers") && + !libraryRender.includes("No connected spaces") && + libraryActions.includes("This location is read-only.") && + !libraryActions.includes("Mounted WebSpaces are read-only resolver handles."), + "Library empty Spaces states must be centered and explicit instead of rendering a cramped generic folder message", +); +assert( + ['"refresh"', '"cache"', '"sync"'].every((op) => webspaceProvider.includes(op)) && + webspaceProvider.includes("Refresh {") && + webspaceProvider.includes("Cache {") && + webspaceProvider.includes("Sync {") && + webspaceProvider.includes("fn refresh_handle(") && + webspaceProvider.includes("fn cache_handle(") && + webspaceProvider.includes("fn sync_handle(") && + webspaceProvider.includes("elastos.webspace.refresh-receipt/v1") && + webspaceProvider.includes("elastos.webspace.cache-receipt/v1") && + webspaceProvider.includes("elastos.webspace.sync-receipt/v1") && + webspaceProvider.includes("refresh_replaces_index_and_persists_refreshed_head") && + webspaceProvider.includes("sync_clears_dirty_fork_head_without_claiming_byte_sync") && + webspaceCmd.includes("async fn refresh(") && + webspaceCmd.includes("async fn cache(") && + webspaceCmd.includes("async fn sync(") && + serverMain.includes("Refresh {") && + serverMain.includes("Cache {") && + serverMain.includes("Sync {"), + "WebSpace provider must expose real provider-owned refresh/cache/sync lifecycle operations and CLI verbs instead of only static head/status metadata", +); +assert( + webspaceProvider.includes('"health"') && + webspaceProvider.includes("Health {") && + webspaceProvider.includes("fn health_report(") && + webspaceProvider.includes("fn health_for_handle(") && + webspaceProvider.includes("elastos.webspace.health/v1") && + webspaceProvider.includes("elastos.webspace.resolver-health/v1") && + webspaceProvider.includes( + "health_reports_external_resolver_attention_and_metadata_readiness", + ) && + webspaceProvider.includes("dirty_head_count") && + webspaceCmd.includes("async fn health(") && + webspaceCmd.includes("WebSpace health:") && + serverMain.includes("Health {"), + "WebSpace provider must expose resolver health as a provider/CLI contract with metadata-ready, mounted-no-index, and dirty-head coverage", +); +assert( + ['"write"', '"mkdir"', '"delete"'].every((op) => webspaceProvider.includes(op)) && + webspaceProvider.includes("OBJECT_TABLE_SCHEMA") && + webspaceProvider.includes("struct WebSpaceObject") && + webspaceProvider.includes("fn write_handle(") && + webspaceProvider.includes("fn mkdir_handle(") && + webspaceProvider.includes("fn delete_handle(") && + webspaceProvider.includes("materialized_object_handle") && + webspaceProvider.includes("elastos.webspace.write-receipt/v1") && + webspaceProvider.includes("elastos.webspace.mkdir-receipt/v1") && + webspaceProvider.includes("elastos.webspace.delete-receipt/v1") && + webspaceProvider.includes("DEFAULT_MUTABLE_ACCESS_POLICY") && + webspaceProvider.includes("mutable_mount_materializes_objects_and_persists_them") && + objectProviderImpl.includes("async fn webspace_write_bytes(") && + objectProviderImpl.includes("async fn webspace_mkdir(") && + objectProviderImpl.includes("async fn webspace_delete_permanently(") && + objectProviderImpl.includes("handle_library_upload_bytes_runtime") && + gatewayProviderProxy.includes("handle_library_upload_bytes_runtime") && + libraryGatewayTests.includes( + "test_library_gateway_mutates_writable_webspace_through_runtime_provider", + ), + "WebSpace provider and Library gateway must support local materialized mutable WebSpace objects without exposing raw resolver authority", +); +assert( + providerRegistry.includes("fn apply_provider_byte_range(") && + providerRegistry.includes("fn apply_provider_stream_response(") && + providerRegistry.includes("fn decode_provider_stream_payload(") && + providerRegistry.includes('const PROVIDER_STREAM_SCHEMA: &str = "elastos.provider.stream/v1"') && + providerRegistry.includes('const PROVIDER_STREAM_ENCODING: &str = "base64-chunks"') && + providerRegistry.includes("fn attach_provider_invocation_envelope(") && + providerRegistry.includes("elastos.provider.invocation/v1") && + providerRegistry.includes("runtime-local-provider-plane") && + providerRegistry.includes("carrier-provider-plane") && + providerRegistry.includes("struct ProviderCarrierRoute") && + providerRegistry.includes("enum ProviderInvocationTransport") && + providerRegistry.includes("trait ProviderCarrierInvoker") && + providerRegistry.includes("set_carrier_invoker") && + providerRegistry.includes("provider_carrier_route_receipt") && + providerRegistry.includes("route.peer_did.as_deref()") && + providerRegistry.includes( + "Carrier provider invocation requires registered Carrier invoker", + ) && + providerRegistry.includes("fn provider_invocation_capability(") && + providerRegistry.includes("provider byte range requires response data.data base64 payload") && + providerRegistry.includes("provider stream expected {expected} bytes") && + providerRegistry.includes("provider stream chunk index mismatch") && + providerRegistry.includes("base64::engine::general_purpose::STANDARD") && + providerRegistry.includes("provider byte range expected {expected} bytes") && + providerRegistry.includes("provider invocation request must not predeclare runtime field") && + providerRegistry.includes('response["data"]["runtime_invocation"]["schema"]') && + providerRegistry.includes('response["_runtime_transfer"]["capability"]') && + providerRegistry.includes('response["_runtime_transfer"]["transport"]') && + providerRegistry.includes("test_provider_invocation_attaches_range_progress_transfer_receipt") && + providerRegistry.includes('assert_eq!(sliced, b"abcdefghij");') && + providerRegistry.includes( + "test_provider_invocation_stream_normalizes_range_progress_transfer_receipt", + ) && + providerRegistry.includes("test_provider_invocation_rejects_malformed_stream_payload") && + providerRegistry.includes("test_provider_invocation_rejects_predeclared_runtime_metadata") && + providerRegistry.includes("test_provider_invocation_carrier_routes_through_registered_invoker") && + providerRegistry.includes("test_provider_invocation_carrier_requires_registered_invoker"), + "Runtime provider invocation must send typed source/target/capability envelopes to target providers, enforce byte-range and stream receipts, expose Carrier provider-plane transport, and reject malformed provider stream payloads", +); +assert( + carrierRuntime.includes('"provider_invoke"') && + carrierRuntime.includes("MAX_CARRIER_REPLICATION_CANDIDATES") && + carrierRuntime.includes("MAX_CARRIER_AVAILABILITY_TICKET_LEN") && + carrierRuntime.includes("MAX_CARRIER_AVAILABILITY_ENDPOINT_ID_LEN") && + carrierRuntime.includes("struct CarrierAvailabilityRequirements") && + carrierRuntime.includes("struct CarrierReplicationProof") && + carrierRuntime.includes("remote_receipt: Option") && + carrierRuntime.includes("carrier_provider_invoke_registry") && + carrierRuntime.includes("validate_carrier_provider_invocation") && + carrierRuntime.includes("decode_carrier_provider_stream_payload") && + carrierRuntime.includes("remote_content_provider_response_bytes") && + carrierRuntime.includes("remote_content_receipt_peer_selection_summary") && + carrierRuntime.includes("remote_content_receipt_peer_selection_replicas_summary") && + carrierRuntime.includes("remote_content_receipt_accounting_summary") && + carrierRuntime.includes("remote_content_receipt_abuse_controls_summary") && + carrierRuntime.includes("carrier_provider_target_allowed") && + carrierRuntime.includes("fetch_content_via_carrier_provider_invocation") && + carrierRuntime.includes("ensure_content_via_carrier_provider_invocation") && + carrierRuntime.includes("import_content_via_carrier_provider_invocation") && + carrierRuntime.includes("import_object_content_via_carrier_provider_invocation") && + carrierRuntime.includes("import_exact_content_via_carrier_provider_invocation") && + carrierRuntime.includes("MAX_CARRIER_OBJECT_IMPORT_FILES") && + carrierRuntime.includes("MAX_CARRIER_OBJECT_IMPORT_BYTES") && + carrierRuntime.includes("remote_content_receipt_summary") && + carrierRuntime.includes("content_availability_replicas") && + carrierRuntime.includes("carrier_replica_candidate_score") && + carrierRuntime.includes("CarrierPeerReputation") && + carrierRuntime.includes("CarrierPeerReputationStore") && + carrierRuntime.includes("carrier_reputation_score") && + carrierRuntime.includes("carrier-peer-reputation.json") && + carrierRuntime.includes("with_provider_registry_and_data_dir") && + carrierRuntime.includes("save_carrier_peer_reputation") && + carrierRuntime.includes("content_availability_replicas_with_reputation") && + carrierRuntime.includes('"selection_reason"') && + carrierRuntime.includes('"local_reputation"') && + carrierRuntime.includes('"replica_summary_limit"') && + carrierRuntime.includes('"replicas_truncated"') && + carrierRuntime.includes("carrier_peer_selection_json") && + carrierRuntime.includes("carrier_provider_quota") && + carrierRuntime.includes("carrier_abuse_controls_json") && + carrierRuntime.includes("carrier_remote_candidate_limit") && + carrierRuntime.includes('["remote_receipt"]["abuse_controls"]') && + carrierRuntime.includes('"requirements_exceed_quota"') && + carrierRuntime.includes('"effective_max_replicas"') && + carrierRuntime.includes('"carrier_provider_invocation_guardrail"') && + carrierRuntime.includes("test_carrier_quota_marks_impossible_replica_requirements") && + carrierRuntime.includes( + "test_carrier_remote_candidate_limit_keeps_live_multi_peer_requirement", + ) && + carrierRuntime.includes("carrier_provider_replication") && + carrierRuntime.includes('"remote_receipt"') && + carrierRuntime.includes('"storage_quota_status"') && + carrierRuntime.includes('"content_bytes"') && + carrierRuntime.includes('"local_only": true') && + carrierRuntime.includes('"transfer": "stream"') && + carrierRuntime.includes("ProviderTransfer::Stream") && + carrierRuntime.includes('"carrier_provider_invoke"') && + carrierRuntime.includes('"content" | "availability" | "rights" | "key" | "decrypt" | "drm"') && + carrierRuntime.includes("CarrierProviderInvoker") && + carrierRuntime.includes("ProviderCarrierInvoker for CarrierProviderInvoker") && + carrierRuntime.includes("invoke_provider(") && + carrierRuntime.includes("carrier_endpoint_matches_peer") && + carrierRuntime.includes("provider_invoke carrier metadata must not expose connect_ticket") && + carrierRuntime.includes("test_carrier_provider_invoke_dispatches_runtime_enveloped_request") && + carrierRuntime.includes("test_carrier_provider_invoke_accepts_stream_contract_metadata") && + carrierRuntime.includes("test_carrier_provider_invoke_rejects_stream_without_contract_metadata") && + carrierRuntime.includes("test_carrier_provider_invoke_rejects_raw_backend_target") && + carrierRuntime.includes("test_carrier_availability_fetch_uses_provider_invocation_transport") && + carrierRuntime.includes("test_carrier_replication_proof_uses_remote_content_provider_invocation") && + carrierRuntime.includes("test_carrier_replication_falls_back_to_exact_import_when_remote_pin_fails") && + carrierRuntime.includes("test_carrier_replication_prefers_object_import_when_manifest_exists") && + carrierRuntime.includes("test_carrier_availability_ensure_proves_remote_replica_via_provider_plane") && + carrierRuntime.includes( + "test_carrier_availability_requires_remote_attempt_for_live_proof_when_min_met", + ) && + carrierRuntime.includes("test_carrier_peer_selection_proof_redacts_connect_tickets") && + carrierRuntime.includes( + "test_remote_content_receipt_peer_selection_summary_redacts_replica_rows", + ) && + carrierRuntime.includes( + "test_remote_content_receipt_peer_selection_summary_marks_truncated_rows", + ) && + carrierRuntime.includes("test_content_availability_replicas_are_scored_and_sorted") && + carrierRuntime.includes("test_content_availability_replicas_apply_local_runtime_reputation") && + carrierRuntime.includes("test_carrier_peer_reputation_persists_local_history") && + carrierRuntime.includes("test_content_availability_replicas_ignore_signed_repair_only_announcements") && + carrierRuntime.includes("test_content_availability_replicas_ignore_oversized_candidate_metadata") && + carrierRuntime.includes('remote_transfer["transfer"], "stream"') && + serverInfra.includes("set_carrier_invoker") && + serverInfra.includes("CarrierAvailabilityProvider::with_provider_registry") && + serverInfra.includes("CarrierProviderInvoker::new()") && + serverInfra.includes("maybe_spawn_content_repair_scheduler") && + serverInfra.includes("ELASTOS_CONTENT_REPAIR_SCHEDULER") && + serverInfra.includes("invoke_content_repair_worker(") && + serverInfra.includes("content_repair_scheduler_config_clamps_operator_env") && + serverInfra.includes("content_repair_scheduler_is_opt_in"), + "Carrier provider invocation must be Runtime-mediated over provider_invoke, service-provider-only, Stream-contract validated for Carrier availability fetch, registered only with the built-in Carrier node, prove remote replicas through remote content/ensure+status, enforce bounded peer-selection/quota metadata, ignore repair-only announcements as candidates, provide an opt-in bounded content repair scheduler, and must not leak raw connect tickets", +); +assert( + contentProvider.includes("struct AvailabilityRequirements") && + contentProvider.includes("struct ContentRepairTask") && + contentProvider.includes("struct ContentFetchTransfer") && + contentProvider.includes("IMPORT_EXACT_MAX_BYTES") && + contentProvider.includes("IMPORT_OBJECT_MAX_FILES") && + contentProvider.includes("AVAILABILITY_DASHBOARD_SCHEMA") && + contentProvider.includes("CONTENT_ACCOUNTING_SCHEMA") && + contentProvider.includes("CONTENT_STORAGE_QUOTA_SCHEMA") && + contentProvider.includes("CONTENT_ABUSE_CONTROLS_SCHEMA") && + contentProvider.includes("REPAIR_TASK_SCHEMA") && + contentProvider.includes("REPAIR_WORKER_RUN_SCHEMA") && + contentProvider.includes("REPAIR_WORKER_ABUSE_CONTROLS_SCHEMA") && + contentProvider.includes("REPAIR_WORKER_DEFAULT_MAX_ATTEMPTS") && + contentProvider.includes('"repair_worker"') && + contentProvider.includes("fn record_repair_task(") && + contentProvider.includes("fn availability_dashboard(") && + contentProvider.includes("fn availability_quota_status(") && + contentProvider.includes("fn content_accounting_json(") && + contentProvider.includes("fn content_accounting_observation_from_publish_request(") && + contentProvider.includes("fn content_accounting_from_previous_or_unknown(") && + contentProvider.includes("fn local_abuse_controls_json(") && + contentProvider.includes("fn provider_abuse_controls_json(") && + contentProvider.includes('"verified_remote_receipts"') && + contentProvider.includes('"recent_remote_replicas"') && + contentProvider.includes('"recent_remote_replica_limit"') && + contentProvider.includes('"recent_remote_replicas_truncated"') && + contentProvider.includes('"local_reputation"') && + contentProvider.includes('"local_runtime"') && + contentProvider.includes('"replica_bytes_estimate"') && + contentProvider.includes('"storage_quota_policy"') && + contentProvider.includes('"abuse_controls"') && + contentProvider.includes('dashboard["data"]["proofs"]["live_multi_peer"]') && + contentProvider.includes('dashboard["data"]["quota"]["by_status"]["within_quota"]') && + contentProvider.includes("async fn run_repair_worker(") && + contentProvider.includes("fn validate_repair_worker_invocation(") && + contentProvider.includes("repair-tasks.jsonl") && + contentProvider.includes("fn provider_transfer_value(") && + contentProvider.includes("async fn import_exact(") && + contentProvider.includes("async fn import_object(") && + contentProvider.includes("fn validate_import_exact_invocation(") && + contentProvider.includes("fn validate_import_object_invocation(") && + contentProvider.includes("fn validate_import_object_payload_bounds(") && + contentProvider.includes("fn validate_runtime_invocation_fields(") && + contentProvider.includes("fn provider_stream_payload_bytes(") && + contentProvider.includes("fn validate_network_availability_claim(") && + contentProvider.includes("content_repair_worker_guardrail") && + contentProvider.includes("runtime_invocation_required") && + contentProvider.includes('"requirements": requirements.to_json()') && + contentProvider.includes('ProviderTransfer::Stream =>') && + contentProvider.includes('content fetch transfer must be bytes or stream') && + contentProvider.includes('fn provider_response_stream(') && + contentProvider.includes("content_fetch_propagates_range_progress_transfer_receipt") && + contentProvider.includes("content_fetch_stream_returns_provider_stream_payload") && + contentProvider.includes("content_fetch_ranges_availability_provider_when_local_backend_misses") && + contentProvider.includes( + "content_fetch_stream_ranges_availability_provider_when_local_backend_misses", + ) && + contentProvider.includes("content_fetch_local_only_skips_availability_provider") && + contentProvider.includes("multi-peer availability requires live_multi_peer_proof=true") && + contentProvider.includes("network availability peer_selection requires mode or strategy") && + contentProvider.includes("Carrier availability announcement requires a topic") && + contentProvider.includes("content_publish_rejects_unproven_multi_peer_availability_claim") && + contentProvider.includes("content_publish_requires_peer_selection_policy_metadata") && + contentProvider.includes("content_publish_records_local_only_repair_task") && + contentProvider.includes("content_import_exact_accepts_matching_cid_stream") && + contentProvider.includes("content_import_exact_rejects_cid_mismatch_and_unpins_import") && + contentProvider.includes("content_import_exact_requires_runtime_provider_invocation") && + contentProvider.includes("content_import_object_requires_runtime_provider_invocation") && + contentProvider.includes("content_import_object_reconstructs_manifest_directory") && + contentProvider.includes('"carrier_object_import"') && + contentProvider.includes('status["data"]["accounting"]["content_bytes"]') && + contentProvider.includes("content_repair_worker_requires_runtime_provider_invocation") && + contentProvider.includes("content_repair_worker_retries_queued_availability_task") && + contentProvider.includes("content_repair_worker_enforces_attempt_budget") && + contentProvider.includes("content_status_without_cid_returns_availability_dashboard") && + contentProvider.includes('dashboard["data"]["proofs"]["recent_remote_replicas"]') && + contentCmd.includes("#[command(name = \"repair-worker\")]") && + contentCmd.includes("fn repair_worker_request(") && + contentCmd.includes("fn content_command_builds_repair_worker_request(") && + contentCmd.includes("ProviderInvocationTransport::Local") && + contentCmd.includes("ProviderTransfer::Json") && + contentProvider.includes("content_publish_enforces_availability_requirements") && + availabilityProvider.includes("requirements: Value") && + availabilityProvider.includes("fn upstream_peer_selection(") && + availabilityProvider.includes("multi-replica availability target response requires peer_selection metadata") && + availabilityProvider.includes("upstream_multi_replica_network_available_requires_peer_selection"), + "Content availability must enforce requested replica/quota/live-proof requirements before recording network/Carrier availability claims, persist repair task state, keep repair-worker passes Runtime-provider-internal with bounded guardrails, and propagate provider byte-range/progress receipts plus optional Stream payloads through fetch", +); +assert( + objectProviderImpl.includes("fn shared_access_decision(") && + objectProviderImpl.includes("fn shared_access_open_contract(") && + objectProviderImpl.includes("fn validate_shared_access_recipient_proof(") && + objectProviderImpl.includes("fn shared_access_recipient_proof_state(") && + objectProviderImpl.includes("elastos.library.recipient-proof/v1") && + objectProviderImpl.includes("elastos.library.recipient-proof-state/v1") && + objectProviderImpl.includes("recipient_proof requires proof_binding_id") && + objectProviderImpl.includes("requires passkey proof binding") && + gatewayProviderProxy.includes('object.remove("recipient_proof")') && + gatewayProviderProxy.includes('"proof_binding_id": context.proof_binding_id.as_deref().unwrap_or_default()') && + gatewayProviderProxy.includes('"source": "runtime-launch-grant"') && + objectProviderImpl.includes("elastos.library.access-decision/v1") && + objectProviderImpl.includes("elastos.library.shared-open/v1") && + objectProviderImpl.includes("runtime-provider-fetch") && + objectProviderImpl.includes('"allowed": false') && + objectProviderImpl.includes('"reason": err.to_string()') && + libraryDialog.includes("Share Grants / Key Release") && + libraryDialog.includes("Recipient Grants / Key Release") && + libraryDialog.includes("contentSecurity?.published_payload") && + libraryMenuSmoke.includes("Share Grants / Key Release") && + libraryMenuSmoke.includes("Recipient Grants / Key Release") && + libraryMenuSmoke.includes("not_required_for_plain_published_content") && + libraryGatewayTests.includes("ready_for_plain_content_fetch") && + libraryGatewayTests.includes("recipient_proof_verified") && + libraryGatewayTests.includes("authority.proof_binding_id") && + libraryGatewayTests.includes("requires Runtime recipient_proof") && + libraryGatewayTests.includes("not authorized"), + "Library recipient sharing must expose explicit access decisions, Runtime recipient-proof state, open contracts, key-release state, and denied-access audit coverage", +); +assert( + libraryApp.includes("function syncPlacesActive()") && + libraryRender.includes('loading="eager" decoding="async"') && + !library.includes("hydrateInlineIcons") && + !library.includes("loadIconMarkup") && + !/function renderAll\(\) \{\s*renderPlaces\(\);/.test(library), + "Library must not rebuild the sidebar or run async icon hydration on every folder render", +); +assert( + libraryNavigation.includes("LIBRARY_HISTORY_SCHEMA") && + libraryNavigation.includes("LIBRARY_HISTORY_GUARD_SCHEMA") && + libraryNavigation.includes("window.history.pushState") && + libraryNavigation.includes("window.history.replaceState") && + libraryEvents.includes('window.addEventListener("popstate"') && + libraryNavigation.includes("window.history.back()") && + libraryNavigation.includes("window.history.forward()"), + "Library must take over browser Back/Forward for Explorer navigation instead of letting native history leave the capsule", +); +assert( + libraryRender.includes("document.createDocumentFragment()") && + libraryRender.includes("elements.content.replaceChildren(fragment)"), + "Library content pane must redraw with one DOM replacement to avoid sluggish view changes", +); +assert( + libraryCss.includes("--explorer-list-columns: 30px minmax(260px, 1fr) 170px 86px 180px;") && + libraryCss.includes(".content[data-view=\"list\"] .item-date {\n grid-column: 3;") && + libraryCss.includes(".explore-table-headers-th--size {\n grid-column: 4;\n padding: 0 10px;\n text-align: right;") && + libraryCss.includes(".explore-table-headers-th--type {\n grid-column: 5;"), + "Library list rows and headers must share one Explorer column grid", +); +assert( + libraryCss.includes("max-height: calc(100vh - 16px);") && + libraryMenu.includes("event.stopPropagation();") && + libraryMenu.includes("contextMenu.style.visibility = \"hidden\"") && + libraryMenu.includes("function normalizedMenuActions(actions)") && + libraryMenu.includes("divider.setAttribute(\"role\", \"separator\")"), + "Library context menu must use one viewport-aware renderer whose action buttons do not get swallowed by document click handling", +); +assert( + libraryMenuSmoke.includes("PASS Library menu smoke") && + libraryMenuSmoke.includes("openBackgroundMenu") && + libraryMenuSmoke.includes("openItemMenu") && + libraryMenuSmoke.includes("Spaces background menu") && + libraryMenuSmoke.includes('label: "Spaces"') && + !libraryMenuSmoke.includes('label: "WebSpaces"') && + libraryMenuSmoke.includes("right-click must not open the Library context menu") && + libraryMenuSmoke.includes("right-click must cancel the browser context menu") && + libraryMenuSmoke.includes("sidebar place menu") && + libraryMenuSmoke.includes("Open in New Window") && + libraryMenuSmoke.includes("sidebar must mark only") && + libraryMenuSmoke.includes("empty state must be centered horizontally") && + libraryMenuSmoke.includes("Published badge must be visible in ${view} view") && + libraryMenuSmoke.includes("New Folder must use inline naming, not window.prompt") && + libraryMenuSmoke.includes("window.history.back()") && + libraryMenuSmoke.includes("window.history.forward()") && + libraryMenuSmoke.includes("Upload must use the raw Library upload transport") && + libraryMenuSmoke.includes("Drop upload must use the raw Library upload transport") && + libraryMenuSmoke.includes("F2 rename must call rename") && + libraryMenuSmoke.includes("Selected-name click must not start rename; rename is explicit through context menu or F2") && + libraryMenuSmoke.includes("Folder Download must use the raw Library download transport") && + libraryMenuSmoke.includes("Download must use the raw Library download transport") && + libraryMenuSmoke.includes("Compress to ZIP must call compress_archive") && + libraryMenuSmoke.includes("Compress Selected to ZIP must call compress_archive") && + libraryMenuSmoke.includes("mounted WebSpace menu") && + libraryMenuSmoke.includes("indexed WebSpace file menu") && + libraryMenuSmoke.includes("/WebSpaces/Google/Drive/Project X/file.pdf") && + libraryMenuSmoke.includes("Indexed WebSpace Download must use the raw Library download transport") && + libraryMenuSmoke.includes("Elastos content WebSpace file menu") && + libraryMenuSmoke.includes("Status must call status") && + libraryMenuSmoke.includes("Repair must call repair") && + libraryMenuSmoke.includes("Share must copy the published elastos:// link") && + libraryMenuSmoke.includes("Double-clicking a selected folder name must open it instead of starting rename") && + libraryMenuSmoke.includes("List-view double-clicking a selected folder name must open it instead of starting rename") && + libraryMenuSmoke.includes("List-view double-clicking a selected file name must open it without later starting rename") && + libraryMenuSmoke.includes("List-view double-clicking the active rename editor must not also open/read the file") && + libraryMenuSmoke.includes("Shift-click must select a visible PC2-style range in list view") && + libraryMenuSmoke.includes("Enter on a selected file must open/read it like PC2") && + libraryMenuSmoke.includes("Shift-F10 selected file menu") && + libraryMenuSmoke.includes("Enter on multiple selected files must open every selected item like PC2") && + libraryMenuSmoke.includes("Double-clicking a selected file name must open it instead of starting rename") && + libraryMenuSmoke.includes("Double-click preview without an installed viewer must call read") && + libraryMenuSmoke.includes("Copy/Paste Into Folder must call copy") && + libraryMenuSmoke.includes("Drag/drop move must call move") && + libraryMenuSmoke.includes("Alt-drag/drop copy must call copy") && + libraryMenuSmoke.includes("Publish must call publish") && + libraryMenuSmoke.includes("Delete Permanently must use in-app confirmation, not window.confirm") && + libraryMenuSmoke.includes("Delete must move the object to Trash") && + libraryMenuSmoke.includes("Trash sidebar place menu"), + "Library must have an operator smoke for context-menu reliability, WebSpaces read-only behavior, and core object journeys", +); +assert( + !libraryEvents.includes("NAME_CLICK_RENAME_DELAY_MS") && + !libraryEvents.includes("cancelPendingNameClickRename") && + !libraryEvents.includes("clickedName") && + !sourceBlock( + libraryEvents, + 'elements.content.addEventListener("click"', + "Library click handler", + ).includes("startRename(") && + libraryEvents.includes("function isNameEditorTarget(target)") && + sourceBlock( + libraryEvents, + 'elements.content.addEventListener("dblclick"', + "Library double-click handler", + ).includes("isNameEditorTarget(event.target)") && + libraryEvents.includes("selectRangeTo(item.dataset.uri") && + libraryEvents.includes('event.key === "Enter"') && + libraryEvents.includes("openSelectedObjects(objects, openObject, showError)") && + libraryEvents.includes("async function openSelectedObjects(objects, openObject, showError)") && + libraryEvents.includes('event.key === "ContextMenu"') && + libraryEvents.includes('event.shiftKey && event.key === "F10"') && + libraryEvents.includes("isMenuOpen(elements)") && + libraryEvents.includes('if (event.key === "F2" && !editable)') && + libraryApp.includes('menuAction("Rename", () => startRename(object))'), + "Library rename must be explicit through context menu/F2; content clicks must never start rename, and PC2-style range selection/keyboard open/context menu must stay wired", +); +assert( + libraryLiveSmoke.includes("verify_public_library_assets") && + libraryLiveSmoke.includes("/apps/library/src/uploads.js") && + libraryLiveSmoke.includes("/apps/library/src/api.js") && + libraryLiveSmoke.includes("/api/provider/object/upload") && + libraryLiveSmoke.includes("/api/provider/object/upload/start") && + libraryLiveSmoke.includes("CHUNKED_UPLOAD_THRESHOLD_BYTES") && + libraryLiveSmoke.includes("http-chunk-session") && + libraryLiveSmoke.includes("/api/provider/object/download/raw") && + libraryLiveSmoke.includes("raw download readback mismatch") && + libraryLiveSmoke.includes("elastos.object.transfer.receipt/v1") && + libraryLiveSmoke.includes("function isNameEditorTarget(target)") && + libraryLiveSmoke.includes("NAME_CLICK_RENAME_DELAY_MS") && + libraryLiveSmoke.includes("clickedName") && + libraryLiveSmoke.includes("cancelPendingNameClickRename") && + libraryLiveSmoke.includes("selectRangeTo(item.dataset.uri") && + libraryLiveSmoke.includes('event.key === "Enter"') && + libraryLiveSmoke.includes("openSelectedObjects(objects, openObject, showError)") && + libraryLiveSmoke.includes('event.key === "ContextMenu"') && + libraryLiveSmoke.includes('event.shiftKey && event.key === "F10"') && + libraryLiveSmoke.includes("signed provider path skipped") && + libraryLiveSmoke.includes("PASS Library live publish/share smoke"), + "Library live smoke must verify public static assets without a signed session and provider publish/share with a signed Home session", +); +assert( + libraryIndex.includes('rel="stylesheet" href="library.css"') && + libraryIndex.includes('type="module" src="src/app.js"') && + !libraryIndex.includes("