From 0fab4ce17d21ecd9a55ea2d05450af7f4cd35c56 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 13:54:32 +0000 Subject: [PATCH] Instant upload: register already-owned content by hash, zero bytes on the wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 of the delta-sync plan. Re-uploading a file the user already has (another device, a restore, a duplicate) used to transfer every byte just for the server to discard them as a dedup hit. The frontend now computes the file's BLAKE3 locally and, on a hit, registers the file with a single ~150-byte metadata call. Server — POST /api/files/by-hash: - All checks live in the application service per the AuthZ rule: Create permission on the target folder via the authorization engine, hash ownership via the existing user-scoped query (a non-owned hash returns 404 — same shape as "no such blob" — and emits an instant_upload.rejected audit event), quota on the logical size. - On success: one ref_count bump + the existing save_file_with_blob row registration (compensation included); is_new_blob=false so lifecycle hooks skip thumbnail regeneration. ~10 ms warm. - The storage-usage service is now built before the application services and injected, instead of only living on AppState. Client — WASM BLAKE3 + worker: - wasm/oxicloud-hash: the exact same blake3 crate the server uses, compiled with WASM SIMD128 (~660 MB/s measured) so browser hashes match server content addresses bit for bit. Built by scripts/build-wasm.sh; the artifacts (45 KB wasm + 8 KB glue) are vendored like pdf.js — no npm dependencies, no wasm toolchain needed for regular builds. - static/js/workers/hashWorker.js streams the File in 8 MiB slices off the main thread (constant RAM at any file size). - features/files/instantUpload.js orchestrates: threshold (8 MiB — below it the round-trips cost more than the bytes), user-scoped /api/dedup/check, by-hash registration, and silent fallback to the normal byte upload on any miss, race or unsupported environment. Wired into both uploadFiles and uploadFolderEntries. - biome.json vendors exclusion fixed to cover nested directories (previous vendors were .mjs and never matched the *.js include). Verified end-to-end against PostgreSQL 16: node-driven WASM hash equals the server's content_hash for a 20 MB file; by-hash returns 201 in ~10 ms warm with a 151-byte request (vs 20,971,873 bytes for the byte upload); the copy downloads byte-identical and the manifest ref_count goes 1→2; a second user probing the same hash gets exists:false and 404 plus the audit line; duplicate name → 409, malformed hash → 400; worker and wasm are served with correct MIME (application/wasm). https://claude.ai/code/session_01WdNenpnujNR2sc32XVvwfS --- .gitignore | 1 + biome.json | 2 +- scripts/build-wasm.sh | 39 +++ .../services/file_upload_service.rs | 142 +++++++++- src/common/di.rs | 25 +- src/interfaces/api/handlers/file_handler.rs | 84 ++++++ src/interfaces/api/mod.rs | 1 + src/interfaces/api/routes.rs | 5 +- static/js/core/types.js | 23 ++ static/js/features/files/fileOperations.js | 119 ++++---- static/js/features/files/instantUpload.js | 177 ++++++++++++ .../vendors/hash-wasm/oxicloud_hash_wasm.js | 261 ++++++++++++++++++ .../hash-wasm/oxicloud_hash_wasm_bg.wasm | Bin 0 -> 45242 bytes static/js/workers/hashWorker.js | 79 ++++++ wasm/oxicloud-hash/Cargo.lock | 184 ++++++++++++ wasm/oxicloud-hash/Cargo.toml | 30 ++ wasm/oxicloud-hash/src/lib.rs | 98 +++++++ 17 files changed, 1209 insertions(+), 61 deletions(-) create mode 100755 scripts/build-wasm.sh create mode 100644 static/js/features/files/instantUpload.js create mode 100644 static/js/vendors/hash-wasm/oxicloud_hash_wasm.js create mode 100644 static/js/vendors/hash-wasm/oxicloud_hash_wasm_bg.wasm create mode 100644 static/js/workers/hashWorker.js create mode 100644 wasm/oxicloud-hash/Cargo.lock create mode 100644 wasm/oxicloud-hash/Cargo.toml create mode 100644 wasm/oxicloud-hash/src/lib.rs diff --git a/.gitignore b/.gitignore index 0d7b9636..905f8a95 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,4 @@ tests/e2e/playwright/.auth/ # Test fixtures generated on-the-fly by tests/api/run.sh tests/fixtures/chunk-over-cap-*.bin +wasm/oxicloud-hash/target/ diff --git a/biome.json b/biome.json index 5abc8fe5..9a5abf93 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,7 @@ } }, "files": { - "includes": ["static/**/*.js", "static/**/*.css", "static/**/*.json", "!static/js/vendors/"] + "includes": ["static/**/*.js", "static/**/*.css", "static/**/*.json", "!static/js/vendors"] }, "formatter": { "enabled": true, diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh new file mode 100755 index 00000000..9a64cff7 --- /dev/null +++ b/scripts/build-wasm.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Rebuild the vendored BLAKE3 WASM module (static/js/vendors/hash-wasm/). +# +# The generated artifacts ARE committed — like the other vendored modules +# (pdf.js) — so regular frontend/backend builds never need the wasm +# toolchain. Re-run this script only when wasm/oxicloud-hash/ changes +# (e.g. bumping the blake3 crate, or adding FastCDC for the delta-sync +# client) and commit the regenerated files. +# +# Requirements (one-time): +# rustup target add wasm32-unknown-unknown +# cargo install wasm-bindgen-cli --locked +# +# wasm-bindgen-cli's version must match the crate's `wasm-bindgen` +# dependency; cargo prints a clear error when they drift. + +set -euo pipefail +cd "$(dirname "$0")/.." + +CRATE=wasm/oxicloud-hash +OUT=static/js/vendors/hash-wasm + +# SIMD128 is baseline in every evergreen browser (Chrome 91+, Firefox 89+, +# Safari 16.4+) and is worth ~3-4× in hashing throughput. Browsers without +# it fail instantiation; the frontend detects that and falls back to a +# plain byte upload. +RUSTFLAGS="-C target-feature=+simd128" \ + cargo build \ + --manifest-path "$CRATE/Cargo.toml" \ + --target wasm32-unknown-unknown \ + --release + +wasm-bindgen \ + --target web \ + --no-typescript \ + --out-dir "$OUT" \ + "$CRATE/target/wasm32-unknown-unknown/release/oxicloud_hash_wasm.wasm" + +echo "Vendored: $(ls -la "$OUT" | tail -n +2 | awk '{print $9, "("$5" bytes)"}' | xargs)" diff --git a/src/application/services/file_upload_service.rs b/src/application/services/file_upload_service.rs index 49f00a95..02ac99a0 100644 --- a/src/application/services/file_upload_service.rs +++ b/src/application/services/file_upload_service.rs @@ -1,14 +1,19 @@ use std::sync::Arc; +use uuid::Uuid; use crate::application::dtos::file_dto::FileDto; +use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::file_lifecycle::FileLifecycleHook; use crate::application::ports::file_ports::{FileUploadUseCase, StoredBlob}; -use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; +use crate::application::ports::storage_ports::{FileReadPort, FileWritePort, StorageUsagePort}; use crate::application::services::storage_usage_service::StorageUsageService; use crate::common::errors::DomainError; +use crate::domain::services::authorization::{Permission, Resource, Subject}; use crate::infrastructure::repositories::pg::FileBlobReadRepository; use crate::infrastructure::repositories::pg::FileBlobWriteRepository; +use crate::infrastructure::services::dedup_service::DedupService; use crate::infrastructure::services::file_content_cache::FileContentCache; +use crate::infrastructure::services::pg_acl_engine::PgAclEngine; use tracing::{debug, info, warn}; /// Helper function to extract username from folder path string. @@ -49,6 +54,18 @@ pub struct FileUploadService { content_cache: Option>, /// Single lifecycle dispatcher — fires on_file_created / on_file_updated. file_lifecycle_hook: Option>, + /// Dependencies of the instant-upload path + /// (`create_file_from_owned_blob_with_perms`); `None` in minimal test + /// wiring. + instant_upload: Option, +} + +/// Everything the instant-upload path needs beyond the upload service's own +/// ports: permission checks, the dedup index, and quota enforcement. +struct InstantUploadDeps { + authz: Arc, + dedup: Arc, + quota: Arc, } impl FileUploadService { @@ -60,6 +77,7 @@ impl FileUploadService { storage_usage_service: None, content_cache: None, file_lifecycle_hook: None, + instant_upload: None, } } @@ -74,9 +92,26 @@ impl FileUploadService { storage_usage_service: None, content_cache: None, file_lifecycle_hook: None, + instant_upload: None, } } + /// Wires the authorization engine, dedup index and quota service that + /// power the instant-upload path. + pub fn with_instant_upload( + mut self, + authz: Arc, + dedup: Arc, + quota: Arc, + ) -> Self { + self.instant_upload = Some(InstantUploadDeps { + authz, + dedup, + quota, + }); + self + } + /// Configures the content cache for invalidation on file updates. pub fn with_content_cache(mut self, cache: Arc) -> Self { self.content_cache = Some(cache); @@ -98,6 +133,111 @@ impl FileUploadService { self } + // ── Instant upload (zero content bytes) ────────────────────── + + /// Register a new file row pointing at a blob the caller **already + /// owns** — the instant-upload path: the client proved it has the + /// content by hash, so no bytes travel and no chunk is written. Pure + /// metadata: one ref_count bump + one row INSERT. + /// + /// Security model (mirrors `GET /api/dedup/check/{hash}`): + /// - The caller must have `Create` permission on the target folder. + /// - The hash is only claimable when the caller owns at least one + /// non-trashed file referencing it — never a global content oracle. + /// A non-owned hash returns `NotFound` (anti-enumeration: same shape + /// as "no such blob") and emits an `instant_upload.rejected` audit + /// event with the real reason. + /// - Quota is enforced on the logical size, exactly like a byte upload. + pub async fn create_file_from_owned_blob_with_perms( + &self, + caller_id: Uuid, + name: String, + folder_id: String, + hash: &str, + ) -> Result { + let Some(InstantUploadDeps { + authz, + dedup, + quota, + }) = &self.instant_upload + else { + return Err(DomainError::internal_error( + "FileUpload", + "instant upload is not wired (authz/dedup/quota missing)", + )); + }; + + // ── AuthZ: Create on the target folder ─────────────────── + let folder_uuid = Uuid::parse_str(&folder_id) + .map_err(|_| DomainError::not_found("Folder", folder_id.clone()))?; + authz + .require( + Subject::User(caller_id), + Permission::Create, + Resource::Folder(folder_uuid), + ) + .await?; + + // ── Ownership: only blobs the caller can already read ──── + if !dedup + .user_owns_blob_reference(hash, &caller_id.to_string()) + .await + { + tracing::info!( + target: "audit", + event = "instant_upload.rejected", + reason = "hash_not_owned", + caller_id = %caller_id, + blob_hash = %hash, + "👮🏻‍♂️ Instant upload rejected: caller owns no file referencing the claimed hash", + ); + return Err(DomainError::not_found("Blob", hash)); + } + + let Some(metadata) = dedup.get_blob_metadata(hash).await else { + // Lost a race with the last-reference delete — same shape as + // "never existed". + return Err(DomainError::not_found("Blob", hash)); + }; + + // ── Quota on the logical size, before taking any reference ── + quota.check_storage_quota(caller_id, metadata.size).await?; + + // The manifest knows the original content type; fall back to the + // new name's extension when the stored one is generic. + let claimed = metadata.content_type.as_deref().unwrap_or(""); + let content_type = + match crate::common::mime_detect::refine_content_type(&[], &name, claimed) { + ct if ct.is_empty() => "application/octet-stream".to_string(), + ct => ct, + }; + + // Take the reference the row registration will consume (it releases + // it again on any failure). A concurrent GC between the ownership + // check and this bump surfaces as NotFound — the client falls back + // to a normal byte upload. + dedup.add_reference(hash).await?; + + let dto = self + .upload_file_streaming( + name, + Some(folder_id), + content_type, + StoredBlob { + hash: hash.to_string(), + size: metadata.size, + is_new_blob: false, + }, + ) + .await?; + + info!( + "⚡ INSTANT UPLOAD: {} ({} bytes, 0 transferred, ID: {})", + dto.name, metadata.size, dto.id + ); + Ok(dto) + } + // ── private helpers ────────────────────────────────────────── /// Optionally update storage usage after a successful upload. diff --git a/src/common/di.rs b/src/common/di.rs index 97cf013d..49e9bf87 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -439,6 +439,7 @@ impl AppServiceFactory { repos: &RepositoryServices, trash_service: Option>, authz: &Arc, + storage_usage: &Arc, ) -> ApplicationServices { // Main services let folder_service = Arc::new(FolderService::new( @@ -452,7 +453,12 @@ impl AppServiceFactory { repos.file_read_repository.clone(), ) .with_content_cache(core.file_content_cache.clone()) - .with_file_lifecycle_hook(core.file_lifecycle.clone()), + .with_file_lifecycle_hook(core.file_lifecycle.clone()) + .with_instant_upload( + authz.clone(), + core.dedup_service.clone(), + storage_usage.clone(), + ), ); let file_retrieval_service = Arc::new(FileRetrievalService::new_with_cache( @@ -733,9 +739,19 @@ impl AppServiceFactory { .create_trash_service(&repos, &core, &authorization) .await; + // 3c. Storage usage / quota service (needed by the instant-upload + // path inside the application services, and re-exposed on AppState + // for the handler-side quota checks of the byte-upload paths). + let storage_usage = self.create_storage_usage_service(&repos, &pool, &maintenance_pool); + // 4. Application services (with trash + authz already wired) - let mut apps = - self.create_application_services(&core, &repos, trash_service.clone(), &authorization); + let mut apps = self.create_application_services( + &core, + &repos, + trash_service.clone(), + &authorization, + &storage_usage, + ); // 5. Share service let share_service = self.create_share_service(&repos, &pool, &authorization); @@ -775,8 +791,7 @@ impl AppServiceFactory { recent_service = Some(recent.clone()); apps.recent_service = Some(recent); - storage_usage_service = - Some(self.create_storage_usage_service(&repos, &pool, &maintenance_pool)); + storage_usage_service = Some(storage_usage.clone()); self.start_tree_etag_flush_job(&maintenance_pool); diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 903fd87e..67c27f57 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -69,6 +69,57 @@ impl FileHandler { } } + /// Instant upload: create a file from a blob the caller already owns. + /// + /// Zero content bytes travel — the client proved possession of the + /// content by hash (it computed BLAKE3 locally and confirmed via + /// `GET /api/dedup/check/{hash}`), so the server only bumps the blob's + /// reference count and registers the metadata row. + /// + /// All authorization (folder Create permission, hash ownership with + /// anti-enumeration, quota) lives in the application service. + pub(super) async fn create_file_by_hash_impl( + State(state): State, + auth_user: AuthUser, + Json(request): Json, + ) -> impl IntoResponse { + // Hash shape check — same contract as /api/dedup/check/{hash}. + if request.hash.len() != 64 || !request.hash.chars().all(|c| c.is_ascii_hexdigit()) { + return AppError::bad_request( + "Invalid hash format. Expected BLAKE3 (64 hex characters)", + ) + .into_response(); + } + // Basename only — same path-traversal guard as the multipart upload. + let filename = request + .name + .rsplit('/') + .next() + .unwrap_or(&request.name) + .rsplit('\\') + .next() + .unwrap_or(&request.name) + .to_string(); + if filename.is_empty() { + return AppError::bad_request("File name must not be empty").into_response(); + } + + match state + .applications + .file_upload_service + .create_file_from_owned_blob_with_perms( + auth_user.id, + filename, + request.folder_id, + &request.hash, + ) + .await + { + Ok(file) => Self::created_json_response(&file).into_response(), + Err(err) => Self::domain_error_response(err).into_response(), + } + } + /// Core upload logic shared by [`Self::upload_file`] and /// [`Self::upload_file_with_thumbnails`]. /// @@ -1039,6 +1090,39 @@ pub async fn upload_file_with_thumbnails( FileHandler::upload_file_with_thumbnails_impl(state, auth_user, multipart).await } +/// Request body for the instant-upload endpoint. +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateFileByHashRequest { + /// File name to create (path components are stripped). + pub name: String, + /// Target folder ID (the caller needs Create permission on it). + pub folder_id: String, + /// BLAKE3 hash (64 hex chars) of content the caller already owns. + pub hash: String, +} + +#[utoipa::path( + post, + path = "/api/files/by-hash", + request_body = CreateFileByHashRequest, + responses( + (status = 201, description = "File created from an already-owned blob — zero bytes transferred", body = FileDto), + (status = 400, description = "Invalid hash format or empty name"), + (status = 404, description = "No owned blob with this hash (anti-enumeration: same shape as unknown hash)"), + (status = 409, description = "A file with this name already exists in the folder"), + (status = 507, description = "Storage quota exceeded"), + ), + security(("bearerAuth" = [])), + tag = "files" +)] +pub async fn create_file_by_hash( + state: State, + auth_user: AuthUser, + request: Json, +) -> impl IntoResponse { + FileHandler::create_file_by_hash_impl(state, auth_user, request).await +} + #[utoipa::path( get, path = "/api/files/{id}", diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index e725fc67..51eb8e6d 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -80,6 +80,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; // File handlers (free functions — see file_handler.rs for why) handlers::file_handler::list_files_query, handlers::file_handler::upload_file_with_thumbnails, + handlers::file_handler::create_file_by_hash, handlers::file_handler::download_file, handlers::file_handler::get_thumbnail, handlers::file_handler::upload_thumbnail, diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index f2ac1662..262dfa9c 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -55,8 +55,8 @@ use crate::interfaces::api::handlers::chunked_upload_handler::{ cancel_upload, complete_upload, create_upload, get_upload_status, upload_chunk, }; use crate::interfaces::api::handlers::file_handler::{ - delete_file, download_file, get_file_metadata, get_thumbnail, list_files_query, - move_file_simple, rename_file, upload_file_with_thumbnails, upload_thumbnail, + create_file_by_hash, delete_file, download_file, get_file_metadata, get_thumbnail, + list_files_query, move_file_simple, rename_file, upload_file_with_thumbnails, upload_thumbnail, }; #[allow(deprecated)] use crate::interfaces::api::handlers::folder_handler::{ @@ -229,6 +229,7 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { let basic_file_router = Router::new() .route("/", get(list_files_query)) .route("/upload", post(upload_file_with_thumbnails)) + .route("/by-hash", post(create_file_by_hash)) .route("/{id}", get(download_file)) .route( "/{id}/thumbnail/{size}", diff --git a/static/js/core/types.js b/static/js/core/types.js index 99ba6f02..f0b49ae1 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -518,3 +518,26 @@ * @typedef {{kind: 'user', id: string} | {kind: 'group', id: string}} GroupMemberItem */ +// ------------------- Instant upload (dedup) + +/** + * Response from `GET /api/dedup/check/{hash}` — user-scoped: `exists` only + * reflects content the CALLER already owns, never global existence. + * Mirrors `HashCheckResponse` on the server (`dedup_handler.rs`). + * @typedef {Object} HashCheckAnswer + * @property {boolean} exists + * @property {string} hash BLAKE3 echoed back (64 hex chars) + * @property {number} [existing_size] size in bytes, present when `exists` + */ + +/** + * Request body for `POST /api/files/by-hash` (instant upload — registers a + * file from an already-owned blob, zero content bytes on the wire). + * Mirrors `CreateFileByHashRequest` on the server (`file_handler.rs`). + * The 201 response body is a {@link FileItem}. + * @typedef {Object} CreateFileByHash + * @property {string} name file name to create (basename only) + * @property {string} folder_id target folder (caller needs Create on it) + * @property {string} hash BLAKE3 of the owned content (64 hex chars) + */ + diff --git a/static/js/features/files/fileOperations.js b/static/js/features/files/fileOperations.js index 87f7578d..a4bc092d 100644 --- a/static/js/features/files/fileOperations.js +++ b/static/js/features/files/fileOperations.js @@ -12,6 +12,7 @@ import { i18n } from '../../core/i18n.js'; import { notifications } from '../../core/notifications.js'; import { invalidateFolderMeta } from '../../model/filesModel.js'; import { triggerBrowserDownload } from '../../utils/download.js'; +import { tryInstantUpload } from './instantUpload.js'; /** * @typedef {Object} BatchResult @@ -404,20 +405,28 @@ const fileOps = { if (quotaStop) return; const file = readableFiles[idx]; - const formData = new FormData(); - if (targetFolderId) formData.append('folder_id', targetFolderId); - formData.append('file', file); + // ── Instant upload: when the server already has this exact + // content for this user, register it by hash — zero bytes + // on the wire. Any miss/failure falls back to a byte upload. + /** @type {UploadAnswer | null} */ + let result = await tryInstantUpload(file, targetFolderId); + if (result) { + if (batchId) { + try { + notifications.updateFile(batchId, file.name, 100, result.ok ? 'done' : 'error'); + } catch (_) {} + } + } else { + const formData = new FormData(); + if (targetFolderId) formData.append('folder_id', targetFolderId); + formData.append('file', file); - console.log(`Uploading file to folder: ${targetFolderId || 'root'}`, { - file: file.name, - size: file.size - }); - - // Scale stall timeout with file size: - // base 120s + 60s per GB, so a 7 GB file gets ~540s stall limit - const sizeGB = file.size / (1024 * 1024 * 1024); - const dynamicTimeout = Math.max(120000, 120000 + Math.ceil(sizeGB) * 60000); - const result = await this._uploadFileXHR(formData, batchId, file.name, dynamicTimeout); + // Scale stall timeout with file size: + // base 120s + 60s per GB, so a 7 GB file gets ~540s stall limit + const sizeGB = file.size / (1024 * 1024 * 1024); + const dynamicTimeout = Math.max(120000, 120000 + Math.ceil(sizeGB) * 60000); + result = await this._uploadFileXHR(formData, batchId, file.name, dynamicTimeout); + } uploadedCount++; @@ -663,48 +672,54 @@ const fileOps = { const parentPath = parts.slice(0, -1).join('/'); const targetFolderId = folderMap.get(parentPath) || currentFolderId; - // ── FIFO/pipe guard (0-byte files only) ── - // Named pipes (runit supervise/control) report size=0 - // but block on open(). Pre-read only 0-byte files into - // memory; files with size>0 are always regular files and - // go straight to FormData (zero extra memory copy). - /** @type {Blob} */ - let uploadFile = file; // default: use original File - if (file.size === 0) { - try { - const buf = await Promise.race([ - file.arrayBuffer(), - new Promise((_, rej) => setTimeout(() => rej(new Error('read-timeout')), 2000)) - ]); - uploadFile = new Blob([buf], { - type: file.type || 'application/octet-stream' - }); - } catch { - console.warn(`[SKIP] #${idx} ${rel} — cannot read 0-byte file (FIFO/pipe?), skipping`); - uploadedCount++; - successCount++; - if (batchId) { - try { - notifications.fileCompleted(batchId, true); - } catch (_) {} + // ── Instant upload (zero bytes on the wire) ── + // Same fallback contract as uploadFiles: a null result + // means "do the byte upload". The shared accounting + // after this try block handles both outcomes. + const instant = await tryInstantUpload(file, targetFolderId); + if (instant) { + result = instant; + } else { + // ── FIFO/pipe guard (0-byte files only) ── + // Named pipes (runit supervise/control) report size=0 + // but block on open(). Pre-read only 0-byte files into + // memory; files with size>0 are always regular files and + // go straight to FormData (zero extra memory copy). + /** @type {Blob} */ + let uploadFile = file; // default: use original File + if (file.size === 0) { + try { + const buf = await Promise.race([ + file.arrayBuffer(), + new Promise((_, rej) => setTimeout(() => rej(new Error('read-timeout')), 2000)) + ]); + uploadFile = new Blob([buf], { + type: file.type || 'application/octet-stream' + }); + } catch { + console.warn(`[SKIP] #${idx} ${rel} — cannot read 0-byte file (FIFO/pipe?), skipping`); + uploadedCount++; + successCount++; + if (batchId) { + try { + notifications.fileCompleted(batchId, true); + } catch (_) {} + } + return; } - return; } + + const formData = new FormData(); + formData.append('folder_id', targetFolderId); + formData.append('file', uploadFile, file.name); + + const thisTimeout = + file.size === 0 + ? TIMEOUT_MS_ZERO + : Math.max(TIMEOUT_MIN_MS, TIMEOUT_BASE_MS + Math.ceil(file.size / (1024 * 1024)) * TIMEOUT_PER_MB_MS); + + result = await this._uploadFileFetch(formData, thisTimeout); } - - const formData = new FormData(); - formData.append('folder_id', targetFolderId); - formData.append('file', uploadFile, file.name); - - const thisTimeout = - file.size === 0 - ? TIMEOUT_MS_ZERO - : Math.max(TIMEOUT_MIN_MS, TIMEOUT_BASE_MS + Math.ceil(file.size / (1024 * 1024)) * TIMEOUT_PER_MB_MS); - console.log(`[UPLOAD START] #${idx} ${rel} (${file.size} bytes, timeout=${thisTimeout}ms)`); - - result = await this._uploadFileFetch(formData, thisTimeout); - - console.log(`[UPLOAD END] #${idx} ${rel} ok=${result.ok}${result.errorMsg ? ` err=${result.errorMsg}` : ''}`); } catch (e) { result = { ok: false, diff --git a/static/js/features/files/instantUpload.js b/static/js/features/files/instantUpload.js new file mode 100644 index 00000000..bda924e1 --- /dev/null +++ b/static/js/features/files/instantUpload.js @@ -0,0 +1,177 @@ +/** + * OxiCloud - Instant upload (zero-byte dedup upload) + * + * Before transferring a file's bytes, compute its BLAKE3 locally (in a + * worker, off the main thread) and ask the server whether the caller + * already owns that exact content (`GET /api/dedup/check/{hash}` — the + * check is user-scoped, never a global content oracle). On a hit, a + * single metadata call (`POST /api/files/by-hash`) registers the file + * with ZERO content bytes on the wire. + * + * Performance posture: + * - Hashing runs in a dedicated worker with WASM SIMD128 — the UI thread + * never blocks, RAM stays constant (8 MiB slices). + * - Files below {@link INSTANT_UPLOAD_MIN_SIZE} skip the whole dance: + * two extra round-trips cost more than just uploading them. + * - Any failure (no WASM support, worker error, server miss, races) + * falls back silently to the normal byte upload — instant upload is + * an optimization, never a gate. + */ + +import { getCsrfHeaders } from '../../core/csrf.js'; + +/** + * Files smaller than this upload normally: hashing + two round-trips + * outweigh the transfer. 8 MiB matches the chunked-upload threshold's + * order of magnitude. + */ +export const INSTANT_UPLOAD_MIN_SIZE = 8 * 1024 * 1024; + +// Absolute URL on purpose — works in dev and in the release IIFE bundle +// (same pattern as the pdf.js loader in thumbnail.js). +const HASH_WORKER_URL = '/js/workers/hashWorker.js'; + +/** Hashing budget: 60 s base + 30 s per GB (WASM SIMD does ~0.5-1 GB/s). */ +const HASH_TIMEOUT_BASE_MS = 60000; +const HASH_TIMEOUT_PER_GB_MS = 30000; + +/** + * `false` once the environment proved unable to run the worker/WASM + * (old browser, blocked worker) — later files skip straight to the byte + * upload instead of failing the same way again. `null` = not yet known. + * @type {boolean | null} + */ +let _instantUploadUsable = null; + +/** + * Hash a file in a one-shot worker. Resolves `null` on any failure — + * the caller falls back to a normal upload. + * @param {File} file + * @returns {Promise} + */ +function hashFileInWorker(file) { + return new Promise((resolve) => { + /** @type {Worker} */ + let worker; + try { + worker = new Worker(HASH_WORKER_URL, { type: 'module' }); + } catch (_) { + _instantUploadUsable = false; + resolve(null); + return; + } + + const sizeGB = file.size / (1024 * 1024 * 1024); + const timeoutMs = HASH_TIMEOUT_BASE_MS + Math.ceil(sizeGB) * HASH_TIMEOUT_PER_GB_MS; + + /** @param {string | null} hash */ + const settle = (hash) => { + clearTimeout(timer); + worker.terminate(); + resolve(hash); + }; + const timer = setTimeout(() => settle(null), timeoutMs); + + worker.onmessage = (event) => { + const data = /** @type {{ ok: boolean, hash?: string, error?: string }} */ (event.data); + if (!data.ok) { + // The worker ran but WASM failed (e.g. no SIMD128 support): + // a permanent environment property, don't retry per file. + _instantUploadUsable = false; + } + settle(data.ok && data.hash ? data.hash : null); + }; + worker.onerror = () => { + // Worker script failed to load/parse — permanent. + _instantUploadUsable = false; + settle(null); + }; + + worker.postMessage({ file }); + }); +} + +/** + * Ask the server whether the caller already owns content with this hash. + * @param {string} hash + * @returns {Promise} + */ +async function callerOwnsHash(hash) { + try { + const response = await fetch(`/api/dedup/check/${hash}`, { + headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' } + }); + if (!response.ok) return false; + const body = /** @type {import('../../core/types.js').HashCheckAnswer} */ (await response.json()); + return body.exists === true; + } catch (_) { + return false; + } +} + +/** + * Try to register `file` as a zero-byte instant upload. + * + * Returns `null` whenever the byte upload should proceed (file too + * small, environment unusable, hash miss, lost race, transient errors). + * Returns an upload-result object compatible with the uploaders' + * `UploadAnswer` shape when the attempt is conclusive — success, quota + * exceeded, or name conflict (a byte upload would fail identically). + * + * @param {File} file + * @param {string | null | undefined} folderId + * @returns {Promise<{ ok: boolean, data?: any, errorMsg?: string, isQuotaError?: boolean } | null>} + */ +export async function tryInstantUpload(file, folderId) { + if (!folderId || file.size < INSTANT_UPLOAD_MIN_SIZE || _instantUploadUsable === false || typeof Worker === 'undefined') { + return null; + } + + const hash = await hashFileInWorker(file); + if (!hash) return null; + + if (!(await callerOwnsHash(hash))) return null; + + try { + const response = await fetch('/api/files/by-hash', { + method: 'POST', + headers: { + ...getCsrfHeaders(), + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache, no-store, must-revalidate' + }, + body: JSON.stringify( + /** @type {import('../../core/types.js').CreateFileByHash} */ ({ + name: file.name, + folder_id: folderId, + hash + }) + ) + }); + + if (response.status === 201) { + return { ok: true, data: await response.json() }; + } + + /** @type {string} */ + let errorMsg = `Instant upload failed (HTTP ${response.status})`; + try { + const body = await response.json(); + errorMsg = body.message || body.error || errorMsg; + } catch (_) {} + + if (response.status === 507) { + return { ok: false, isQuotaError: true, errorMsg }; + } + if (response.status === 409) { + // Duplicate name in the folder — a byte upload would hit the + // exact same conflict; surface it without transferring. + return { ok: false, errorMsg }; + } + // 404 (ownership race with a delete+GC), 4xx/5xx: fall back to the + // byte upload — the server dedups it on write anyway. + return null; + } catch (_) { + return null; + } +} diff --git a/static/js/vendors/hash-wasm/oxicloud_hash_wasm.js b/static/js/vendors/hash-wasm/oxicloud_hash_wasm.js new file mode 100644 index 00000000..2a341978 --- /dev/null +++ b/static/js/vendors/hash-wasm/oxicloud_hash_wasm.js @@ -0,0 +1,261 @@ +/** + * Incremental BLAKE3 hasher. + * + * ```js + * const h = new Blake3Hasher(); + * h.update(chunkBytes); // repeat per slice + * const hex = h.finalizeHex(); + * ``` + */ +export class Blake3Hasher { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + Blake3HasherFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_blake3hasher_free(ptr, 0); + } + /** + * Bytes hashed so far — lets the worker report progress without + * tracking its own counter. + * @returns {number} + */ + count() { + const ret = wasm.blake3hasher_count(this.__wbg_ptr); + return ret; + } + /** + * Finish and return the lowercase hex digest (64 chars). The hasher + * can keep receiving `update` calls afterwards (BLAKE3 finalization + * is non-destructive), but the frontend treats it as terminal. + * @returns {string} + */ + finalizeHex() { + let deferred1_0; + let deferred1_1; + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + wasm.blake3hasher_finalizeHex(retptr, this.__wbg_ptr); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + deferred1_0 = r0; + deferred1_1 = r1; + return getStringFromWasm0(r0, r1); + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + wasm.__wbindgen_export2(deferred1_0, deferred1_1, 1); + } + } + /** + * Create a fresh hasher. + */ + constructor() { + const ret = wasm.blake3hasher_new(); + this.__wbg_ptr = ret; + Blake3HasherFinalization.register(this, this.__wbg_ptr, this); + return this; + } + /** + * Feed one slice of the file. + * @param {Uint8Array} data + */ + update(data) { + const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_export); + const len0 = WASM_VECTOR_LEN; + wasm.blake3hasher_update(this.__wbg_ptr, ptr0, len0); + } +} +if (Symbol.dispose) Blake3Hasher.prototype[Symbol.dispose] = Blake3Hasher.prototype.free; + +/** + * One-shot convenience for small buffers. + * @param {Uint8Array} data + * @returns {string} + */ +export function blake3Hex(data) { + let deferred2_0; + let deferred2_1; + try { + const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); + const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_export); + const len0 = WASM_VECTOR_LEN; + wasm.blake3Hex(retptr, ptr0, len0); + var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); + var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); + deferred2_0 = r0; + deferred2_1 = r1; + return getStringFromWasm0(r0, r1); + } finally { + wasm.__wbindgen_add_to_stack_pointer(16); + wasm.__wbindgen_export2(deferred2_0, deferred2_1, 1); + } +} +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg___wbindgen_throw_bbadd78c1bac3a77: (arg0, arg1) => { + throw new Error(getStringFromWasm0(arg0, arg1)); + } + }; + return { + __proto__: null, + './oxicloud_hash_wasm_bg.js': import0 + }; +} + +const Blake3HasherFinalization = + typeof FinalizationRegistry === 'undefined' + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry((ptr) => wasm.__wbg_blake3hasher_free(ptr, 1)); + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if ( + cachedDataViewMemory0 === null || + cachedDataViewMemory0.buffer.detached === true || + (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer) + ) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +function getStringFromWasm0(ptr, len) { + return decodeText(ptr >>> 0, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function passArray8ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 1, 1) >>> 0; + getUint8ArrayMemory0().set(arg, ptr / 1); + WASM_VECTOR_LEN = arg.length; + return ptr; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasmInstance, wasm; +function __wbg_finalize_init(instance, module) { + wasmInstance = instance; + wasm = instance.exports; + wasmModule = module; + cachedDataViewMemory0 = null; + cachedUint8ArrayMemory0 = null; + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = module.ok && expectedResponseType(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn( + '`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n', + e + ); + } else { + throw e; + } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + + function expectedResponseType(type) { + switch (type) { + case 'basic': + case 'cors': + case 'default': + return true; + } + return false; + } +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + if (module !== undefined) { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({ module } = module); + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead'); + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + if (module_or_path !== undefined) { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({ module_or_path } = module_or_path); + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead'); + } + } + + if (module_or_path === undefined) { + module_or_path = new URL('oxicloud_hash_wasm_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if ( + typeof module_or_path === 'string' || + (typeof Request === 'function' && module_or_path instanceof Request) || + (typeof URL === 'function' && module_or_path instanceof URL) + ) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { __wbg_init as default, initSync }; diff --git a/static/js/vendors/hash-wasm/oxicloud_hash_wasm_bg.wasm b/static/js/vendors/hash-wasm/oxicloud_hash_wasm_bg.wasm new file mode 100644 index 0000000000000000000000000000000000000000..7ab00d463942f1b339a82028fd04fccc5e01d2c5 GIT binary patch literal 45242 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 Kl%;f_{Qm(B void) | null, + * postMessage: (message: unknown) => void }} + */ +const workerScope = /** @type {any} */ (self); + +/** + * Memoized WASM module (in-flight or settled), `default()` already run. + * Reset on failure so a later message can retry a transient load error. + * @type {Promise | null} + */ +let _wasmPromise = null; + +/** @returns {Promise} */ +function getWasm() { + if (!_wasmPromise) { + _wasmPromise = import(WASM_GLUE_URL) + .then(async (mod) => { + await mod.default(); + return mod; + }) + .catch((err) => { + _wasmPromise = null; + throw err; + }); + } + return _wasmPromise; +} + +workerScope.onmessage = async (event) => { + const file = /** @type {{ file: File }} */ (event.data).file; + try { + const wasm = await getWasm(); + const hasher = new wasm.Blake3Hasher(); + try { + for (let offset = 0; offset < file.size; offset += SLICE_BYTES) { + const end = Math.min(offset + SLICE_BYTES, file.size); + // eslint-disable-next-line no-await-in-loop -- sequential by design: constant RAM + const buffer = await file.slice(offset, end).arrayBuffer(); + hasher.update(new Uint8Array(buffer)); + } + workerScope.postMessage({ ok: true, hash: hasher.finalizeHex() }); + } finally { + hasher.free(); + } + } catch (err) { + workerScope.postMessage({ + ok: false, + error: err instanceof Error ? err.message : String(err) + }); + } +}; diff --git a/wasm/oxicloud-hash/Cargo.lock b/wasm/oxicloud-hash/Cargo.lock new file mode 100644 index 00000000..b053178b --- /dev/null +++ b/wasm/oxicloud-hash/Cargo.lock @@ -0,0 +1,184 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "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", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oxicloud-hash-wasm" +version = "0.1.0" +dependencies = [ + "blake3", + "wasm-bindgen", +] + +[[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 = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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 = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasm-bindgen" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +dependencies = [ + "unicode-ident", +] diff --git a/wasm/oxicloud-hash/Cargo.toml b/wasm/oxicloud-hash/Cargo.toml new file mode 100644 index 00000000..0c54d4d1 --- /dev/null +++ b/wasm/oxicloud-hash/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "oxicloud-hash-wasm" +version = "0.1.0" +edition = "2021" +description = "BLAKE3 hashing for the OxiCloud web frontend — compiled from the exact same crate the server uses, so client-side hashes match server-side content addressing bit for bit." +publish = false + +# Standalone workspace root: this crate is built only by +# scripts/build-wasm.sh (wasm32 target) and must not join the server +# workspace — `cargo test --workspace` / clippy on the host have nothing +# useful to do with a wasm cdylib. +[workspace] + +[lib] +crate-type = ["cdylib"] + +[dependencies] +# Same major as the server's Cargo.toml. `wasm32_simd` enables the WASM +# SIMD128 kernels (~3-4× over the portable implementation); every +# evergreen browser since 2023 supports it, and the frontend falls back +# to a plain byte upload when instantiation fails. +blake3 = { version = "1.8.4", default-features = false, features = ["wasm32_simd"] } +wasm-bindgen = "0.2" + +[profile.release] +# Hashing throughput is the whole point of this module. +opt-level = 3 +lto = "fat" +codegen-units = 1 +strip = true diff --git a/wasm/oxicloud-hash/src/lib.rs b/wasm/oxicloud-hash/src/lib.rs new file mode 100644 index 00000000..91cb8d55 --- /dev/null +++ b/wasm/oxicloud-hash/src/lib.rs @@ -0,0 +1,98 @@ +//! BLAKE3 for the OxiCloud web frontend. +//! +//! Compiled from the same `blake3` crate the server uses, so a hash +//! computed in the browser equals the server's content address bit for +//! bit — the property the instant-upload path depends on. +//! +//! The API is incremental on purpose: the worker feeds the file in +//! slices (`Blob.slice().arrayBuffer()`), keeping RAM constant no matter +//! how large the file is. + +use wasm_bindgen::prelude::*; + +/// Incremental BLAKE3 hasher. +/// +/// ```js +/// const h = new Blake3Hasher(); +/// h.update(chunkBytes); // repeat per slice +/// const hex = h.finalizeHex(); +/// ``` +#[wasm_bindgen] +pub struct Blake3Hasher { + inner: blake3::Hasher, +} + +#[wasm_bindgen] +impl Blake3Hasher { + /// Create a fresh hasher. + #[wasm_bindgen(constructor)] + pub fn new() -> Blake3Hasher { + Blake3Hasher { + inner: blake3::Hasher::new(), + } + } + + /// Feed one slice of the file. + pub fn update(&mut self, data: &[u8]) { + self.inner.update(data); + } + + /// Finish and return the lowercase hex digest (64 chars). The hasher + /// can keep receiving `update` calls afterwards (BLAKE3 finalization + /// is non-destructive), but the frontend treats it as terminal. + #[wasm_bindgen(js_name = finalizeHex)] + pub fn finalize_hex(&self) -> String { + self.inner.finalize().to_hex().to_string() + } + + /// Bytes hashed so far — lets the worker report progress without + /// tracking its own counter. + pub fn count(&self) -> f64 { + self.inner.count() as f64 + } +} + +impl Default for Blake3Hasher { + fn default() -> Self { + Self::new() + } +} + +/// One-shot convenience for small buffers. +#[wasm_bindgen(js_name = blake3Hex)] +pub fn blake3_hex(data: &[u8]) -> String { + blake3::hash(data).to_hex().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The vector the frontend smoke test uses — also proves the wasm + /// build hashes identically to the server (same crate, same output). + #[test] + fn hello_world_vector() { + let hasher = { + let mut h = Blake3Hasher::new(); + h.update(b"Hello, "); + h.update(b"World!"); + h + }; + assert_eq!( + hasher.finalize_hex(), + "288a86a79f20a3d6dccdca7713beaed178798296bdfa7913fa2a62d9727bf8f8" + ); + assert_eq!( + blake3_hex(b"Hello, World!"), + "288a86a79f20a3d6dccdca7713beaed178798296bdfa7913fa2a62d9727bf8f8" + ); + } + + #[test] + fn empty_input_vector() { + assert_eq!( + blake3_hex(b""), + "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262" + ); + } +}