Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0a282c712 | |||
| 3eb0045676 | |||
| 639eb5d36c | |||
| d12d321079 | |||
| 4fcd05d3c6 | |||
| 503e25ff98 | |||
| bba0855218 | |||
| d437ffc226 | |||
| e36758f183 | |||
| adf45dce2d | |||
| eb232b6e8e | |||
| 0b9d8c4a72 | |||
| 65eb266983 | |||
| 54a0c2fb65 | |||
| 648ff98c2d | |||
| d6f178bde6 | |||
| ffaef5cb15 | |||
| d4cc1d43ea | |||
| 759becdd04 | |||
| 51e8836227 | |||
| 3c0a54e08b | |||
| c074a5d2ed | |||
| a9ba9de6be | |||
| 263e7a58b9 | |||
| 74b81d7ba8 | |||
| 0d4837184f | |||
| 7f3de92961 | |||
| a7bc902dd0 | |||
| 95d78d0d08 | |||
| b62cb0bc97 | |||
| 32346636e0 | |||
| 415ba3e76d | |||
| 6594f67d3e | |||
| 61974e0b54 | |||
| fc845956fa |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -20,4 +20,5 @@ dist_*/
|
||||
.claude/
|
||||
.serena/
|
||||
|
||||
#------# custom
|
||||
#------# custom
|
||||
rust/target
|
||||
@@ -1,68 +0,0 @@
|
||||
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
|
||||
# * For C, use cpp
|
||||
# * For JavaScript, use typescript
|
||||
# Special requirements:
|
||||
# * csharp: Requires the presence of a .sln file in the project folder.
|
||||
language: typescript
|
||||
|
||||
# whether to use the project's gitignore file to ignore files
|
||||
# Added on 2025-04-07
|
||||
ignore_all_files_in_gitignore: true
|
||||
# list of additional paths to ignore
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||
# Added (renamed) on 2025-04-07
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
project_name: "smarts3"
|
||||
153
changelog.md
153
changelog.md
@@ -1,5 +1,158 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-21 - 6.2.0 - feat(cluster)
|
||||
add shard healing, drive health heartbeats, and clustered policy directory support
|
||||
|
||||
- implements manifest-based healing that scans affected shards on offline nodes, reconstructs data with erasure coding, and rewrites recovered shards to local storage
|
||||
- includes drive status reporting in membership heartbeats by wiring DriveManager health checks into cluster heartbeat messages
|
||||
- adds clustered policies directory initialization and exposes policy storage paths from the distributed coordinator
|
||||
- extends distributed coordinator support for remote shard read and delete operations plus multipart upload session metadata
|
||||
|
||||
## 2026-03-21 - 6.1.0 - feat(cluster)
|
||||
add clustered storage backend with QUIC transport, erasure coding, and shard management
|
||||
|
||||
- introduces cluster configuration in Rust and TypeScript, including seed nodes, drive paths, heartbeat settings, and erasure coding options
|
||||
- adds core cluster modules for membership, topology state, object manifests, placement, shard storage, drive management, healing scaffolding, and inter-node protocol handling
|
||||
- adds QUIC-based transport for cluster communication and integrates a distributed storage backend alongside the existing standalone FileStore
|
||||
- updates the server startup path to initialize standalone or clustered storage based on configuration and exposes a basic clusterStatus management endpoint
|
||||
- refreshes build and dependency versions to support the new clustered storage implementation
|
||||
|
||||
## 2026-03-14 - 6.0.1 - fix(rust-bridge)
|
||||
update smartrust and limit RustBridge binary lookup to dist_rust
|
||||
|
||||
- Bumps @push.rocks/smartrust from ^1.0.0 to ^1.3.2.
|
||||
- Removes rust target debug and release fallback paths from RustBridge local binary resolution, relying on dist_rust/ruststorage.
|
||||
|
||||
## 2026-03-14 - 6.0.0 - BREAKING CHANGE(core)
|
||||
Rebrand from smarts3 to smartstorage
|
||||
|
||||
- Package renamed from @push.rocks/smarts3 to @push.rocks/smartstorage
|
||||
- Class renamed from Smarts3 to SmartStorage (no backward-compatible re-export)
|
||||
- Interface renamed from ISmarts3Config to ISmartStorageConfig
|
||||
- Method renamed from getS3Descriptor to getStorageDescriptor
|
||||
- Rust binary renamed from rusts3 to ruststorage
|
||||
- Rust types renamed: S3Error→StorageError, S3Action→StorageAction, S3Config→SmartStorageConfig, S3Server→StorageServer
|
||||
- On-disk file extension changed from ._S3_object to ._storage_object (BREAKING for existing stored data)
|
||||
- Default credentials changed from S3RVER to STORAGE
|
||||
- All internal S3 branding removed; AWS S3 protocol compatibility (IAM actions, ARNs, SigV4) fully maintained
|
||||
|
||||
## 2026-02-17 - 5.3.0 - feat(auth)
|
||||
add AWS SigV4 authentication and bucket policy support
|
||||
|
||||
- Implement AWS SigV4 full verification (constant-time comparison, 15-minute clock skew enforcement) and expose default signing region (server.region = 'us-east-1').
|
||||
- Add IAM-style bucket policy engine with Put/Get/Delete policy APIs (GetBucketPolicy/PutBucketPolicy/DeleteBucketPolicy), wildcard action/resource matching, Allow/Deny evaluation, and on-disk persistence under .policies/{bucket}.policy.json.
|
||||
- Documentation and README expanded with policy usage, examples, API table entries, and notes about policy CRUD and behavior for anonymous/authenticated requests.
|
||||
- Rust code refactors: simplify storage/server result structs and multipart handling (removed several unused size/key/bucket fields), remove S3Error::to_response and error_xml helpers, and other internal cleanup to support new auth/policy features.
|
||||
|
||||
## 2026-02-17 - 5.2.0 - feat(auth,policy)
|
||||
add AWS SigV4 authentication and S3 bucket policy support
|
||||
|
||||
- Implemented real AWS SigV4 verification (HMAC-SHA256), including x-amz-date handling, clock skew enforcement and constant-time signature comparison
|
||||
- Added bucket policy model, validator and evaluation engine (Deny > Allow > NoOpinion) with a PolicyStore (RwLock cache + disk-backed .policies/*.policy.json)
|
||||
- Integrated action resolution and auth+policy pipeline into the HTTP server: authorization checks run per-request, anonymous requests are denied by default, ListAllMyBuckets requires authentication
|
||||
- Added bucket policy CRUD handlers via ?policy query parameter (GET/PUT/DELETE) and cleanup of policies on bucket deletion
|
||||
- Storage and config updates: created .policies dir and policy path helpers; default region added to server config (TS + Rust)
|
||||
- Added comprehensive tests for auth and policy behavior (policy CRUD, evaluation, per-action enforcement, auth integration)
|
||||
- Updated Rust dependencies and Cargo.toml/Cargo.lock to include hmac, sha2, hex, subtle, cpufeatures
|
||||
|
||||
## 2026-02-13 - 5.1.1 - fix(smarts3)
|
||||
replace TypeScript server with Rust-powered core and IPC bridge
|
||||
|
||||
- Major refactor: Node.js/TypeScript in-process server replaced by a Rust crate ('rusts3') with a TypeScript IPC wrapper (RustBridge).
|
||||
- Removed many TypeScript server modules (smarts3-server, filesystem-store, multipart-manager, controllers, router, context, logger, xml utils, etc.); Smarts3Server export removed — public API now proxies to the Rust binary.
|
||||
- Smarts3 now spawns and communicates with the rusts3 binary via RustBridge IPC (commands include start, stop, createBucket).
|
||||
- Build & packaging changes: build script now runs `tsrust` before `tsbuild`; added `@git.zone/tsrust` devDependency; added `dist_rust` artifacts and new cross-compile targets in npmextra.json; .gitignore updated for rust/target.
|
||||
- Dependency changes: added `@push.rocks/smartrust` (RustBridge) and simplified plugins surface; previous smartfs/smartxml usage removed from TS code and replaced by the Rust implementation + IPC.
|
||||
- Added Rust project files (rust/Cargo.toml, rust/src/*) implementing server, IPC management loop, storage, XML responses, errors, and config.
|
||||
- Documentation updated (README and hints) to describe the Rust core, supported prebuilt targets (linux_amd64, linux_arm64), IPC commands, and developer build notes.
|
||||
|
||||
## 2025-11-23 - 5.1.0 - feat(multipart)
|
||||
Implement full multipart upload support with persistent manager, periodic cleanup, and API integration
|
||||
|
||||
- Add IMultipartConfig to server config with defaults (expirationDays: 7, cleanupIntervalMinutes: 60) and merge into existing config flow
|
||||
- Introduce MultipartUploadManager: persistent upload metadata on disk, part upload/assembly, restore uploads on startup, listParts/listUploads, abort/cleanup functionality
|
||||
- Start and stop multipart cleanup task from Smarts3Server lifecycle (startCleanupTask on start, stopCleanupTask on stop) with configurable interval and expiration
|
||||
- ObjectController: support multipart endpoints (initiate, upload part, complete, abort) and move assembled final object into the object store on completion; set ETag headers and return proper XML responses
|
||||
- BucketController: support listing in-progress multipart uploads via ?uploads query parameter and return S3-compatible XML
|
||||
- Persist multipart state to disk and restore on initialization to survive restarts; perform automatic cleanup of expired uploads
|
||||
|
||||
## 2025-11-23 - 5.0.2 - fix(readme)
|
||||
Clarify contribution agreement requirement in README
|
||||
|
||||
- Updated the Issue Reporting and Security section in readme.md to make it explicit that developers must sign and comply with the contribution agreement (and complete identification) before obtaining a code.foss.global account to submit pull requests.
|
||||
|
||||
## 2025-11-23 - 5.0.1 - fix(docs)
|
||||
Clarify README wording about S3 compatibility and AWS SDK usage
|
||||
|
||||
- Update README wording to "Full S3 API compatibility" and clarify it works seamlessly with AWS SDK v3 and other S3 clients
|
||||
|
||||
## 2025-11-23 - 5.0.0 - BREAKING CHANGE(core)
|
||||
Production-ready S3-compatible server: nested config, multipart uploads, CORS, structured logging, SmartFS migration and improved error handling
|
||||
|
||||
- Breaking change: configuration format migrated from flat to nested structure (server, storage, auth, cors, logging, limits). Update existing configs accordingly.
|
||||
- Implemented full multipart upload support (initiate, upload part, complete, abort) with on-disk part management and final assembly.
|
||||
- Added CORS middleware with configurable origins, methods, headers, exposed headers, maxAge and credentials support.
|
||||
- Structured, configurable logging (levels: error|warn|info|debug; formats: text|json) and request/response logging middleware.
|
||||
- Simple static credential authentication middleware (configurable list of credentials).
|
||||
- Migrated filesystem operations to @push.rocks/smartfs (Web Streams interoperability) and removed smartbucket from production dependencies.
|
||||
- Improved S3-compatible error handling and XML responses (S3Error class and XML utilities).
|
||||
- Exposed Smarts3Server and made store/multipart managers accessible for tests and advanced usage; added helper methods like getS3Descriptor and createBucket.
|
||||
|
||||
## 2025-11-23 - 4.0.0 - BREAKING CHANGE(Smarts3)
|
||||
Migrate Smarts3 configuration to nested server/storage objects and remove legacy flat config support
|
||||
|
||||
- Smarts3.createAndStart() and Smarts3 constructor now accept ISmarts3Config with nested `server` and `storage` objects.
|
||||
- Removed support for the legacy flat config shape (top-level `port` and `cleanSlate`) / ILegacySmarts3Config.
|
||||
- Updated tests to use new config shape (server:{ port, silent } and storage:{ cleanSlate }).
|
||||
- mergeConfig and Smarts3Server now rely on the nested config shape; consumers must update their initialization code.
|
||||
|
||||
## 2025-11-23 - 3.2.0 - feat(multipart)
|
||||
Add multipart upload support with MultipartUploadManager and controller integration
|
||||
|
||||
- Introduce MultipartUploadManager (ts/classes/multipart-manager.ts) to manage multipart upload lifecycle and store parts on disk
|
||||
- Wire multipart manager into server and request context (S3Context, Smarts3Server) and initialize multipart storage on server start
|
||||
- Add multipart-related routes and handlers in ObjectController: initiate (POST ?uploads), upload part (PUT ?partNumber&uploadId), complete (POST ?uploadId), and abort (DELETE ?uploadId)
|
||||
- On complete, combine parts into final object and store via existing FilesystemStore workflow
|
||||
- Expose multipart manager on Smarts3Server for controller access
|
||||
|
||||
## 2025-11-23 - 3.1.0 - feat(logging)
|
||||
Add structured Logger and integrate into Smarts3Server; pass full config to server
|
||||
|
||||
- Introduce a new Logger class (ts/classes/logger.ts) providing leveled logging (error, warn, info, debug), text/json formats and an enable flag.
|
||||
- Integrate Logger into Smarts3Server: use structured logging for server lifecycle events, HTTP request/response logging and S3 errors instead of direct console usage.
|
||||
- Smarts3 now passes the full merged configuration into Smarts3Server (config.logging can control logging behavior).
|
||||
- Server start/stop messages and internal request/error logs are emitted via the Logger and respect the configured logging level/format and silent option.
|
||||
|
||||
## 2025-11-23 - 3.0.4 - fix(smarts3)
|
||||
Use filesystem store for bucket creation and remove smartbucket runtime dependency
|
||||
|
||||
- Switched createBucket to call the internal FilesystemStore.createBucket instead of using @push.rocks/smartbucket
|
||||
- Made Smarts3Server.store public so Smarts3 can access the filesystem store directly
|
||||
- Removed runtime import/export of @push.rocks/smartbucket from plugins and moved @push.rocks/smartbucket to devDependencies in package.json
|
||||
- Updated createBucket to return a simple { name } object after creating the bucket via the filesystem store
|
||||
|
||||
## 2025-11-23 - 3.0.3 - fix(filesystem)
|
||||
Migrate filesystem implementation to @push.rocks/smartfs and add Web Streams handling
|
||||
|
||||
- Replace dependency @push.rocks/smartfile with @push.rocks/smartfs and update README references
|
||||
- plugins: instantiate SmartFs with SmartFsProviderNode and export smartfs (remove direct fs export)
|
||||
- Refactor FilesystemStore to use smartfs directory/file APIs for initialize, reset, list, read, write, copy and delete
|
||||
- Implement Web Stream ↔ Node.js stream conversion for uploads/downloads (Readable.fromWeb and writer.write with Uint8Array)
|
||||
- Persist and read metadata (.metadata.json) and cached MD5 (.md5) via smartfs APIs
|
||||
- Update readme.hints and documentation to note successful migration and next steps
|
||||
|
||||
## 2025-11-21 - 3.0.2 - fix(smarts3)
|
||||
Prepare patch release 3.0.2 — no code changes detected
|
||||
|
||||
- No source changes in the diff
|
||||
- Bump patch version from 3.0.1 to 3.0.2 for maintenance/release bookkeeping
|
||||
|
||||
## 2025-11-21 - 3.0.1 - fix(readme)
|
||||
Add Issue Reporting and Security section to README
|
||||
|
||||
- Add guidance to report bugs, issues, and security vulnerabilities via community.foss.global
|
||||
- Inform developers how to sign a contribution agreement and get a code.foss.global account to submit pull requests
|
||||
|
||||
## 2025-11-21 - 3.0.0 - BREAKING CHANGE(Smarts3)
|
||||
Remove legacy s3rver backend, simplify Smarts3 server API, and bump dependencies
|
||||
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
{
|
||||
"gitzone": {
|
||||
"@git.zone/tsrust": {
|
||||
"targets": [
|
||||
"linux_amd64",
|
||||
"linux_arm64"
|
||||
]
|
||||
},
|
||||
"@git.zone/cli": {
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
"gitscope": "push.rocks",
|
||||
"gitrepo": "smarts3",
|
||||
"description": "A Node.js TypeScript package to create a local S3 endpoint for simulating AWS S3 operations using mapped local directories for development and testing purposes.",
|
||||
"npmPackagename": "@push.rocks/smarts3",
|
||||
"gitrepo": "smartstorage",
|
||||
"description": "A Node.js TypeScript package to create a local S3-compatible storage server using mapped local directories for development and testing purposes.",
|
||||
"npmPackagename": "@push.rocks/smartstorage",
|
||||
"license": "MIT",
|
||||
"projectDomain": "push.rocks",
|
||||
"keywords": [
|
||||
"S3 Mock Server",
|
||||
"Local S3",
|
||||
"smartstorage",
|
||||
"S3 Compatible",
|
||||
"Local Storage Server",
|
||||
"Node.js",
|
||||
"TypeScript",
|
||||
"Local Development",
|
||||
@@ -20,20 +27,26 @@
|
||||
"File Storage",
|
||||
"AWS S3 Compatibility",
|
||||
"Development Tool",
|
||||
"S3 Endpoint",
|
||||
"S3 Simulation",
|
||||
"Storage Endpoint",
|
||||
"Storage Simulation",
|
||||
"Bucket Management",
|
||||
"File Upload",
|
||||
"CI/CD Integration",
|
||||
"Developer Onboarding"
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
},
|
||||
"npmci": {
|
||||
"npmGlobalTools": [],
|
||||
"npmAccessLevel": "public"
|
||||
},
|
||||
"tsdoc": {
|
||||
"@git.zone/tsdoc": {
|
||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||
},
|
||||
"@ship.zone/szci": {
|
||||
"npmGlobalTools": []
|
||||
}
|
||||
}
|
||||
}
|
||||
45
package.json
45
package.json
@@ -1,25 +1,28 @@
|
||||
{
|
||||
"name": "@push.rocks/smarts3",
|
||||
"version": "3.0.0",
|
||||
"name": "@push.rocks/smartstorage",
|
||||
"version": "6.2.0",
|
||||
"private": false,
|
||||
"description": "A Node.js TypeScript package to create a local S3 endpoint for simulating AWS S3 operations using mapped local directories for development and testing purposes.",
|
||||
"description": "A Node.js TypeScript package to create a local S3-compatible storage server using mapped local directories for development and testing purposes.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"type": "module",
|
||||
"author": "Lossless GmbH",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test:before": "(tsrust)",
|
||||
"test": "(tstest test/ --web --verbose --logfile --timeout 60)",
|
||||
"build": "(tsbuild --web --allowimplicitany)",
|
||||
"build": "(tsrust && tsbuild tsfolders --allowimplicitany)",
|
||||
"buildDocs": "tsdoc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.937.0",
|
||||
"@git.zone/tsbuild": "^3.1.0",
|
||||
"@git.zone/tsbundle": "^2.5.2",
|
||||
"@git.zone/tsrun": "^2.0.0",
|
||||
"@git.zone/tstest": "^3.1.0",
|
||||
"@types/node": "^22.9.0"
|
||||
"@aws-sdk/client-s3": "^3.1014.0",
|
||||
"@git.zone/tsbuild": "^4.3.0",
|
||||
"@git.zone/tsbundle": "^2.9.1",
|
||||
"@git.zone/tsrun": "^2.0.1",
|
||||
"@git.zone/tsrust": "^1.3.0",
|
||||
"@git.zone/tstest": "^3.5.0",
|
||||
"@push.rocks/smartbucket": "^4.5.1",
|
||||
"@types/node": "^25.5.0"
|
||||
},
|
||||
"browserslist": [
|
||||
"last 1 chrome versions"
|
||||
@@ -30,6 +33,7 @@
|
||||
"dist/**/*",
|
||||
"dist_*/**/*",
|
||||
"dist_ts/**/*",
|
||||
"dist_rust/**/*",
|
||||
"dist_ts_web/**/*",
|
||||
"assets/**/*",
|
||||
"cli.js",
|
||||
@@ -37,15 +41,14 @@
|
||||
"readme.md"
|
||||
],
|
||||
"dependencies": {
|
||||
"@push.rocks/smartbucket": "^4.3.0",
|
||||
"@push.rocks/smartfile": "^11.2.7",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartxml": "^2.0.0",
|
||||
"@tsclass/tsclass": "^9.3.0"
|
||||
"@push.rocks/smartrust": "^1.3.2",
|
||||
"@tsclass/tsclass": "^9.5.0"
|
||||
},
|
||||
"keywords": [
|
||||
"S3 Mock Server",
|
||||
"Local S3",
|
||||
"smartstorage",
|
||||
"S3 Compatible",
|
||||
"Local Storage Server",
|
||||
"Node.js",
|
||||
"TypeScript",
|
||||
"Local Development",
|
||||
@@ -54,20 +57,20 @@
|
||||
"File Storage",
|
||||
"AWS S3 Compatibility",
|
||||
"Development Tool",
|
||||
"S3 Endpoint",
|
||||
"S3 Simulation",
|
||||
"Storage Endpoint",
|
||||
"Storage Simulation",
|
||||
"Bucket Management",
|
||||
"File Upload",
|
||||
"CI/CD Integration",
|
||||
"Developer Onboarding"
|
||||
],
|
||||
"homepage": "https://code.foss.global/push.rocks/smarts3#readme",
|
||||
"homepage": "https://code.foss.global/push.rocks/smartstorage#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://code.foss.global/push.rocks/smarts3.git"
|
||||
"url": "ssh://git@code.foss.global:29419/push.rocks/smartstorage.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://code.foss.global/push.rocks/smarts3/issues"
|
||||
"url": "https://code.foss.global/push.rocks/smartstorage/issues"
|
||||
},
|
||||
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748",
|
||||
"pnpm": {
|
||||
|
||||
6242
pnpm-lock.yaml
generated
6242
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
438
production-readiness.md
Normal file
438
production-readiness.md
Normal file
@@ -0,0 +1,438 @@
|
||||
# Production-Readiness Plan for smartstorage
|
||||
|
||||
**Goal:** Make smartstorage production-ready as a MinIO alternative for use cases where:
|
||||
- Running MinIO is out of scope
|
||||
- You have a program written for S3 and want to use the local filesystem
|
||||
- You need a lightweight, zero-dependency S3-compatible server
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Current State Analysis
|
||||
|
||||
### ✅ What's Working
|
||||
|
||||
- **Native S3 server** with zero framework dependencies
|
||||
- **Core S3 operations:** PUT, GET, HEAD, DELETE (objects & buckets)
|
||||
- **List buckets and objects** (V1 and V2 API)
|
||||
- **Object copy** with metadata handling
|
||||
- **Range requests** for partial downloads
|
||||
- **MD5 checksums** and ETag support
|
||||
- **Custom metadata** (x-amz-meta-*)
|
||||
- **Filesystem-backed storage** with Windows compatibility
|
||||
- **S3-compatible XML error responses**
|
||||
- **Middleware system** and routing
|
||||
- **AWS SDK v3 compatibility** (tested)
|
||||
|
||||
### ❌ Production Gaps Identified
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Critical Features (Required for Production)
|
||||
|
||||
### 1. Multipart Upload Support 🚀 **HIGHEST PRIORITY**
|
||||
|
||||
**Why:** Essential for uploading files >5MB efficiently. Without this, smartstorage can't handle real-world production workloads.
|
||||
|
||||
**Implementation Required:**
|
||||
- `POST /:bucket/:key?uploads` - CreateMultipartUpload
|
||||
- `PUT /:bucket/:key?partNumber=X&uploadId=Y` - UploadPart
|
||||
- `POST /:bucket/:key?uploadId=X` - CompleteMultipartUpload
|
||||
- `DELETE /:bucket/:key?uploadId=X` - AbortMultipartUpload
|
||||
- `GET /:bucket/:key?uploadId=X` - ListParts
|
||||
- Multipart state management (temp storage for parts)
|
||||
- Part ETag tracking and validation
|
||||
- Automatic cleanup of abandoned uploads
|
||||
|
||||
**Files to Create/Modify:**
|
||||
- `ts/controllers/multipart.controller.ts` (new)
|
||||
- `ts/classes/filesystem-store.ts` (add multipart methods)
|
||||
- `ts/classes/smartstorage-server.ts` (add multipart routes)
|
||||
|
||||
---
|
||||
|
||||
### 2. Configurable Authentication 🔐
|
||||
|
||||
**Why:** Currently hardcoded credentials ('STORAGE'/'STORAGE'). Production needs custom credentials.
|
||||
|
||||
**Implementation Required:**
|
||||
- Support custom access keys and secrets via configuration
|
||||
- Implement AWS Signature V4 verification
|
||||
- Support multiple credential pairs (IAM-like users)
|
||||
- Optional: Disable authentication for local dev use
|
||||
|
||||
**Configuration Example:**
|
||||
```typescript
|
||||
interface IAuthConfig {
|
||||
enabled: boolean;
|
||||
credentials: Array<{
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
}>;
|
||||
signatureVersion: 'v4' | 'none';
|
||||
}
|
||||
```
|
||||
|
||||
**Files to Create/Modify:**
|
||||
- `ts/classes/auth-middleware.ts` (new)
|
||||
- `ts/classes/signature-validator.ts` (new)
|
||||
- `ts/classes/smartstorage-server.ts` (integrate auth middleware)
|
||||
- `ts/index.ts` (add auth config options)
|
||||
|
||||
---
|
||||
|
||||
### 3. CORS Support 🌐
|
||||
|
||||
**Why:** Required for browser-based uploads and modern web apps.
|
||||
|
||||
**Implementation Required:**
|
||||
- Add CORS middleware
|
||||
- Support preflight OPTIONS requests
|
||||
- Configurable CORS origins, methods, headers
|
||||
- Per-bucket CORS configuration (optional)
|
||||
|
||||
**Configuration Example:**
|
||||
```typescript
|
||||
interface ICorsConfig {
|
||||
enabled: boolean;
|
||||
allowedOrigins: string[]; // ['*'] or ['https://example.com']
|
||||
allowedMethods: string[]; // ['GET', 'POST', 'PUT', 'DELETE']
|
||||
allowedHeaders: string[]; // ['*'] or specific headers
|
||||
exposedHeaders: string[]; // ['ETag', 'x-amz-*']
|
||||
maxAge: number; // 3600 (seconds)
|
||||
allowCredentials: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**Files to Create/Modify:**
|
||||
- `ts/classes/cors-middleware.ts` (new)
|
||||
- `ts/classes/smartstorage-server.ts` (integrate CORS middleware)
|
||||
- `ts/index.ts` (add CORS config options)
|
||||
|
||||
---
|
||||
|
||||
### 4. SSL/TLS Support 🔒
|
||||
|
||||
**Why:** Production systems require encrypted connections.
|
||||
|
||||
**Implementation Required:**
|
||||
- HTTPS server option with cert/key configuration
|
||||
- Auto-redirect HTTP to HTTPS (optional)
|
||||
- Support for self-signed certs in dev mode
|
||||
|
||||
**Configuration Example:**
|
||||
```typescript
|
||||
interface ISslConfig {
|
||||
enabled: boolean;
|
||||
cert: string; // Path to certificate file or cert content
|
||||
key: string; // Path to key file or key content
|
||||
ca?: string; // Optional CA cert
|
||||
redirectHttp?: boolean; // Redirect HTTP to HTTPS
|
||||
}
|
||||
```
|
||||
|
||||
**Files to Create/Modify:**
|
||||
- `ts/classes/smartstorage-server.ts` (add HTTPS server creation)
|
||||
- `ts/index.ts` (add SSL config options)
|
||||
|
||||
---
|
||||
|
||||
### 5. Production Configuration System ⚙️
|
||||
|
||||
**Why:** Production needs flexible configuration, not just constructor options.
|
||||
|
||||
**Implementation Required:**
|
||||
- Support configuration file (JSON/YAML)
|
||||
- Environment variable support
|
||||
- Configuration validation
|
||||
- Sensible production defaults
|
||||
- Example configurations for common use cases
|
||||
|
||||
**Configuration File Example (`smartstorage.config.json`):**
|
||||
```json
|
||||
{
|
||||
"server": {
|
||||
"port": 3000,
|
||||
"address": "0.0.0.0",
|
||||
"ssl": {
|
||||
"enabled": true,
|
||||
"cert": "./certs/server.crt",
|
||||
"key": "./certs/server.key"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"directory": "./s3-data",
|
||||
"cleanSlate": false
|
||||
},
|
||||
"auth": {
|
||||
"enabled": true,
|
||||
"credentials": [
|
||||
{
|
||||
"accessKeyId": "AKIAIOSFODNN7EXAMPLE",
|
||||
"secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
}
|
||||
]
|
||||
},
|
||||
"cors": {
|
||||
"enabled": true,
|
||||
"allowedOrigins": ["*"],
|
||||
"allowedMethods": ["GET", "POST", "PUT", "DELETE", "HEAD"],
|
||||
"allowedHeaders": ["*"]
|
||||
},
|
||||
"limits": {
|
||||
"maxObjectSize": 5368709120,
|
||||
"maxMetadataSize": 2048,
|
||||
"requestTimeout": 300000
|
||||
},
|
||||
"logging": {
|
||||
"level": "info",
|
||||
"format": "json",
|
||||
"accessLog": {
|
||||
"enabled": true,
|
||||
"path": "./logs/access.log"
|
||||
},
|
||||
"errorLog": {
|
||||
"enabled": true,
|
||||
"path": "./logs/error.log"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Files to Create/Modify:**
|
||||
- `ts/classes/config-loader.ts` (new)
|
||||
- `ts/classes/config-validator.ts` (new)
|
||||
- `ts/index.ts` (use config loader)
|
||||
- Create example config files in root
|
||||
|
||||
---
|
||||
|
||||
### 6. Production Logging 📝
|
||||
|
||||
**Why:** Console logs aren't suitable for production monitoring.
|
||||
|
||||
**Implementation Required:**
|
||||
- Structured logging (JSON format option)
|
||||
- Log levels (ERROR, WARN, INFO, DEBUG)
|
||||
- File rotation support
|
||||
- Access logs (S3 standard format)
|
||||
- Integration with logging library
|
||||
|
||||
**Files to Create/Modify:**
|
||||
- `ts/classes/logger.ts` (new - use @push.rocks/smartlog?)
|
||||
- `ts/classes/access-logger-middleware.ts` (new)
|
||||
- `ts/classes/smartstorage-server.ts` (replace console.log with logger)
|
||||
- All controller files (use structured logging)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Important Features (Should Have)
|
||||
|
||||
### 7. Health Check & Metrics 💊
|
||||
|
||||
**Implementation Required:**
|
||||
- `GET /_health` endpoint (non-S3, for monitoring)
|
||||
- `GET /_metrics` endpoint (Prometheus format?)
|
||||
- Server stats (requests/sec, storage used, uptime)
|
||||
- Readiness/liveness probes for Kubernetes
|
||||
|
||||
**Files to Create/Modify:**
|
||||
- `ts/controllers/health.controller.ts` (new)
|
||||
- `ts/classes/metrics-collector.ts` (new)
|
||||
- `ts/classes/smartstorage-server.ts` (add health routes)
|
||||
|
||||
---
|
||||
|
||||
### 8. Batch Operations 📦
|
||||
|
||||
**Implementation Required:**
|
||||
- `POST /:bucket?delete` - DeleteObjects (delete multiple objects in one request)
|
||||
- Essential for efficient cleanup operations
|
||||
|
||||
**Files to Create/Modify:**
|
||||
- `ts/controllers/object.controller.ts` (add deleteObjects method)
|
||||
|
||||
---
|
||||
|
||||
### 9. Request Size Limits & Validation 🛡️
|
||||
|
||||
**Implementation Required:**
|
||||
- Max object size configuration
|
||||
- Max metadata size limits
|
||||
- Request timeout configuration
|
||||
- Body size limits
|
||||
- Bucket name validation (S3 rules)
|
||||
- Key name validation
|
||||
|
||||
**Files to Create/Modify:**
|
||||
- `ts/classes/validation-middleware.ts` (new)
|
||||
- `ts/utils/validators.ts` (new)
|
||||
- `ts/classes/smartstorage-server.ts` (integrate validation middleware)
|
||||
|
||||
---
|
||||
|
||||
### 10. Conditional Requests 🔄
|
||||
|
||||
**Implementation Required:**
|
||||
- If-Match / If-None-Match (ETag validation)
|
||||
- If-Modified-Since / If-Unmodified-Since
|
||||
- Required for caching and conflict prevention
|
||||
|
||||
**Files to Create/Modify:**
|
||||
- `ts/controllers/object.controller.ts` (add conditional logic to GET/HEAD)
|
||||
|
||||
---
|
||||
|
||||
### 11. Graceful Shutdown 👋
|
||||
|
||||
**Implementation Required:**
|
||||
- Drain existing connections
|
||||
- Reject new connections
|
||||
- Clean multipart cleanup on shutdown
|
||||
- SIGTERM/SIGINT handling
|
||||
|
||||
**Files to Create/Modify:**
|
||||
- `ts/classes/smartstorage-server.ts` (add graceful shutdown logic)
|
||||
- `ts/index.ts` (add signal handlers)
|
||||
|
||||
---
|
||||
|
||||
## 💡 Nice-to-Have Features
|
||||
|
||||
### 12. Advanced Features
|
||||
|
||||
- Bucket versioning support
|
||||
- Object tagging
|
||||
- Lifecycle policies (auto-delete old objects)
|
||||
- Storage class simulation (STANDARD, GLACIER, etc.)
|
||||
- Server-side encryption simulation
|
||||
- Presigned URL support (for time-limited access)
|
||||
|
||||
### 13. Performance Optimizations
|
||||
|
||||
- Stream optimization for large files
|
||||
- Optional in-memory caching for small objects
|
||||
- Parallel upload/download support
|
||||
- Compression support (gzip)
|
||||
|
||||
### 14. Developer Experience
|
||||
|
||||
- Docker image for easy deployment
|
||||
- Docker Compose examples
|
||||
- Kubernetes manifests
|
||||
- CLI for server management
|
||||
- Admin API for bucket management
|
||||
|
||||
---
|
||||
|
||||
## 📐 Implementation Phases
|
||||
|
||||
### Phase 1: Critical Production Features (Priority 1)
|
||||
|
||||
**Estimated Effort:** 2-3 weeks
|
||||
|
||||
1. ✅ Multipart uploads (biggest technical lift)
|
||||
2. ✅ Configurable authentication
|
||||
3. ✅ CORS middleware
|
||||
4. ✅ Production configuration system
|
||||
5. ✅ Production logging
|
||||
|
||||
**Outcome:** smartstorage can handle real production workloads
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Reliability & Operations (Priority 2)
|
||||
|
||||
**Estimated Effort:** 1-2 weeks
|
||||
|
||||
6. ✅ SSL/TLS support
|
||||
7. ✅ Health checks & metrics
|
||||
8. ✅ Request validation & limits
|
||||
9. ✅ Graceful shutdown
|
||||
10. ✅ Batch operations
|
||||
|
||||
**Outcome:** smartstorage is operationally mature
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: S3 Compatibility (Priority 3)
|
||||
|
||||
**Estimated Effort:** 1-2 weeks
|
||||
|
||||
11. ✅ Conditional requests
|
||||
12. ✅ Additional S3 features as needed
|
||||
13. ✅ Comprehensive test suite
|
||||
14. ✅ Documentation updates
|
||||
|
||||
**Outcome:** smartstorage has broad S3 API compatibility
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Polish (Priority 4)
|
||||
|
||||
**Estimated Effort:** As needed
|
||||
|
||||
15. ✅ Docker packaging
|
||||
16. ✅ Performance optimization
|
||||
17. ✅ Advanced features based on user feedback
|
||||
|
||||
**Outcome:** smartstorage is a complete MinIO alternative
|
||||
|
||||
---
|
||||
|
||||
## 🤔 Open Questions
|
||||
|
||||
1. **Authentication:** Do you want full AWS Signature V4 validation, or simpler static credential checking?
|
||||
2. **Configuration:** Prefer JSON, YAML, or .env file format?
|
||||
3. **Logging:** Do you have a preferred logging library, or shall I use @push.rocks/smartlog?
|
||||
4. **Scope:** Should we tackle all of Phase 1, or start with a subset (e.g., just multipart + auth)?
|
||||
5. **Testing:** Should we add comprehensive tests as we go, or batch them at the end?
|
||||
6. **Breaking changes:** Can I modify the constructor options interface, or must it remain backward compatible?
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Target Use Cases
|
||||
|
||||
**With this plan implemented, smartstorage will be a solid MinIO alternative for:**
|
||||
|
||||
✅ **Local S3 development** - Fast, simple, no Docker required
|
||||
✅ **Testing S3 integrations** - Reliable, repeatable tests
|
||||
✅ **Microservices using S3 API** with filesystem backend
|
||||
✅ **CI/CD pipelines** - Lightweight S3 for testing
|
||||
✅ **Small-to-medium production deployments** where MinIO is overkill
|
||||
✅ **Edge computing** - S3 API for local file storage
|
||||
✅ **Embedded systems** - Minimal dependencies, small footprint
|
||||
|
||||
---
|
||||
|
||||
## 📊 Current vs. Production Comparison
|
||||
|
||||
| Feature | Current | After Phase 1 | After Phase 2 | Production Ready |
|
||||
|---------|---------|---------------|---------------|------------------|
|
||||
| Basic S3 ops | ✅ | ✅ | ✅ | ✅ |
|
||||
| Multipart upload | ❌ | ✅ | ✅ | ✅ |
|
||||
| Authentication | ⚠️ (hardcoded) | ✅ | ✅ | ✅ |
|
||||
| CORS | ❌ | ✅ | ✅ | ✅ |
|
||||
| SSL/TLS | ❌ | ❌ | ✅ | ✅ |
|
||||
| Config files | ❌ | ✅ | ✅ | ✅ |
|
||||
| Production logging | ⚠️ (console) | ✅ | ✅ | ✅ |
|
||||
| Health checks | ❌ | ❌ | ✅ | ✅ |
|
||||
| Request limits | ❌ | ❌ | ✅ | ✅ |
|
||||
| Graceful shutdown | ❌ | ❌ | ✅ | ✅ |
|
||||
| Conditional requests | ❌ | ❌ | ❌ | ✅ |
|
||||
| Batch operations | ❌ | ❌ | ✅ | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- All features should maintain backward compatibility where possible
|
||||
- Each feature should include comprehensive tests
|
||||
- Documentation (readme.md) should be updated as features are added
|
||||
- Consider adding a migration guide for users upgrading from testing to production use
|
||||
- Performance benchmarks should be established and maintained
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-11-23
|
||||
**Status:** Planning Phase
|
||||
**Next Step:** Get approval and prioritize implementation order
|
||||
@@ -1 +1,69 @@
|
||||
|
||||
# Project Hints for smartstorage
|
||||
|
||||
## Current State (v6.0.0)
|
||||
|
||||
- **Rust-powered S3-compatible storage server** via `@push.rocks/smartrust` IPC bridge
|
||||
- High-performance: streaming I/O, zero-copy, backpressure, range seek
|
||||
- TypeScript is thin IPC wrapper; all HTTP/storage/routing in Rust binary `ruststorage`
|
||||
- Full S3 compatibility: PUT, GET, HEAD, DELETE for objects and buckets
|
||||
- Multipart upload support (streaming, no OOM)
|
||||
- **Real AWS SigV4 authentication** (cryptographic signature verification)
|
||||
- **Bucket policies** (AWS/MinIO-compatible JSON policies, public access support)
|
||||
- CORS support
|
||||
- ListBuckets, ListObjects (v1/v2), CopyObject
|
||||
|
||||
## Architecture
|
||||
|
||||
### Rust Binary (`rust/src/`)
|
||||
- `main.rs` - Clap CLI, management mode entry
|
||||
- `config.rs` - Serde config structs matching TS interfaces (includes `region`)
|
||||
- `management.rs` - IPC loop (newline-delimited JSON over stdin/stdout)
|
||||
- `server.rs` - hyper 1.x HTTP server, routing, CORS, auth+policy pipeline, all S3-compatible handlers
|
||||
- `storage.rs` - FileStore: filesystem-backed storage, multipart manager, `.policies/` dir
|
||||
- `xml_response.rs` - S3-compatible XML response builders
|
||||
- `error.rs` - StorageError codes with HTTP status mapping
|
||||
- `auth.rs` - AWS SigV4 signature verification (HMAC-SHA256, clock skew, constant-time compare)
|
||||
- `action.rs` - StorageAction enum + request-to-IAM-action resolver + RequestContext
|
||||
- `policy.rs` - BucketPolicy model, evaluation engine (Deny > Allow > NoOpinion), PolicyStore (RwLock cache + disk)
|
||||
|
||||
### TypeScript Bridge (`ts/`)
|
||||
- `ts/index.ts` - SmartStorage class with RustBridge<TRustStorageCommands>
|
||||
- `ts/plugins.ts` - path, smartpath, RustBridge, tsclass
|
||||
- `ts/paths.ts` - packageDir, bucketsDir defaults
|
||||
|
||||
### IPC Commands
|
||||
| Command | Params | Action |
|
||||
|---------|--------|--------|
|
||||
| `start` | `{ config: ISmartStorageConfig }` | Init storage + HTTP server |
|
||||
| `stop` | `{}` | Graceful shutdown |
|
||||
| `createBucket` | `{ name: string }` | Create bucket directory |
|
||||
|
||||
### Storage Layout
|
||||
- Objects: `{root}/{bucket}/{key}._storage_object`
|
||||
- Metadata: `{root}/{bucket}/{key}._storage_object.metadata.json`
|
||||
- MD5: `{root}/{bucket}/{key}._storage_object.md5`
|
||||
- Multipart: `{root}/.multipart/{upload_id}/part-{N}`
|
||||
- Policies: `{root}/.policies/{bucket}.policy.json`
|
||||
|
||||
## Build
|
||||
|
||||
- `pnpm build` runs `tsrust && tsbuild --web --allowimplicitany`
|
||||
- `tsrust` compiles Rust to `dist_rust/ruststorage`
|
||||
- Targets: linux_amd64, linux_arm64 (configured in npmextra.json)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `@push.rocks/smartrust` - RustBridge IPC bridge
|
||||
- `@push.rocks/smartpath` - Path utilities
|
||||
- `@tsclass/tsclass` - IS3Descriptor type
|
||||
- `@git.zone/tsrust` (devDep) - Rust cross-compilation
|
||||
|
||||
## Testing
|
||||
|
||||
- `test/test.aws-sdk.node.ts` - AWS SDK v3 compatibility (10 tests, auth disabled, port 3337)
|
||||
- `test/test.auth.node.ts` - Auth + bucket policy integration (20 tests, auth enabled, port 3344)
|
||||
- `test/test.policy-crud.node.ts` - Policy API CRUD + validation edge cases (17 tests, port 3345)
|
||||
- `test/test.policy-eval.node.ts` - Policy evaluation: principals, actions, resources, deny-vs-allow (22 tests, port 3346)
|
||||
- `test/test.policy-actions.node.ts` - Per-action policy enforcement (15 tests, port 3347)
|
||||
- `test/test.ts` - SmartBucket integration (3 tests)
|
||||
- Run: `pnpm test` or `tstest test/test.aws-sdk.node.ts --verbose`
|
||||
|
||||
725
readme.md
725
readme.md
@@ -1,432 +1,451 @@
|
||||
# @push.rocks/smarts3 🚀
|
||||
# @push.rocks/smartstorage
|
||||
|
||||
**Mock S3 made simple** - A powerful Node.js TypeScript package for creating a local S3 endpoint that simulates AWS S3 operations using mapped local directories. Perfect for development and testing!
|
||||
A high-performance, S3-compatible local storage server powered by a **Rust core** with a clean TypeScript API. Drop-in replacement for AWS S3 during development and testing — no cloud, no Docker, no MinIO. Just `npm install` and go.
|
||||
|
||||
## 🌟 Features
|
||||
## Issue Reporting and Security
|
||||
|
||||
- 🏃 **Lightning-fast local S3 simulation** - No more waiting for cloud operations during development
|
||||
- ⚡ **Native custom S3 server** - Built on Node.js http module with zero framework dependencies
|
||||
- 🔄 **Full AWS S3 API compatibility** - Drop-in replacement for AWS SDK v3 and other S3 clients
|
||||
- 📂 **Local directory mapping** - Your buckets live right on your filesystem with Windows-compatible encoding
|
||||
- 🧪 **Perfect for testing** - Reliable, repeatable tests without cloud dependencies
|
||||
- 🎯 **TypeScript-first** - Built with TypeScript for excellent type safety and IDE support
|
||||
- 🔧 **Zero configuration** - Works out of the box with sensible defaults
|
||||
- 🧹 **Clean slate mode** - Start fresh on every test run
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
## 📦 Installation
|
||||
## Why smartstorage?
|
||||
|
||||
Install using your favorite package manager:
|
||||
| Feature | smartstorage | MinIO | s3rver |
|
||||
|---------|-------------|-------|--------|
|
||||
| Install | `pnpm add` | Docker / binary | `npm install` |
|
||||
| Startup time | ~20ms | seconds | ~200ms |
|
||||
| Large file uploads | Streaming, zero-copy | Yes | OOM risk |
|
||||
| Range requests | Seek-based | Yes | Full read |
|
||||
| Language | Rust + TypeScript | Go | JavaScript |
|
||||
| Multipart uploads | Full support | Yes | No |
|
||||
| Auth | AWS SigV4 (full verification) | Full IAM | Basic |
|
||||
| Bucket policies | IAM-style evaluation | Yes | No |
|
||||
|
||||
### Core Features
|
||||
|
||||
- **Rust-powered HTTP server** — hyper 1.x with streaming I/O, zero-copy, backpressure
|
||||
- **Full S3-compatible API** — works with AWS SDK v3, SmartBucket, any S3 client
|
||||
- **Filesystem-backed storage** — buckets map to directories, objects to files
|
||||
- **Streaming multipart uploads** — large files without memory pressure
|
||||
- **Byte-range requests** — `seek()` directly to the requested byte offset
|
||||
- **AWS SigV4 authentication** — full signature verification with constant-time comparison and 15-min clock skew enforcement
|
||||
- **Bucket policies** — IAM-style JSON policies with Allow/Deny evaluation, wildcard matching, and anonymous access support
|
||||
- **CORS middleware** — configurable cross-origin support
|
||||
- **Structured logging** — tracing-based, error through debug levels
|
||||
- **Clean slate mode** — wipe storage on startup for test isolation
|
||||
- **Test-first design** — start/stop in milliseconds, no port conflicts
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Using npm
|
||||
npm install @push.rocks/smarts3 --save-dev
|
||||
|
||||
# Using pnpm (recommended)
|
||||
pnpm add @push.rocks/smarts3 -D
|
||||
|
||||
# Using yarn
|
||||
yarn add @push.rocks/smarts3 --dev
|
||||
pnpm add @push.rocks/smartstorage -D
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
> **Note:** The package ships with precompiled Rust binaries for `linux_amd64` and `linux_arm64`. No Rust toolchain needed on your machine.
|
||||
|
||||
Get up and running in seconds:
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { Smarts3 } from '@push.rocks/smarts3';
|
||||
import { SmartStorage } from '@push.rocks/smartstorage';
|
||||
|
||||
// Start your local S3 server
|
||||
const s3Server = await Smarts3.createAndStart({
|
||||
port: 3000,
|
||||
cleanSlate: true, // Start with empty buckets
|
||||
// Start a local S3-compatible storage server
|
||||
const storage = await SmartStorage.createAndStart({
|
||||
server: { port: 3000 },
|
||||
storage: { cleanSlate: true },
|
||||
});
|
||||
|
||||
// Create a bucket
|
||||
const bucket = await s3Server.createBucket('my-awesome-bucket');
|
||||
await storage.createBucket('my-bucket');
|
||||
|
||||
// Get S3 connection details for use with AWS SDK or other S3 clients
|
||||
const s3Config = await s3Server.getS3Descriptor();
|
||||
// Get connection details for any S3 client
|
||||
const descriptor = await storage.getStorageDescriptor();
|
||||
// → { endpoint: 'localhost', port: 3000, accessKey: 'STORAGE', accessSecret: 'STORAGE', useSsl: false }
|
||||
|
||||
// When you're done
|
||||
await s3Server.stop();
|
||||
// When done
|
||||
await storage.stop();
|
||||
```
|
||||
|
||||
## 📖 Detailed Usage Guide
|
||||
## Configuration
|
||||
|
||||
### 🏗️ Setting Up Your S3 Server
|
||||
|
||||
The `Smarts3` class provides a simple interface for managing your local S3 server:
|
||||
All config fields are optional — sensible defaults are applied automatically.
|
||||
|
||||
```typescript
|
||||
import { Smarts3 } from '@push.rocks/smarts3';
|
||||
import { SmartStorage, ISmartStorageConfig } from '@push.rocks/smartstorage';
|
||||
|
||||
// Configuration options
|
||||
const config = {
|
||||
port: 3000, // Port to run the server on (default: 3000)
|
||||
cleanSlate: true, // Clear all data on start (default: false)
|
||||
const config: ISmartStorageConfig = {
|
||||
server: {
|
||||
port: 3000, // Default: 3000
|
||||
address: '0.0.0.0', // Default: '0.0.0.0'
|
||||
silent: false, // Default: false
|
||||
region: 'us-east-1', // Default: 'us-east-1' — used for SigV4 signing
|
||||
},
|
||||
storage: {
|
||||
directory: './my-data', // Default: .nogit/bucketsDir
|
||||
cleanSlate: false, // Default: false — set true to wipe on start
|
||||
},
|
||||
auth: {
|
||||
enabled: false, // Default: false
|
||||
credentials: [{
|
||||
accessKeyId: 'MY_KEY',
|
||||
secretAccessKey: 'MY_SECRET',
|
||||
}],
|
||||
},
|
||||
cors: {
|
||||
enabled: false, // Default: false
|
||||
allowedOrigins: ['*'],
|
||||
allowedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS'],
|
||||
allowedHeaders: ['*'],
|
||||
exposedHeaders: ['ETag', 'x-amz-request-id', 'x-amz-version-id'],
|
||||
maxAge: 86400,
|
||||
allowCredentials: false,
|
||||
},
|
||||
logging: {
|
||||
level: 'info', // 'error' | 'warn' | 'info' | 'debug'
|
||||
format: 'text', // 'text' | 'json'
|
||||
enabled: true,
|
||||
},
|
||||
limits: {
|
||||
maxObjectSize: 5 * 1024 * 1024 * 1024, // 5 GB
|
||||
maxMetadataSize: 2048,
|
||||
requestTimeout: 300000, // 5 minutes
|
||||
},
|
||||
multipart: {
|
||||
expirationDays: 7,
|
||||
cleanupIntervalMinutes: 60,
|
||||
},
|
||||
};
|
||||
|
||||
// Create and start in one go
|
||||
const s3Server = await Smarts3.createAndStart(config);
|
||||
|
||||
// Or create and start separately
|
||||
const s3Server = new Smarts3(config);
|
||||
await s3Server.start();
|
||||
const storage = await SmartStorage.createAndStart(config);
|
||||
```
|
||||
|
||||
### 🪣 Working with Buckets
|
||||
### Common Configurations
|
||||
|
||||
Creating and managing buckets is straightforward:
|
||||
**CI/CD testing** — silent, clean, fast:
|
||||
```typescript
|
||||
const storage = await SmartStorage.createAndStart({
|
||||
server: { port: 9999, silent: true },
|
||||
storage: { cleanSlate: true },
|
||||
});
|
||||
```
|
||||
|
||||
**Auth enabled:**
|
||||
```typescript
|
||||
const storage = await SmartStorage.createAndStart({
|
||||
auth: {
|
||||
enabled: true,
|
||||
credentials: [{ accessKeyId: 'test', secretAccessKey: 'test123' }],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**CORS for local web dev:**
|
||||
```typescript
|
||||
const storage = await SmartStorage.createAndStart({
|
||||
cors: {
|
||||
enabled: true,
|
||||
allowedOrigins: ['http://localhost:5173'],
|
||||
allowCredentials: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Usage with AWS SDK v3
|
||||
|
||||
```typescript
|
||||
// Create a new bucket
|
||||
const bucket = await s3Server.createBucket('my-bucket');
|
||||
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||
|
||||
// The bucket is now ready to use!
|
||||
console.log(`Created bucket: ${bucket.name}`);
|
||||
const descriptor = await storage.getStorageDescriptor();
|
||||
|
||||
const client = new S3Client({
|
||||
endpoint: `http://${descriptor.endpoint}:${descriptor.port}`,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: descriptor.accessKey,
|
||||
secretAccessKey: descriptor.accessSecret,
|
||||
},
|
||||
forcePathStyle: true, // Required for path-style access
|
||||
});
|
||||
|
||||
// Upload
|
||||
await client.send(new PutObjectCommand({
|
||||
Bucket: 'my-bucket',
|
||||
Key: 'hello.txt',
|
||||
Body: 'Hello, Storage!',
|
||||
ContentType: 'text/plain',
|
||||
}));
|
||||
|
||||
// Download
|
||||
const { Body } = await client.send(new GetObjectCommand({
|
||||
Bucket: 'my-bucket',
|
||||
Key: 'hello.txt',
|
||||
}));
|
||||
const content = await Body.transformToString(); // "Hello, Storage!"
|
||||
|
||||
// Delete
|
||||
await client.send(new DeleteObjectCommand({
|
||||
Bucket: 'my-bucket',
|
||||
Key: 'hello.txt',
|
||||
}));
|
||||
```
|
||||
|
||||
### 📤 Uploading Files
|
||||
|
||||
Use the powerful `SmartBucket` integration for file operations:
|
||||
## Usage with SmartBucket
|
||||
|
||||
```typescript
|
||||
import { SmartBucket } from '@push.rocks/smartbucket';
|
||||
|
||||
// Get connection configuration
|
||||
const s3Config = await s3Server.getS3Descriptor();
|
||||
const smartbucket = new SmartBucket(await storage.getStorageDescriptor());
|
||||
const bucket = await smartbucket.createBucket('my-bucket');
|
||||
const dir = await bucket.getBaseDirectory();
|
||||
|
||||
// Create a SmartBucket instance
|
||||
const smartbucket = new SmartBucket(s3Config);
|
||||
// Upload
|
||||
await dir.fastPut({ path: 'docs/readme.txt', contents: 'Hello!' });
|
||||
|
||||
// Get your bucket
|
||||
const bucket = await smartbucket.getBucket('my-bucket');
|
||||
// Download
|
||||
const content = await dir.fastGet('docs/readme.txt');
|
||||
|
||||
// Upload a file
|
||||
const baseDir = await bucket.getBaseDirectory();
|
||||
await baseDir.fastStore('path/to/file.txt', 'Hello, S3! 🎉');
|
||||
|
||||
// Upload with more control
|
||||
await baseDir.fastPut({
|
||||
path: 'documents/important.pdf',
|
||||
contents: Buffer.from(yourPdfData),
|
||||
});
|
||||
// List
|
||||
const files = await dir.listFiles();
|
||||
```
|
||||
|
||||
### 📥 Downloading Files
|
||||
## Multipart Uploads
|
||||
|
||||
Retrieve your files easily:
|
||||
For files larger than 5 MB, use multipart uploads. smartstorage handles them with **streaming I/O** — parts are written directly to disk, never buffered in memory.
|
||||
|
||||
```typescript
|
||||
// Get file contents as string
|
||||
const content = await baseDir.fastGet('path/to/file.txt');
|
||||
console.log(content); // "Hello, S3! 🎉"
|
||||
import {
|
||||
CreateMultipartUploadCommand,
|
||||
UploadPartCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
|
||||
// Get file as Buffer
|
||||
const buffer = await baseDir.fastGetBuffer('documents/important.pdf');
|
||||
```
|
||||
|
||||
### 📋 Listing Files
|
||||
|
||||
Browse your bucket contents:
|
||||
|
||||
```typescript
|
||||
// List all files in the bucket
|
||||
const files = await baseDir.listFiles();
|
||||
|
||||
files.forEach((file) => {
|
||||
console.log(`📄 ${file.name} (${file.size} bytes)`);
|
||||
});
|
||||
|
||||
// List files with a specific prefix
|
||||
const docs = await baseDir.listFiles('documents/');
|
||||
```
|
||||
|
||||
### 🗑️ Deleting Files
|
||||
|
||||
Clean up when needed:
|
||||
|
||||
```typescript
|
||||
// Delete a single file
|
||||
await baseDir.fastDelete('old-file.txt');
|
||||
|
||||
// Delete multiple files
|
||||
const filesToDelete = ['temp1.txt', 'temp2.txt', 'temp3.txt'];
|
||||
for (const file of filesToDelete) {
|
||||
await baseDir.fastDelete(file);
|
||||
}
|
||||
```
|
||||
|
||||
## 🧪 Testing Integration
|
||||
|
||||
### Using with Jest
|
||||
|
||||
```typescript
|
||||
import { Smarts3 } from '@push.rocks/smarts3';
|
||||
|
||||
describe('S3 Operations', () => {
|
||||
let s3Server: Smarts3;
|
||||
|
||||
beforeAll(async () => {
|
||||
s3Server = await Smarts3.createAndStart({
|
||||
port: 9999,
|
||||
cleanSlate: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await s3Server.stop();
|
||||
});
|
||||
|
||||
test('should upload and retrieve a file', async () => {
|
||||
const bucket = await s3Server.createBucket('test-bucket');
|
||||
// Your test logic here
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Using with Mocha
|
||||
|
||||
```typescript
|
||||
import { Smarts3 } from '@push.rocks/smarts3';
|
||||
import { expect } from 'chai';
|
||||
|
||||
describe('S3 Operations', () => {
|
||||
let s3Server: Smarts3;
|
||||
|
||||
before(async () => {
|
||||
s3Server = await Smarts3.createAndStart({
|
||||
port: 9999,
|
||||
cleanSlate: true,
|
||||
});
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await s3Server.stop();
|
||||
});
|
||||
|
||||
it('should upload and retrieve a file', async () => {
|
||||
const bucket = await s3Server.createBucket('test-bucket');
|
||||
// Your test logic here
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## 🔌 AWS SDK Integration
|
||||
|
||||
Use `smarts3` with the official AWS SDK:
|
||||
|
||||
```typescript
|
||||
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { Smarts3 } from '@push.rocks/smarts3';
|
||||
|
||||
// Start local S3
|
||||
const s3Server = await Smarts3.createAndStart({ port: 3000 });
|
||||
const config = await s3Server.getS3Descriptor();
|
||||
|
||||
// Configure AWS SDK
|
||||
const s3Client = new S3Client({
|
||||
endpoint: `http://${config.endpoint}:${config.port}`,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: config.accessKey,
|
||||
secretAccessKey: config.accessSecret,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
// Use AWS SDK as normal
|
||||
const command = new PutObjectCommand({
|
||||
// 1. Initiate
|
||||
const { UploadId } = await client.send(new CreateMultipartUploadCommand({
|
||||
Bucket: 'my-bucket',
|
||||
Key: 'test-file.txt',
|
||||
Body: 'Hello from AWS SDK!',
|
||||
Key: 'large-file.bin',
|
||||
}));
|
||||
|
||||
// 2. Upload parts
|
||||
const parts = [];
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const { ETag } = await client.send(new UploadPartCommand({
|
||||
Bucket: 'my-bucket',
|
||||
Key: 'large-file.bin',
|
||||
UploadId,
|
||||
PartNumber: i + 1,
|
||||
Body: chunks[i],
|
||||
}));
|
||||
parts.push({ PartNumber: i + 1, ETag });
|
||||
}
|
||||
|
||||
// 3. Complete
|
||||
await client.send(new CompleteMultipartUploadCommand({
|
||||
Bucket: 'my-bucket',
|
||||
Key: 'large-file.bin',
|
||||
UploadId,
|
||||
MultipartUpload: { Parts: parts },
|
||||
}));
|
||||
```
|
||||
|
||||
## Bucket Policies
|
||||
|
||||
smartstorage supports AWS-style bucket policies for fine-grained access control. Policies use the same IAM JSON format as real S3 — so you can develop and test your policy logic locally before deploying.
|
||||
|
||||
When `auth.enabled` is `true`, the auth pipeline works as follows:
|
||||
1. **Authenticate** — verify the AWS SigV4 signature (anonymous requests skip this step)
|
||||
2. **Authorize** — evaluate bucket policies against the request action, resource, and caller identity
|
||||
3. **Default** — authenticated users get full access; anonymous requests are denied unless a policy explicitly allows them
|
||||
|
||||
### Setting a Bucket Policy
|
||||
|
||||
Use the S3 `PutBucketPolicy` API (or any S3 client that supports it):
|
||||
|
||||
```typescript
|
||||
import { PutBucketPolicyCommand } from '@aws-sdk/client-s3';
|
||||
|
||||
// Allow anonymous read access to all objects in a bucket
|
||||
await client.send(new PutBucketPolicyCommand({
|
||||
Bucket: 'public-assets',
|
||||
Policy: JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [{
|
||||
Sid: 'PublicRead',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: ['s3:GetObject'],
|
||||
Resource: ['arn:aws:s3:::public-assets/*'],
|
||||
}],
|
||||
}),
|
||||
}));
|
||||
```
|
||||
|
||||
### Policy Features
|
||||
|
||||
- **Effect**: `Allow` and `Deny` (explicit Deny always wins)
|
||||
- **Principal**: `"*"` (everyone) or `{ "AWS": ["arn:..."] }` for specific identities
|
||||
- **Action**: IAM-style actions like `s3:GetObject`, `s3:PutObject`, `s3:*`, or prefix wildcards like `s3:Get*`
|
||||
- **Resource**: ARN patterns with `*` and `?` wildcards (e.g. `arn:aws:s3:::my-bucket/*`)
|
||||
- **Persistence**: Policies survive server restarts — stored as JSON on disk alongside your data
|
||||
|
||||
### Policy CRUD Operations
|
||||
|
||||
| Operation | AWS SDK Command | HTTP |
|
||||
|-----------|----------------|------|
|
||||
| Get policy | `GetBucketPolicyCommand` | `GET /{bucket}?policy` |
|
||||
| Set policy | `PutBucketPolicyCommand` | `PUT /{bucket}?policy` |
|
||||
| Delete policy | `DeleteBucketPolicyCommand` | `DELETE /{bucket}?policy` |
|
||||
|
||||
Deleting a bucket automatically removes its associated policy.
|
||||
|
||||
## Testing Integration
|
||||
|
||||
```typescript
|
||||
import { SmartStorage } from '@push.rocks/smartstorage';
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
let storage: SmartStorage;
|
||||
|
||||
tap.test('setup', async () => {
|
||||
storage = await SmartStorage.createAndStart({
|
||||
server: { port: 4567, silent: true },
|
||||
storage: { cleanSlate: true },
|
||||
});
|
||||
});
|
||||
|
||||
await s3Client.send(command);
|
||||
```
|
||||
|
||||
## 🎯 Real-World Examples
|
||||
|
||||
### CI/CD Pipeline Testing
|
||||
|
||||
```typescript
|
||||
// ci-test.ts
|
||||
import { Smarts3 } from '@push.rocks/smarts3';
|
||||
|
||||
export async function setupTestEnvironment() {
|
||||
// Start S3 server for CI tests
|
||||
const s3 = await Smarts3.createAndStart({
|
||||
port: process.env.S3_PORT || 3000,
|
||||
cleanSlate: true,
|
||||
});
|
||||
|
||||
// Create test buckets
|
||||
await s3.createBucket('uploads');
|
||||
await s3.createBucket('processed');
|
||||
await s3.createBucket('archive');
|
||||
|
||||
return s3;
|
||||
}
|
||||
```
|
||||
|
||||
### Microservice Development
|
||||
|
||||
```typescript
|
||||
// dev-server.ts
|
||||
import { Smarts3 } from '@push.rocks/smarts3';
|
||||
import express from 'express';
|
||||
|
||||
async function startDevelopmentServer() {
|
||||
// Start local S3
|
||||
const s3 = await Smarts3.createAndStart({ port: 3000 });
|
||||
await s3.createBucket('user-uploads');
|
||||
|
||||
// Start your API server
|
||||
const app = express();
|
||||
|
||||
app.post('/upload', async (req, res) => {
|
||||
// Your upload logic using local S3
|
||||
});
|
||||
|
||||
app.listen(8080, () => {
|
||||
console.log('🚀 Dev server running with local S3!');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Data Migration Testing
|
||||
|
||||
```typescript
|
||||
import { Smarts3 } from '@push.rocks/smarts3';
|
||||
|
||||
async function testDataMigration() {
|
||||
const s3 = await Smarts3.createAndStart({ cleanSlate: true });
|
||||
|
||||
// Create source and destination buckets
|
||||
const sourceBucket = await s3.createBucket('legacy-data');
|
||||
const destBucket = await s3.createBucket('new-data');
|
||||
|
||||
// Populate source with test data
|
||||
const config = await s3.getS3Descriptor();
|
||||
const smartbucket = new SmartBucket(config);
|
||||
const source = await smartbucket.getBucket('legacy-data');
|
||||
const sourceDir = await source.getBaseDirectory();
|
||||
|
||||
// Add test files
|
||||
await sourceDir.fastStore(
|
||||
'user-1.json',
|
||||
JSON.stringify({ id: 1, name: 'Alice' }),
|
||||
);
|
||||
await sourceDir.fastStore(
|
||||
'user-2.json',
|
||||
JSON.stringify({ id: 2, name: 'Bob' }),
|
||||
);
|
||||
|
||||
// Run your migration logic
|
||||
await runMigration(config);
|
||||
|
||||
// Verify migration results
|
||||
const dest = await smartbucket.getBucket('new-data');
|
||||
const destDir = await dest.getBaseDirectory();
|
||||
const migratedFiles = await destDir.listFiles();
|
||||
|
||||
console.log(`✅ Migrated ${migratedFiles.length} files successfully!`);
|
||||
}
|
||||
```
|
||||
|
||||
## 🛠️ Advanced Configuration
|
||||
|
||||
### Custom S3 Descriptor Options
|
||||
|
||||
When integrating with different S3 clients, you can customize the connection details:
|
||||
|
||||
```typescript
|
||||
const customDescriptor = await s3Server.getS3Descriptor({
|
||||
endpoint: 'localhost', // Custom endpoint
|
||||
port: 3001, // Different port
|
||||
useSsl: false, // SSL configuration
|
||||
// Add any additional options your S3 client needs
|
||||
tap.test('should store and retrieve objects', async () => {
|
||||
await storage.createBucket('test');
|
||||
// ... your test logic using AWS SDK or SmartBucket
|
||||
});
|
||||
|
||||
tap.test('teardown', async () => {
|
||||
await storage.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
```
|
||||
|
||||
### Environment-Based Configuration
|
||||
## API Reference
|
||||
|
||||
```typescript
|
||||
const config = {
|
||||
port: parseInt(process.env.S3_PORT || '3000'),
|
||||
cleanSlate: process.env.NODE_ENV === 'test',
|
||||
};
|
||||
### `SmartStorage` Class
|
||||
|
||||
const s3Server = await Smarts3.createAndStart(config);
|
||||
#### `static createAndStart(config?: ISmartStorageConfig): Promise<SmartStorage>`
|
||||
|
||||
Create and start a server in one call.
|
||||
|
||||
#### `start(): Promise<void>`
|
||||
|
||||
Spawn the Rust binary and start the HTTP server.
|
||||
|
||||
#### `stop(): Promise<void>`
|
||||
|
||||
Gracefully stop the server and kill the Rust process.
|
||||
|
||||
#### `createBucket(name: string): Promise<{ name: string }>`
|
||||
|
||||
Create a storage bucket.
|
||||
|
||||
#### `getStorageDescriptor(options?): Promise<IS3Descriptor>`
|
||||
|
||||
Get connection details for S3-compatible clients. Returns:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `endpoint` | `string` | Server hostname (`localhost` by default) |
|
||||
| `port` | `number` | Server port |
|
||||
| `accessKey` | `string` | Access key from first configured credential |
|
||||
| `accessSecret` | `string` | Secret key from first configured credential |
|
||||
| `useSsl` | `boolean` | Always `false` (plain HTTP) |
|
||||
|
||||
## Architecture
|
||||
|
||||
smartstorage uses a **hybrid Rust + TypeScript** architecture:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ Your Code (AWS SDK, etc.) │
|
||||
│ ↕ HTTP (localhost:3000) │
|
||||
├─────────────────────────────────┤
|
||||
│ ruststorage binary (Rust) │
|
||||
│ ├─ hyper 1.x HTTP server │
|
||||
│ ├─ S3 path-style routing │
|
||||
│ ├─ Streaming storage layer │
|
||||
│ ├─ Multipart manager │
|
||||
│ ├─ SigV4 auth + policy engine │
|
||||
│ ├─ CORS middleware │
|
||||
│ └─ S3 XML response builder │
|
||||
├─────────────────────────────────┤
|
||||
│ TypeScript (thin IPC wrapper) │
|
||||
│ ├─ SmartStorage class │
|
||||
│ ├─ RustBridge (stdin/stdout) │
|
||||
│ └─ Config & S3 descriptor │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🤝 Use Cases
|
||||
**Why Rust?** The TypeScript implementation had critical perf issues: OOM on multipart uploads (parts buffered in memory), double stream copying, file descriptor leaks on HEAD requests, full-file reads for range requests, and no backpressure. The Rust binary solves all of these with streaming I/O, zero-copy, and direct `seek()` for range requests.
|
||||
|
||||
- **🧪 Unit & Integration Testing** - Test S3 operations without AWS credentials or internet
|
||||
- **🏗️ Local Development** - Develop cloud features offline with full S3 compatibility
|
||||
- **📚 Teaching & Demos** - Perfect for workshops and tutorials without AWS setup
|
||||
- **🔄 CI/CD Pipelines** - Reliable S3 operations in containerized test environments
|
||||
- **🎭 Mocking & Stubbing** - Replace real S3 calls in test suites
|
||||
- **📊 Data Migration Testing** - Safely test data migrations locally before production
|
||||
**IPC Protocol:** TypeScript spawns the `ruststorage` binary with `--management` and communicates via newline-delimited JSON over stdin/stdout. Commands: `start`, `stop`, `createBucket`.
|
||||
|
||||
## 🔧 API Reference
|
||||
### S3-Compatible Operations Supported
|
||||
|
||||
### Smarts3 Class
|
||||
| Operation | Method | Path |
|
||||
|-----------|--------|------|
|
||||
| ListBuckets | `GET /` | |
|
||||
| CreateBucket | `PUT /{bucket}` | |
|
||||
| DeleteBucket | `DELETE /{bucket}` | |
|
||||
| HeadBucket | `HEAD /{bucket}` | |
|
||||
| ListObjects (v1/v2) | `GET /{bucket}` | `?list-type=2` for v2 |
|
||||
| PutObject | `PUT /{bucket}/{key}` | |
|
||||
| GetObject | `GET /{bucket}/{key}` | Supports `Range` header |
|
||||
| HeadObject | `HEAD /{bucket}/{key}` | |
|
||||
| DeleteObject | `DELETE /{bucket}/{key}` | |
|
||||
| CopyObject | `PUT /{bucket}/{key}` | `x-amz-copy-source` header |
|
||||
| InitiateMultipartUpload | `POST /{bucket}/{key}?uploads` | |
|
||||
| UploadPart | `PUT /{bucket}/{key}?partNumber&uploadId` | |
|
||||
| CompleteMultipartUpload | `POST /{bucket}/{key}?uploadId` | |
|
||||
| AbortMultipartUpload | `DELETE /{bucket}/{key}?uploadId` | |
|
||||
| ListMultipartUploads | `GET /{bucket}?uploads` | |
|
||||
| GetBucketPolicy | `GET /{bucket}?policy` | |
|
||||
| PutBucketPolicy | `PUT /{bucket}?policy` | |
|
||||
| DeleteBucketPolicy | `DELETE /{bucket}?policy` | |
|
||||
|
||||
#### Constructor Options
|
||||
### On-Disk Format
|
||||
|
||||
```typescript
|
||||
interface ISmarts3ContructorOptions {
|
||||
port?: number; // Server port (default: 3000)
|
||||
cleanSlate?: boolean; // Clear storage on start (default: false)
|
||||
}
|
||||
```
|
||||
{storage.directory}/
|
||||
{bucket}/
|
||||
{key}._storage_object # Object data
|
||||
{key}._storage_object.metadata.json # Metadata (content-type, x-amz-meta-*, etc.)
|
||||
{key}._storage_object.md5 # Cached MD5 hash
|
||||
.multipart/
|
||||
{upload-id}/
|
||||
metadata.json # Upload metadata (bucket, key, parts)
|
||||
part-1 # Part data files
|
||||
part-2
|
||||
...
|
||||
.policies/
|
||||
{bucket}.policy.json # Bucket policy (IAM JSON format)
|
||||
```
|
||||
|
||||
#### Methods
|
||||
## Related Packages
|
||||
|
||||
- `static createAndStart(options)` - Create and start server in one call
|
||||
- `start()` - Start the S3 server
|
||||
- `stop()` - Stop the S3 server
|
||||
- `createBucket(name)` - Create a new bucket
|
||||
- `getS3Descriptor(options?)` - Get S3 connection configuration
|
||||
|
||||
## 🐛 Debugging Tips
|
||||
|
||||
1. **Enable verbose logging** - The server logs all operations by default
|
||||
2. **Check the buckets directory** - Find your data in `.nogit/bucketsDir/`
|
||||
3. **Use the correct endpoint** - Remember to use `127.0.0.1` or `localhost`
|
||||
4. **Force path style** - Always use path-style URLs with local S3
|
||||
|
||||
## 📈 Performance
|
||||
|
||||
`@push.rocks/smarts3` is optimized for development and testing:
|
||||
|
||||
- ⚡ **Instant operations** - No network latency
|
||||
- 💾 **Low memory footprint** - Efficient file system usage
|
||||
- 🔄 **Fast cleanup** - Clean slate mode for quick test resets
|
||||
- 🚀 **Parallel operations** - Handle multiple requests simultaneously
|
||||
|
||||
## 🔗 Related Packages
|
||||
|
||||
- [`@push.rocks/smartbucket`](https://www.npmjs.com/package/@push.rocks/smartbucket) - Powerful S3 abstraction layer
|
||||
- [`@push.rocks/smartfile`](https://www.npmjs.com/package/@push.rocks/smartfile) - Advanced file system operations
|
||||
- [`@tsclass/tsclass`](https://www.npmjs.com/package/@tsclass/tsclass) - TypeScript class helpers
|
||||
- [`@push.rocks/smartbucket`](https://code.foss.global/push.rocks/smartbucket) — High-level S3-compatible abstraction layer
|
||||
- [`@push.rocks/smartrust`](https://code.foss.global/push.rocks/smartrust) — TypeScript <-> Rust IPC bridge
|
||||
- [`@git.zone/tsrust`](https://code.foss.global/git.zone/tsrust) — Rust cross-compilation for npm packages
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District court Bremen HRB 35230 HB, Germany
|
||||
Task Venture Capital GmbH
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
|
||||
2
rust/.cargo/config.toml
Normal file
2
rust/.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
2377
rust/Cargo.lock
generated
Normal file
2377
rust/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
rust/Cargo.toml
Normal file
43
rust/Cargo.toml
Normal file
@@ -0,0 +1,43 @@
|
||||
[package]
|
||||
name = "ruststorage"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "ruststorage"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
hyper = { version = "1", features = ["http1", "server"] }
|
||||
hyper-util = { version = "0.1", features = ["tokio", "http1"] }
|
||||
http-body-util = "0.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
quick-xml = { version = "0.37", features = ["serialize"] }
|
||||
md-5 = "0.10"
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
bytes = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
percent-encoding = "2"
|
||||
url = "2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
futures-core = "0.3"
|
||||
futures = "0.3"
|
||||
async-trait = "0.1"
|
||||
reed-solomon-erasure = { version = "6", features = ["simd-accel"] }
|
||||
xxhash-rust = { version = "0.8", features = ["xxh64"] }
|
||||
crc32c = "0.6"
|
||||
bincode = "1"
|
||||
quinn = "0.11"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
|
||||
rcgen = "0.13"
|
||||
dashmap = "6"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
172
rust/src/action.rs
Normal file
172
rust/src/action.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use hyper::body::Incoming;
|
||||
use hyper::{Method, Request};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Storage actions that map to IAM permission strings.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StorageAction {
|
||||
ListAllMyBuckets,
|
||||
CreateBucket,
|
||||
DeleteBucket,
|
||||
HeadBucket,
|
||||
ListBucket,
|
||||
GetObject,
|
||||
HeadObject,
|
||||
PutObject,
|
||||
DeleteObject,
|
||||
CopyObject,
|
||||
ListBucketMultipartUploads,
|
||||
AbortMultipartUpload,
|
||||
InitiateMultipartUpload,
|
||||
UploadPart,
|
||||
CompleteMultipartUpload,
|
||||
GetBucketPolicy,
|
||||
PutBucketPolicy,
|
||||
DeleteBucketPolicy,
|
||||
}
|
||||
|
||||
impl StorageAction {
|
||||
/// Return the IAM-style action string (e.g. "s3:GetObject").
|
||||
pub fn iam_action(&self) -> &'static str {
|
||||
match self {
|
||||
StorageAction::ListAllMyBuckets => "s3:ListAllMyBuckets",
|
||||
StorageAction::CreateBucket => "s3:CreateBucket",
|
||||
StorageAction::DeleteBucket => "s3:DeleteBucket",
|
||||
StorageAction::HeadBucket => "s3:ListBucket",
|
||||
StorageAction::ListBucket => "s3:ListBucket",
|
||||
StorageAction::GetObject => "s3:GetObject",
|
||||
StorageAction::HeadObject => "s3:GetObject",
|
||||
StorageAction::PutObject => "s3:PutObject",
|
||||
StorageAction::DeleteObject => "s3:DeleteObject",
|
||||
StorageAction::CopyObject => "s3:PutObject",
|
||||
StorageAction::ListBucketMultipartUploads => "s3:ListBucketMultipartUploads",
|
||||
StorageAction::AbortMultipartUpload => "s3:AbortMultipartUpload",
|
||||
StorageAction::InitiateMultipartUpload => "s3:PutObject",
|
||||
StorageAction::UploadPart => "s3:PutObject",
|
||||
StorageAction::CompleteMultipartUpload => "s3:PutObject",
|
||||
StorageAction::GetBucketPolicy => "s3:GetBucketPolicy",
|
||||
StorageAction::PutBucketPolicy => "s3:PutBucketPolicy",
|
||||
StorageAction::DeleteBucketPolicy => "s3:DeleteBucketPolicy",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Context extracted from a request, used for policy evaluation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RequestContext {
|
||||
pub action: StorageAction,
|
||||
pub bucket: Option<String>,
|
||||
pub key: Option<String>,
|
||||
}
|
||||
|
||||
impl RequestContext {
|
||||
/// Build the ARN for this request's resource.
|
||||
pub fn resource_arn(&self) -> String {
|
||||
match (&self.bucket, &self.key) {
|
||||
(Some(bucket), Some(key)) => format!("arn:aws:s3:::{}/{}", bucket, key),
|
||||
(Some(bucket), None) => format!("arn:aws:s3:::{}", bucket),
|
||||
_ => "arn:aws:s3:::*".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the storage action from an incoming HTTP request.
|
||||
pub fn resolve_action(req: &Request<Incoming>) -> RequestContext {
|
||||
let method = req.method().clone();
|
||||
let path = req.uri().path().to_string();
|
||||
let query_string = req.uri().query().unwrap_or("").to_string();
|
||||
let query = parse_query_simple(&query_string);
|
||||
|
||||
let segments: Vec<&str> = path
|
||||
.trim_start_matches('/')
|
||||
.splitn(2, '/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
match segments.len() {
|
||||
0 => {
|
||||
// Root: GET / -> ListBuckets
|
||||
RequestContext {
|
||||
action: StorageAction::ListAllMyBuckets,
|
||||
bucket: None,
|
||||
key: None,
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
let bucket = percent_decode(segments[0]);
|
||||
let has_policy = query.contains_key("policy");
|
||||
let has_uploads = query.contains_key("uploads");
|
||||
|
||||
let action = match (&method, has_policy, has_uploads) {
|
||||
(&Method::GET, true, _) => StorageAction::GetBucketPolicy,
|
||||
(&Method::PUT, true, _) => StorageAction::PutBucketPolicy,
|
||||
(&Method::DELETE, true, _) => StorageAction::DeleteBucketPolicy,
|
||||
(&Method::GET, _, true) => StorageAction::ListBucketMultipartUploads,
|
||||
(&Method::GET, _, _) => StorageAction::ListBucket,
|
||||
(&Method::PUT, _, _) => StorageAction::CreateBucket,
|
||||
(&Method::DELETE, _, _) => StorageAction::DeleteBucket,
|
||||
(&Method::HEAD, _, _) => StorageAction::HeadBucket,
|
||||
_ => StorageAction::ListBucket,
|
||||
};
|
||||
|
||||
RequestContext {
|
||||
action,
|
||||
bucket: Some(bucket),
|
||||
key: None,
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
let bucket = percent_decode(segments[0]);
|
||||
let key = percent_decode(segments[1]);
|
||||
|
||||
let has_copy_source = req.headers().contains_key("x-amz-copy-source");
|
||||
let has_part_number = query.contains_key("partNumber");
|
||||
let has_upload_id = query.contains_key("uploadId");
|
||||
let has_uploads = query.contains_key("uploads");
|
||||
|
||||
let action = match &method {
|
||||
&Method::PUT if has_part_number && has_upload_id => StorageAction::UploadPart,
|
||||
&Method::PUT if has_copy_source => StorageAction::CopyObject,
|
||||
&Method::PUT => StorageAction::PutObject,
|
||||
&Method::GET => StorageAction::GetObject,
|
||||
&Method::HEAD => StorageAction::HeadObject,
|
||||
&Method::DELETE if has_upload_id => StorageAction::AbortMultipartUpload,
|
||||
&Method::DELETE => StorageAction::DeleteObject,
|
||||
&Method::POST if has_uploads => StorageAction::InitiateMultipartUpload,
|
||||
&Method::POST if has_upload_id => StorageAction::CompleteMultipartUpload,
|
||||
_ => StorageAction::GetObject,
|
||||
};
|
||||
|
||||
RequestContext {
|
||||
action,
|
||||
bucket: Some(bucket),
|
||||
key: Some(key),
|
||||
}
|
||||
}
|
||||
_ => RequestContext {
|
||||
action: StorageAction::ListAllMyBuckets,
|
||||
bucket: None,
|
||||
key: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_query_simple(query_string: &str) -> HashMap<String, String> {
|
||||
let mut map = HashMap::new();
|
||||
if query_string.is_empty() {
|
||||
return map;
|
||||
}
|
||||
for pair in query_string.split('&') {
|
||||
let mut parts = pair.splitn(2, '=');
|
||||
let key = parts.next().unwrap_or("");
|
||||
let value = parts.next().unwrap_or("");
|
||||
map.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn percent_decode(s: &str) -> String {
|
||||
percent_encoding::percent_decode_str(s)
|
||||
.decode_utf8_lossy()
|
||||
.to_string()
|
||||
}
|
||||
310
rust/src/auth.rs
Normal file
310
rust/src/auth.rs
Normal file
@@ -0,0 +1,310 @@
|
||||
use hmac::{Hmac, Mac};
|
||||
use hyper::body::Incoming;
|
||||
use hyper::Request;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::config::{Credential, SmartStorageConfig};
|
||||
use crate::error::StorageError;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
/// The identity of an authenticated caller.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthenticatedIdentity {
|
||||
pub access_key_id: String,
|
||||
}
|
||||
|
||||
/// Parsed components of an AWS4-HMAC-SHA256 Authorization header.
|
||||
struct SigV4Header {
|
||||
access_key_id: String,
|
||||
date_stamp: String,
|
||||
region: String,
|
||||
signed_headers: Vec<String>,
|
||||
signature: String,
|
||||
}
|
||||
|
||||
/// Verify the request's SigV4 signature. Returns the caller identity on success.
|
||||
pub fn verify_request(
|
||||
req: &Request<Incoming>,
|
||||
config: &SmartStorageConfig,
|
||||
) -> Result<AuthenticatedIdentity, StorageError> {
|
||||
let auth_header = req
|
||||
.headers()
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
// Reject SigV2
|
||||
if auth_header.starts_with("AWS ") {
|
||||
return Err(StorageError::authorization_header_malformed());
|
||||
}
|
||||
|
||||
if !auth_header.starts_with("AWS4-HMAC-SHA256") {
|
||||
return Err(StorageError::authorization_header_malformed());
|
||||
}
|
||||
|
||||
let parsed = parse_auth_header(auth_header)?;
|
||||
|
||||
// Look up credential
|
||||
let credential = find_credential(&parsed.access_key_id, config)
|
||||
.ok_or_else(StorageError::invalid_access_key_id)?;
|
||||
|
||||
// Get x-amz-date
|
||||
let amz_date = req
|
||||
.headers()
|
||||
.get("x-amz-date")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.or_else(|| {
|
||||
req.headers()
|
||||
.get("date")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
})
|
||||
.ok_or_else(|| StorageError::missing_security_header("Missing x-amz-date header"))?;
|
||||
|
||||
// Enforce 15-min clock skew
|
||||
check_clock_skew(amz_date)?;
|
||||
|
||||
// Get payload hash
|
||||
let content_sha256 = req
|
||||
.headers()
|
||||
.get("x-amz-content-sha256")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("UNSIGNED-PAYLOAD");
|
||||
|
||||
// Build canonical request
|
||||
let canonical_request = build_canonical_request(req, &parsed.signed_headers, content_sha256);
|
||||
|
||||
// Build string to sign
|
||||
let scope = format!(
|
||||
"{}/{}/s3/aws4_request",
|
||||
parsed.date_stamp, parsed.region
|
||||
);
|
||||
let canonical_hash = hex::encode(Sha256::digest(canonical_request.as_bytes()));
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
||||
amz_date, scope, canonical_hash
|
||||
);
|
||||
|
||||
// Derive signing key
|
||||
let signing_key = derive_signing_key(
|
||||
&credential.secret_access_key,
|
||||
&parsed.date_stamp,
|
||||
&parsed.region,
|
||||
);
|
||||
|
||||
// Compute signature
|
||||
let computed = hmac_sha256(&signing_key, string_to_sign.as_bytes());
|
||||
let computed_hex = hex::encode(&computed);
|
||||
|
||||
// Constant-time comparison
|
||||
if !constant_time_eq(computed_hex.as_bytes(), parsed.signature.as_bytes()) {
|
||||
return Err(StorageError::signature_does_not_match());
|
||||
}
|
||||
|
||||
Ok(AuthenticatedIdentity {
|
||||
access_key_id: parsed.access_key_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the Authorization header into its components.
|
||||
fn parse_auth_header(header: &str) -> Result<SigV4Header, StorageError> {
|
||||
// Format: AWS4-HMAC-SHA256 Credential=KEY/YYYYMMDD/region/s3/aws4_request, SignedHeaders=h1;h2, Signature=hex
|
||||
let after_algo = header
|
||||
.strip_prefix("AWS4-HMAC-SHA256")
|
||||
.ok_or_else(StorageError::authorization_header_malformed)?
|
||||
.trim();
|
||||
|
||||
let mut credential_str = None;
|
||||
let mut signed_headers_str = None;
|
||||
let mut signature_str = None;
|
||||
|
||||
for part in after_algo.split(',') {
|
||||
let part = part.trim();
|
||||
if let Some(val) = part.strip_prefix("Credential=") {
|
||||
credential_str = Some(val.trim());
|
||||
} else if let Some(val) = part.strip_prefix("SignedHeaders=") {
|
||||
signed_headers_str = Some(val.trim());
|
||||
} else if let Some(val) = part.strip_prefix("Signature=") {
|
||||
signature_str = Some(val.trim());
|
||||
}
|
||||
}
|
||||
|
||||
let credential_str = credential_str
|
||||
.ok_or_else(StorageError::authorization_header_malformed)?;
|
||||
let signed_headers_str = signed_headers_str
|
||||
.ok_or_else(StorageError::authorization_header_malformed)?;
|
||||
let signature = signature_str
|
||||
.ok_or_else(StorageError::authorization_header_malformed)?
|
||||
.to_string();
|
||||
|
||||
// Parse credential: KEY/YYYYMMDD/region/s3/aws4_request
|
||||
let cred_parts: Vec<&str> = credential_str.splitn(5, '/').collect();
|
||||
if cred_parts.len() < 5 {
|
||||
return Err(StorageError::authorization_header_malformed());
|
||||
}
|
||||
|
||||
let access_key_id = cred_parts[0].to_string();
|
||||
let date_stamp = cred_parts[1].to_string();
|
||||
let region = cred_parts[2].to_string();
|
||||
|
||||
let signed_headers: Vec<String> = signed_headers_str
|
||||
.split(';')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.collect();
|
||||
|
||||
Ok(SigV4Header {
|
||||
access_key_id,
|
||||
date_stamp,
|
||||
region,
|
||||
signed_headers,
|
||||
signature,
|
||||
})
|
||||
}
|
||||
|
||||
/// Find a credential by access key ID.
|
||||
fn find_credential<'a>(access_key_id: &str, config: &'a SmartStorageConfig) -> Option<&'a Credential> {
|
||||
config
|
||||
.auth
|
||||
.credentials
|
||||
.iter()
|
||||
.find(|c| c.access_key_id == access_key_id)
|
||||
}
|
||||
|
||||
/// Check clock skew (15 minutes max).
|
||||
fn check_clock_skew(amz_date: &str) -> Result<(), StorageError> {
|
||||
// Parse ISO 8601 basic format: YYYYMMDDTHHMMSSZ
|
||||
let parsed = chrono::NaiveDateTime::parse_from_str(amz_date, "%Y%m%dT%H%M%SZ")
|
||||
.map_err(|_| StorageError::authorization_header_malformed())?;
|
||||
|
||||
let request_time = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(parsed, chrono::Utc);
|
||||
let now = chrono::Utc::now();
|
||||
let diff = (now - request_time).num_seconds().unsigned_abs();
|
||||
|
||||
if diff > 15 * 60 {
|
||||
return Err(StorageError::request_time_too_skewed());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the canonical request string.
|
||||
fn build_canonical_request(
|
||||
req: &Request<Incoming>,
|
||||
signed_headers: &[String],
|
||||
payload_hash: &str,
|
||||
) -> String {
|
||||
let method = req.method().as_str();
|
||||
let uri_path = req.uri().path();
|
||||
|
||||
// Canonical URI: the path, already percent-encoded by the client
|
||||
let canonical_uri = if uri_path.is_empty() { "/" } else { uri_path };
|
||||
|
||||
// Canonical query string: sorted key=value pairs
|
||||
let canonical_query = build_canonical_query(req.uri().query().unwrap_or(""));
|
||||
|
||||
// Canonical headers: sorted by lowercase header name
|
||||
let canonical_headers = build_canonical_headers(req, signed_headers);
|
||||
|
||||
// Signed headers string
|
||||
let signed_headers_str = signed_headers.join(";");
|
||||
|
||||
// Payload hash — accept UNSIGNED-PAYLOAD and STREAMING-AWS4-HMAC-SHA256-PAYLOAD as-is
|
||||
let effective_payload_hash = if payload_hash == "UNSIGNED-PAYLOAD"
|
||||
|| payload_hash == "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"
|
||||
{
|
||||
payload_hash.to_string()
|
||||
} else {
|
||||
payload_hash.to_string()
|
||||
};
|
||||
|
||||
format!(
|
||||
"{}\n{}\n{}\n{}\n{}\n{}",
|
||||
method,
|
||||
canonical_uri,
|
||||
canonical_query,
|
||||
canonical_headers,
|
||||
signed_headers_str,
|
||||
effective_payload_hash
|
||||
)
|
||||
}
|
||||
|
||||
/// Build canonical query string (sorted key=value pairs).
|
||||
fn build_canonical_query(query: &str) -> String {
|
||||
if query.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut pairs: Vec<(String, String)> = Vec::new();
|
||||
for pair in query.split('&') {
|
||||
let mut parts = pair.splitn(2, '=');
|
||||
let key = parts.next().unwrap_or("");
|
||||
let value = parts.next().unwrap_or("");
|
||||
pairs.push((key.to_string(), value.to_string()));
|
||||
}
|
||||
pairs.sort();
|
||||
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, v))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&")
|
||||
}
|
||||
|
||||
/// Build canonical headers string.
|
||||
fn build_canonical_headers(req: &Request<Incoming>, signed_headers: &[String]) -> String {
|
||||
let mut header_map: HashMap<String, Vec<String>> = HashMap::new();
|
||||
|
||||
for (name, value) in req.headers() {
|
||||
let name_lower = name.as_str().to_lowercase();
|
||||
if signed_headers.contains(&name_lower) {
|
||||
if let Ok(val) = value.to_str() {
|
||||
header_map
|
||||
.entry(name_lower)
|
||||
.or_default()
|
||||
.push(val.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = String::new();
|
||||
for header_name in signed_headers {
|
||||
let values = header_map
|
||||
.get(header_name)
|
||||
.map(|v| v.join(","))
|
||||
.unwrap_or_default();
|
||||
result.push_str(header_name);
|
||||
result.push(':');
|
||||
result.push_str(&values);
|
||||
result.push('\n');
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Derive the signing key via 4-step HMAC chain.
|
||||
fn derive_signing_key(secret_key: &str, date_stamp: &str, region: &str) -> Vec<u8> {
|
||||
let k_secret = format!("AWS4{}", secret_key);
|
||||
let k_date = hmac_sha256(k_secret.as_bytes(), date_stamp.as_bytes());
|
||||
let k_region = hmac_sha256(&k_date, region.as_bytes());
|
||||
let k_service = hmac_sha256(&k_region, b"s3");
|
||||
hmac_sha256(&k_service, b"aws4_request")
|
||||
}
|
||||
|
||||
/// Compute HMAC-SHA256.
|
||||
fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC key length is always valid");
|
||||
mac.update(data);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Constant-time byte comparison.
|
||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
95
rust/src/cluster/config.rs
Normal file
95
rust/src/cluster/config.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClusterConfig {
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub node_id: Option<String>,
|
||||
#[serde(default = "default_quic_port")]
|
||||
pub quic_port: u16,
|
||||
#[serde(default)]
|
||||
pub seed_nodes: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub erasure: ErasureConfig,
|
||||
#[serde(default)]
|
||||
pub drives: DriveConfig,
|
||||
#[serde(default = "default_heartbeat_interval")]
|
||||
pub heartbeat_interval_ms: u64,
|
||||
#[serde(default = "default_heartbeat_timeout")]
|
||||
pub heartbeat_timeout_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ErasureConfig {
|
||||
#[serde(default = "default_data_shards")]
|
||||
pub data_shards: usize,
|
||||
#[serde(default = "default_parity_shards")]
|
||||
pub parity_shards: usize,
|
||||
#[serde(default = "default_chunk_size")]
|
||||
pub chunk_size_bytes: usize,
|
||||
}
|
||||
|
||||
impl ErasureConfig {
|
||||
pub fn total_shards(&self) -> usize {
|
||||
self.data_shards + self.parity_shards
|
||||
}
|
||||
|
||||
/// Minimum shards needed for a write to succeed (data_shards + 1)
|
||||
pub fn write_quorum(&self) -> usize {
|
||||
self.data_shards + 1
|
||||
}
|
||||
|
||||
/// Minimum shards needed to reconstruct data
|
||||
pub fn read_quorum(&self) -> usize {
|
||||
self.data_shards
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ErasureConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
data_shards: default_data_shards(),
|
||||
parity_shards: default_parity_shards(),
|
||||
chunk_size_bytes: default_chunk_size(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DriveConfig {
|
||||
#[serde(default)]
|
||||
pub paths: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for DriveConfig {
|
||||
fn default() -> Self {
|
||||
Self { paths: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
fn default_quic_port() -> u16 {
|
||||
4000
|
||||
}
|
||||
|
||||
fn default_heartbeat_interval() -> u64 {
|
||||
5000
|
||||
}
|
||||
|
||||
fn default_heartbeat_timeout() -> u64 {
|
||||
30000
|
||||
}
|
||||
|
||||
fn default_data_shards() -> usize {
|
||||
4
|
||||
}
|
||||
|
||||
fn default_parity_shards() -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn default_chunk_size() -> usize {
|
||||
4 * 1024 * 1024 // 4 MB
|
||||
}
|
||||
1246
rust/src/cluster/coordinator.rs
Normal file
1246
rust/src/cluster/coordinator.rs
Normal file
File diff suppressed because it is too large
Load Diff
242
rust/src/cluster/drive_manager.rs
Normal file
242
rust/src/cluster/drive_manager.rs
Normal file
@@ -0,0 +1,242 @@
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
use super::config::DriveConfig;
|
||||
|
||||
// ============================
|
||||
// Drive format (on-disk metadata)
|
||||
// ============================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DriveFormat {
|
||||
pub cluster_id: String,
|
||||
pub erasure_set_id: u32,
|
||||
pub drive_index_in_set: u32,
|
||||
pub format_version: u32,
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Drive state tracking
|
||||
// ============================
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DriveStatus {
|
||||
Online,
|
||||
Degraded,
|
||||
Offline,
|
||||
Healing,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DriveStats {
|
||||
pub total_bytes: u64,
|
||||
pub used_bytes: u64,
|
||||
pub avg_write_latency_us: u64,
|
||||
pub avg_read_latency_us: u64,
|
||||
pub error_count: u64,
|
||||
pub last_error: Option<String>,
|
||||
pub last_check: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Default for DriveStats {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
total_bytes: 0,
|
||||
used_bytes: 0,
|
||||
avg_write_latency_us: 0,
|
||||
avg_read_latency_us: 0,
|
||||
error_count: 0,
|
||||
last_error: None,
|
||||
last_check: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DriveState {
|
||||
pub path: PathBuf,
|
||||
pub format: Option<DriveFormat>,
|
||||
pub status: DriveStatus,
|
||||
pub stats: DriveStats,
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Drive manager
|
||||
// ============================
|
||||
|
||||
pub struct DriveManager {
|
||||
drives: Vec<DriveState>,
|
||||
}
|
||||
|
||||
impl DriveManager {
|
||||
/// Initialize drive manager with configured drive paths.
|
||||
pub async fn new(config: &DriveConfig) -> Result<Self> {
|
||||
let mut drives = Vec::with_capacity(config.paths.len());
|
||||
|
||||
for path_str in &config.paths {
|
||||
let path = PathBuf::from(path_str);
|
||||
let storage_dir = path.join(".smartstorage");
|
||||
|
||||
// Ensure the drive directory exists
|
||||
fs::create_dir_all(&storage_dir).await?;
|
||||
|
||||
// Try to read existing format
|
||||
let format = Self::read_format(&storage_dir).await;
|
||||
let status = if path.exists() {
|
||||
DriveStatus::Online
|
||||
} else {
|
||||
DriveStatus::Offline
|
||||
};
|
||||
|
||||
drives.push(DriveState {
|
||||
path,
|
||||
format,
|
||||
status,
|
||||
stats: DriveStats::default(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self { drives })
|
||||
}
|
||||
|
||||
/// Format drives for a new cluster. Stamps each drive with cluster and erasure set info.
|
||||
pub async fn format_drives(
|
||||
&mut self,
|
||||
cluster_id: &str,
|
||||
erasure_set_assignments: &[(u32, u32)], // (erasure_set_id, drive_index_in_set)
|
||||
) -> Result<()> {
|
||||
if erasure_set_assignments.len() != self.drives.len() {
|
||||
anyhow::bail!(
|
||||
"Erasure set assignments count ({}) doesn't match drive count ({})",
|
||||
erasure_set_assignments.len(),
|
||||
self.drives.len()
|
||||
);
|
||||
}
|
||||
|
||||
for (drive, (set_id, drive_idx)) in
|
||||
self.drives.iter_mut().zip(erasure_set_assignments.iter())
|
||||
{
|
||||
let format = DriveFormat {
|
||||
cluster_id: cluster_id.to_string(),
|
||||
erasure_set_id: *set_id,
|
||||
drive_index_in_set: *drive_idx,
|
||||
format_version: 1,
|
||||
};
|
||||
|
||||
let storage_dir = drive.path.join(".smartstorage");
|
||||
fs::create_dir_all(&storage_dir).await?;
|
||||
|
||||
let format_path = storage_dir.join("format.json");
|
||||
let json = serde_json::to_string_pretty(&format)?;
|
||||
fs::write(&format_path, json).await?;
|
||||
|
||||
drive.format = Some(format);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the number of drives managed.
|
||||
pub fn drive_count(&self) -> usize {
|
||||
self.drives.len()
|
||||
}
|
||||
|
||||
/// Get a drive's state by index.
|
||||
pub fn drive(&self, index: usize) -> Option<&DriveState> {
|
||||
self.drives.get(index)
|
||||
}
|
||||
|
||||
/// Get all drives.
|
||||
pub fn drives(&self) -> &[DriveState] {
|
||||
&self.drives
|
||||
}
|
||||
|
||||
/// Get drives that are online.
|
||||
pub fn online_drives(&self) -> Vec<usize> {
|
||||
self.drives
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, d)| d.status == DriveStatus::Online)
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check health of a specific drive by writing and reading a probe file.
|
||||
pub async fn check_drive_health(&mut self, index: usize) -> Result<DriveStatus> {
|
||||
let drive = self
|
||||
.drives
|
||||
.get_mut(index)
|
||||
.ok_or_else(|| anyhow::anyhow!("Drive index {} out of range", index))?;
|
||||
|
||||
let probe_path = drive.path.join(".smartstorage").join(".health_probe");
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// Write probe
|
||||
match fs::write(&probe_path, b"health_check").await {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
drive.stats.error_count += 1;
|
||||
drive.stats.last_error = Some(e.to_string());
|
||||
drive.status = DriveStatus::Offline;
|
||||
drive.stats.last_check = Utc::now();
|
||||
return Ok(DriveStatus::Offline);
|
||||
}
|
||||
}
|
||||
|
||||
// Read probe
|
||||
match fs::read(&probe_path).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
drive.stats.error_count += 1;
|
||||
drive.stats.last_error = Some(e.to_string());
|
||||
drive.status = DriveStatus::Offline;
|
||||
drive.stats.last_check = Utc::now();
|
||||
return Ok(DriveStatus::Offline);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up probe
|
||||
let _ = fs::remove_file(&probe_path).await;
|
||||
|
||||
let latency = start.elapsed();
|
||||
drive.stats.avg_write_latency_us = latency.as_micros() as u64;
|
||||
drive.stats.last_check = Utc::now();
|
||||
|
||||
// Mark degraded if latency is too high (>5 seconds)
|
||||
if latency.as_secs() > 5 {
|
||||
drive.status = DriveStatus::Degraded;
|
||||
} else {
|
||||
drive.status = DriveStatus::Online;
|
||||
}
|
||||
|
||||
Ok(drive.status.clone())
|
||||
}
|
||||
|
||||
/// Run health checks on all drives.
|
||||
pub async fn check_all_drives(&mut self) -> Vec<(usize, DriveStatus)> {
|
||||
let mut results = Vec::new();
|
||||
let count = self.drives.len();
|
||||
for i in 0..count {
|
||||
match self.check_drive_health(i).await {
|
||||
Ok(status) => results.push((i, status)),
|
||||
Err(e) => {
|
||||
tracing::error!(drive = i, error = %e, "Drive health check failed");
|
||||
results.push((i, DriveStatus::Offline));
|
||||
}
|
||||
}
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
// Internal helpers
|
||||
|
||||
async fn read_format(storage_dir: &Path) -> Option<DriveFormat> {
|
||||
let format_path = storage_dir.join("format.json");
|
||||
let content = fs::read_to_string(&format_path).await.ok()?;
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
}
|
||||
246
rust/src/cluster/erasure.rs
Normal file
246
rust/src/cluster/erasure.rs
Normal file
@@ -0,0 +1,246 @@
|
||||
use anyhow::Result;
|
||||
use reed_solomon_erasure::galois_8::ReedSolomon;
|
||||
|
||||
use super::config::ErasureConfig;
|
||||
|
||||
/// Erasure coder that splits data into data+parity shards using Reed-Solomon.
|
||||
///
|
||||
/// Objects are processed in fixed-size chunks (stripes). Each chunk is independently
|
||||
/// erasure-coded, enabling streaming encode/decode without buffering entire objects.
|
||||
pub struct ErasureCoder {
|
||||
rs: ReedSolomon,
|
||||
config: ErasureConfig,
|
||||
}
|
||||
|
||||
impl ErasureCoder {
|
||||
pub fn new(config: &ErasureConfig) -> Result<Self> {
|
||||
let rs = ReedSolomon::new(config.data_shards, config.parity_shards)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create Reed-Solomon encoder: {:?}", e))?;
|
||||
Ok(Self {
|
||||
rs,
|
||||
config: config.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn config(&self) -> &ErasureConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Encode a single chunk of data into data+parity shards.
|
||||
///
|
||||
/// The input data is split into `data_shards` equal-size pieces (padded if needed),
|
||||
/// then `parity_shards` parity pieces are computed.
|
||||
///
|
||||
/// Returns a Vec of length `data_shards + parity_shards`, where:
|
||||
/// - indices 0..data_shards are data shards
|
||||
/// - indices data_shards..total are parity shards
|
||||
pub fn encode_chunk(&self, data: &[u8]) -> Result<Vec<Vec<u8>>> {
|
||||
let k = self.config.data_shards;
|
||||
let m = self.config.parity_shards;
|
||||
|
||||
// Compute shard size: each data shard holds ceil(data_len / k) bytes
|
||||
let shard_size = (data.len() + k - 1) / k;
|
||||
if shard_size == 0 {
|
||||
anyhow::bail!("Cannot encode empty data");
|
||||
}
|
||||
|
||||
// Pad input to fill exactly k shards
|
||||
let mut padded = data.to_vec();
|
||||
padded.resize(shard_size * k, 0);
|
||||
|
||||
// Split into k data shards
|
||||
let mut shards: Vec<Vec<u8>> = padded.chunks(shard_size).map(|c| c.to_vec()).collect();
|
||||
|
||||
// Add m empty parity shards
|
||||
for _ in 0..m {
|
||||
shards.push(vec![0u8; shard_size]);
|
||||
}
|
||||
|
||||
// Compute parity in-place
|
||||
self.rs
|
||||
.encode(&mut shards)
|
||||
.map_err(|e| anyhow::anyhow!("Reed-Solomon encoding failed: {:?}", e))?;
|
||||
|
||||
Ok(shards)
|
||||
}
|
||||
|
||||
/// Decode (reconstruct) original data from a partial set of shards.
|
||||
///
|
||||
/// `shards` must have length == total_shards (data + parity).
|
||||
/// At least `data_shards` entries must be `Some`. Missing shards are `None`.
|
||||
/// `original_size` is the original data size before padding, used to truncate.
|
||||
///
|
||||
/// Returns the reconstructed original data.
|
||||
pub fn decode_chunk(
|
||||
&self,
|
||||
shards: &mut Vec<Option<Vec<u8>>>,
|
||||
original_size: usize,
|
||||
) -> Result<Vec<u8>> {
|
||||
let k = self.config.data_shards;
|
||||
let total = self.config.total_shards();
|
||||
|
||||
if shards.len() != total {
|
||||
anyhow::bail!(
|
||||
"Expected {} shards, got {}",
|
||||
total,
|
||||
shards.len()
|
||||
);
|
||||
}
|
||||
|
||||
let available = shards.iter().filter(|s| s.is_some()).count();
|
||||
if available < k {
|
||||
anyhow::bail!(
|
||||
"Need at least {} shards for reconstruction, only {} available",
|
||||
k,
|
||||
available
|
||||
);
|
||||
}
|
||||
|
||||
// Reconstruct missing shards
|
||||
self.rs
|
||||
.reconstruct(shards)
|
||||
.map_err(|e| anyhow::anyhow!("Reed-Solomon reconstruction failed: {:?}", e))?;
|
||||
|
||||
// Concatenate data shards (first k) and truncate to original size
|
||||
let mut result = Vec::with_capacity(original_size);
|
||||
for i in 0..k {
|
||||
if let Some(ref shard) = shards[i] {
|
||||
result.extend_from_slice(shard);
|
||||
} else {
|
||||
anyhow::bail!("Data shard {} missing after reconstruction", i);
|
||||
}
|
||||
}
|
||||
result.truncate(original_size);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Verify that all shards are consistent (no corruption).
|
||||
pub fn verify(&self, shards: &[Vec<u8>]) -> Result<bool> {
|
||||
let shard_refs: Vec<&[u8]> = shards.iter().map(|s| s.as_slice()).collect();
|
||||
self.rs
|
||||
.verify(&shard_refs)
|
||||
.map_err(|e| anyhow::anyhow!("Reed-Solomon verification failed: {:?}", e))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_config() -> ErasureConfig {
|
||||
ErasureConfig {
|
||||
data_shards: 4,
|
||||
parity_shards: 2,
|
||||
chunk_size_bytes: 4 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_decode_roundtrip() {
|
||||
let coder = ErasureCoder::new(&test_config()).unwrap();
|
||||
let original = b"Hello, erasure coding! This is a test of the Reed-Solomon implementation.";
|
||||
|
||||
let shards = coder.encode_chunk(original).unwrap();
|
||||
assert_eq!(shards.len(), 6); // 4 data + 2 parity
|
||||
|
||||
// All shards should be the same size
|
||||
let shard_size = shards[0].len();
|
||||
for s in &shards {
|
||||
assert_eq!(s.len(), shard_size);
|
||||
}
|
||||
|
||||
// Reconstruct with all shards present
|
||||
let mut shard_opts: Vec<Option<Vec<u8>>> = shards.iter().map(|s| Some(s.clone())).collect();
|
||||
let recovered = coder.decode_chunk(&mut shard_opts, original.len()).unwrap();
|
||||
assert_eq!(&recovered, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_with_missing_shards() {
|
||||
let coder = ErasureCoder::new(&test_config()).unwrap();
|
||||
let original = b"Testing reconstruction with missing shards - this should work with 4 of 6.";
|
||||
|
||||
let shards = coder.encode_chunk(original).unwrap();
|
||||
|
||||
// Remove 2 shards (the maximum we can tolerate with 2 parity)
|
||||
let mut shard_opts: Vec<Option<Vec<u8>>> = shards.iter().map(|s| Some(s.clone())).collect();
|
||||
shard_opts[1] = None; // Remove data shard 1
|
||||
shard_opts[4] = None; // Remove parity shard 0
|
||||
|
||||
let recovered = coder.decode_chunk(&mut shard_opts, original.len()).unwrap();
|
||||
assert_eq!(&recovered, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decode_with_too_many_missing() {
|
||||
let coder = ErasureCoder::new(&test_config()).unwrap();
|
||||
let original = b"This should fail with 3 missing shards.";
|
||||
|
||||
let shards = coder.encode_chunk(original).unwrap();
|
||||
|
||||
// Remove 3 shards (more than parity count of 2)
|
||||
let mut shard_opts: Vec<Option<Vec<u8>>> = shards.iter().map(|s| Some(s.clone())).collect();
|
||||
shard_opts[0] = None;
|
||||
shard_opts[2] = None;
|
||||
shard_opts[5] = None;
|
||||
|
||||
let result = coder.decode_chunk(&mut shard_opts, original.len());
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encode_large_data() {
|
||||
let coder = ErasureCoder::new(&test_config()).unwrap();
|
||||
// 1 MB of data
|
||||
let original: Vec<u8> = (0..1_000_000).map(|i| (i % 256) as u8).collect();
|
||||
|
||||
let shards = coder.encode_chunk(&original).unwrap();
|
||||
assert_eq!(shards.len(), 6);
|
||||
|
||||
// Each shard should be ~250KB (1MB / 4 data shards, rounded up)
|
||||
let expected_shard_size = (original.len() + 3) / 4;
|
||||
assert_eq!(shards[0].len(), expected_shard_size);
|
||||
|
||||
// Verify roundtrip
|
||||
let mut shard_opts: Vec<Option<Vec<u8>>> = shards.iter().map(|s| Some(s.clone())).collect();
|
||||
let recovered = coder.decode_chunk(&mut shard_opts, original.len()).unwrap();
|
||||
assert_eq!(recovered, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_shards() {
|
||||
let coder = ErasureCoder::new(&test_config()).unwrap();
|
||||
let original = b"Verify test data";
|
||||
|
||||
let shards = coder.encode_chunk(original).unwrap();
|
||||
assert!(coder.verify(&shards).unwrap());
|
||||
|
||||
// Corrupt a shard
|
||||
let mut corrupted = shards.clone();
|
||||
corrupted[0][0] ^= 0xFF;
|
||||
assert!(!coder.verify(&corrupted).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_small_config() {
|
||||
// Minimum viable: 2 data + 1 parity
|
||||
let config = ErasureConfig {
|
||||
data_shards: 2,
|
||||
parity_shards: 1,
|
||||
chunk_size_bytes: 1024,
|
||||
};
|
||||
let coder = ErasureCoder::new(&config).unwrap();
|
||||
let original = b"Small config test";
|
||||
|
||||
let shards = coder.encode_chunk(original).unwrap();
|
||||
assert_eq!(shards.len(), 3);
|
||||
|
||||
// Remove 1 shard
|
||||
let mut shard_opts: Vec<Option<Vec<u8>>> = shards.iter().map(|s| Some(s.clone())).collect();
|
||||
shard_opts[0] = None;
|
||||
|
||||
let recovered = coder.decode_chunk(&mut shard_opts, original.len()).unwrap();
|
||||
assert_eq!(&recovered, original);
|
||||
}
|
||||
}
|
||||
344
rust/src/cluster/healing.rs
Normal file
344
rust/src/cluster/healing.rs
Normal file
@@ -0,0 +1,344 @@
|
||||
use anyhow::Result;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::fs;
|
||||
|
||||
use super::config::ErasureConfig;
|
||||
use super::erasure::ErasureCoder;
|
||||
use super::metadata::ObjectManifest;
|
||||
use super::shard_store::{ShardId, ShardStore};
|
||||
use super::state::ClusterState;
|
||||
|
||||
/// Background healing service that scans for under-replicated shards
|
||||
/// and reconstructs them.
|
||||
pub struct HealingService {
|
||||
state: Arc<ClusterState>,
|
||||
erasure_coder: ErasureCoder,
|
||||
local_shard_stores: Vec<Arc<ShardStore>>,
|
||||
manifest_dir: PathBuf,
|
||||
scan_interval: Duration,
|
||||
}
|
||||
|
||||
impl HealingService {
|
||||
pub fn new(
|
||||
state: Arc<ClusterState>,
|
||||
erasure_config: &ErasureConfig,
|
||||
local_shard_stores: Vec<Arc<ShardStore>>,
|
||||
manifest_dir: PathBuf,
|
||||
scan_interval_hours: u64,
|
||||
) -> Result<Self> {
|
||||
Ok(Self {
|
||||
state,
|
||||
erasure_coder: ErasureCoder::new(erasure_config)?,
|
||||
local_shard_stores,
|
||||
manifest_dir,
|
||||
scan_interval: Duration::from_secs(scan_interval_hours * 3600),
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the healing loop as a background task.
|
||||
pub async fn run(&self, mut shutdown: tokio::sync::watch::Receiver<bool>) {
|
||||
let mut interval = tokio::time::interval(self.scan_interval);
|
||||
|
||||
// Skip the first immediate tick
|
||||
interval.tick().await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
tracing::info!("Starting healing scan");
|
||||
match self.heal_scan().await {
|
||||
Ok(stats) => {
|
||||
tracing::info!(
|
||||
checked = stats.shards_checked,
|
||||
healed = stats.shards_healed,
|
||||
errors = stats.errors,
|
||||
"Healing scan completed"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Healing scan failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown.changed() => {
|
||||
tracing::info!("Healing service shutting down");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan all manifests for shards on offline nodes, reconstruct and re-place them.
|
||||
async fn heal_scan(&self) -> Result<HealStats> {
|
||||
let mut stats = HealStats::default();
|
||||
|
||||
let offline_nodes = self.state.offline_nodes().await;
|
||||
if offline_nodes.is_empty() {
|
||||
tracing::debug!("No offline nodes, skipping heal scan");
|
||||
return Ok(stats);
|
||||
}
|
||||
|
||||
// Check that we have majority before healing (split-brain prevention)
|
||||
if !self.state.has_majority().await {
|
||||
tracing::warn!("No majority quorum, skipping heal to prevent split-brain");
|
||||
return Ok(stats);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"Found {} offline nodes, scanning for affected shards",
|
||||
offline_nodes.len()
|
||||
);
|
||||
|
||||
// Iterate all bucket directories under manifest_dir
|
||||
let mut bucket_entries = match fs::read_dir(&self.manifest_dir).await {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Ok(stats),
|
||||
};
|
||||
|
||||
while let Some(bucket_entry) = bucket_entries.next_entry().await? {
|
||||
if !bucket_entry.metadata().await?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let bucket_name = bucket_entry.file_name().to_string_lossy().to_string();
|
||||
if bucket_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scan manifests in this bucket
|
||||
self.heal_bucket(&bucket_name, &offline_nodes, &mut stats)
|
||||
.await;
|
||||
|
||||
// Yield to avoid starving foreground I/O
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
async fn heal_bucket(
|
||||
&self,
|
||||
bucket: &str,
|
||||
offline_nodes: &[String],
|
||||
stats: &mut HealStats,
|
||||
) {
|
||||
let bucket_dir = self.manifest_dir.join(bucket);
|
||||
let manifests = match self.collect_manifests(&bucket_dir).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!(bucket = bucket, error = %e, "Failed to list manifests");
|
||||
stats.errors += 1;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let local_id = self.state.local_node_id().to_string();
|
||||
|
||||
for manifest in &manifests {
|
||||
for chunk in &manifest.chunks {
|
||||
// Check if any shard in this chunk is on an offline node
|
||||
let affected: Vec<_> = chunk
|
||||
.shard_placements
|
||||
.iter()
|
||||
.filter(|p| offline_nodes.contains(&p.node_id))
|
||||
.collect();
|
||||
|
||||
if affected.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
stats.shards_checked += chunk.shard_placements.len() as u64;
|
||||
|
||||
// Try to reconstruct missing shards from available ones
|
||||
let k = manifest.data_shards;
|
||||
let total = manifest.data_shards + manifest.parity_shards;
|
||||
|
||||
// Count available shards (those NOT on offline nodes)
|
||||
let available_count = chunk
|
||||
.shard_placements
|
||||
.iter()
|
||||
.filter(|p| !offline_nodes.contains(&p.node_id))
|
||||
.count();
|
||||
|
||||
if available_count < k {
|
||||
tracing::error!(
|
||||
bucket = manifest.bucket,
|
||||
key = manifest.key,
|
||||
chunk = chunk.chunk_index,
|
||||
available = available_count,
|
||||
needed = k,
|
||||
"Cannot heal chunk: not enough available shards"
|
||||
);
|
||||
stats.errors += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch available shards (only local ones for now)
|
||||
let mut shards: Vec<Option<Vec<u8>>> = vec![None; total];
|
||||
let mut fetched = 0usize;
|
||||
|
||||
for placement in &chunk.shard_placements {
|
||||
if offline_nodes.contains(&placement.node_id) {
|
||||
continue; // Skip offline nodes
|
||||
}
|
||||
if fetched >= k {
|
||||
break;
|
||||
}
|
||||
|
||||
if placement.node_id == local_id {
|
||||
let shard_id = ShardId {
|
||||
bucket: manifest.bucket.clone(),
|
||||
key: manifest.key.clone(),
|
||||
chunk_index: chunk.chunk_index,
|
||||
shard_index: placement.shard_index,
|
||||
};
|
||||
let store_idx = placement.drive_id.parse::<usize>().unwrap_or(0);
|
||||
if let Some(store) = self.local_shard_stores.get(store_idx) {
|
||||
if let Ok((data, _)) = store.read_shard(&shard_id).await {
|
||||
shards[placement.shard_index as usize] = Some(data);
|
||||
fetched += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: fetch from other online remote nodes
|
||||
}
|
||||
|
||||
if fetched < k {
|
||||
tracing::warn!(
|
||||
bucket = manifest.bucket,
|
||||
key = manifest.key,
|
||||
chunk = chunk.chunk_index,
|
||||
"Not enough local shards to heal, skipping"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reconstruct all shards
|
||||
let reconstructed = match self.erasure_coder.decode_chunk(
|
||||
&mut shards,
|
||||
chunk.data_size,
|
||||
) {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
bucket = manifest.bucket,
|
||||
key = manifest.key,
|
||||
chunk = chunk.chunk_index,
|
||||
error = %e,
|
||||
"Reconstruction failed"
|
||||
);
|
||||
stats.errors += 1;
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if !reconstructed {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Re-encode to get all shards back (including the missing ones)
|
||||
let full_data_size = chunk.data_size;
|
||||
let mut data_buf = Vec::with_capacity(full_data_size);
|
||||
for i in 0..k {
|
||||
if let Some(ref shard) = shards[i] {
|
||||
data_buf.extend_from_slice(shard);
|
||||
}
|
||||
}
|
||||
data_buf.truncate(full_data_size);
|
||||
|
||||
let all_shards = match self.erasure_coder.encode_chunk(&data_buf) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Re-encoding for heal failed");
|
||||
stats.errors += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Write the missing shards to the first available local drive
|
||||
for affected_placement in &affected {
|
||||
let shard_idx = affected_placement.shard_index as usize;
|
||||
if shard_idx < all_shards.len() {
|
||||
let shard_data = &all_shards[shard_idx];
|
||||
let checksum = crc32c::crc32c(shard_data);
|
||||
|
||||
let shard_id = ShardId {
|
||||
bucket: manifest.bucket.clone(),
|
||||
key: manifest.key.clone(),
|
||||
chunk_index: chunk.chunk_index,
|
||||
shard_index: affected_placement.shard_index,
|
||||
};
|
||||
|
||||
// Place on first available local drive
|
||||
if let Some(store) = self.local_shard_stores.first() {
|
||||
match store.write_shard(&shard_id, shard_data, checksum).await {
|
||||
Ok(()) => {
|
||||
stats.shards_healed += 1;
|
||||
tracing::info!(
|
||||
bucket = manifest.bucket,
|
||||
key = manifest.key,
|
||||
chunk = chunk.chunk_index,
|
||||
shard = affected_placement.shard_index,
|
||||
"Shard healed successfully"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to write healed shard");
|
||||
stats.errors += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect all manifests under a bucket directory.
|
||||
async fn collect_manifests(&self, dir: &std::path::Path) -> Result<Vec<ObjectManifest>> {
|
||||
let mut manifests = Vec::new();
|
||||
self.collect_manifests_recursive(dir, &mut manifests).await?;
|
||||
Ok(manifests)
|
||||
}
|
||||
|
||||
fn collect_manifests_recursive<'a>(
|
||||
&'a self,
|
||||
dir: &'a std::path::Path,
|
||||
manifests: &'a mut Vec<ObjectManifest>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
let mut entries = match fs::read_dir(dir).await {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let meta = entry.metadata().await?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
if meta.is_dir() {
|
||||
self.collect_manifests_recursive(&entry.path(), manifests)
|
||||
.await?;
|
||||
} else if name.ends_with(".manifest.json") {
|
||||
if let Ok(content) = fs::read_to_string(entry.path()).await {
|
||||
if let Ok(manifest) = serde_json::from_str::<ObjectManifest>(&content) {
|
||||
manifests.push(manifest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct HealStats {
|
||||
pub shards_checked: u64,
|
||||
pub shards_healed: u64,
|
||||
pub errors: u64,
|
||||
}
|
||||
226
rust/src/cluster/membership.rs
Normal file
226
rust/src/cluster/membership.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
use anyhow::Result;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::drive_manager::{DriveManager, DriveStatus};
|
||||
use super::protocol::{
|
||||
ClusterRequest, ClusterResponse, DriveStateInfo, HeartbeatMessage, JoinRequestMessage,
|
||||
NodeInfo,
|
||||
};
|
||||
use super::quic_transport::QuicTransport;
|
||||
use super::state::ClusterState;
|
||||
|
||||
/// Manages cluster membership: heartbeating, joining, failure detection.
|
||||
pub struct MembershipManager {
|
||||
state: Arc<ClusterState>,
|
||||
transport: Arc<QuicTransport>,
|
||||
heartbeat_interval: Duration,
|
||||
local_node_info: NodeInfo,
|
||||
drive_manager: Option<Arc<Mutex<DriveManager>>>,
|
||||
}
|
||||
|
||||
impl MembershipManager {
|
||||
pub fn new(
|
||||
state: Arc<ClusterState>,
|
||||
transport: Arc<QuicTransport>,
|
||||
heartbeat_interval_ms: u64,
|
||||
local_node_info: NodeInfo,
|
||||
) -> Self {
|
||||
Self {
|
||||
state,
|
||||
transport,
|
||||
heartbeat_interval: Duration::from_millis(heartbeat_interval_ms),
|
||||
local_node_info,
|
||||
drive_manager: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the drive manager for health reporting in heartbeats.
|
||||
pub fn with_drive_manager(mut self, dm: Arc<Mutex<DriveManager>>) -> Self {
|
||||
self.drive_manager = Some(dm);
|
||||
self
|
||||
}
|
||||
|
||||
/// Join the cluster by contacting seed nodes.
|
||||
/// Sends a JoinRequest to each seed node until one accepts.
|
||||
pub async fn join_cluster(&self, seed_nodes: &[String]) -> Result<()> {
|
||||
if seed_nodes.is_empty() {
|
||||
tracing::info!("No seed nodes configured, starting as initial cluster node");
|
||||
self.state.add_node(self.local_node_info.clone()).await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for seed in seed_nodes {
|
||||
let addr: SocketAddr = match seed.parse() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
tracing::warn!("Invalid seed node address '{}': {}", seed, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("Attempting to join cluster via seed node {}", seed);
|
||||
|
||||
match self.try_join(addr).await {
|
||||
Ok(()) => {
|
||||
tracing::info!("Successfully joined cluster via {}", seed);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to join via {}: {}", seed, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no seed responded, start as a new cluster
|
||||
tracing::info!("Could not reach any seed nodes, starting as initial cluster node");
|
||||
self.state.add_node(self.local_node_info.clone()).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn try_join(&self, addr: SocketAddr) -> Result<()> {
|
||||
let conn = self
|
||||
.transport
|
||||
.get_connection("seed", addr)
|
||||
.await?;
|
||||
|
||||
let request = ClusterRequest::JoinRequest(JoinRequestMessage {
|
||||
node_info: self.local_node_info.clone(),
|
||||
});
|
||||
|
||||
let response = self.transport.send_request(&conn, &request).await?;
|
||||
|
||||
match response {
|
||||
ClusterResponse::JoinResponse(join_resp) => {
|
||||
if join_resp.accepted {
|
||||
if let Some(topology) = &join_resp.topology {
|
||||
self.state.apply_topology(topology).await;
|
||||
// Also register self
|
||||
self.state.add_node(self.local_node_info.clone()).await;
|
||||
tracing::info!(
|
||||
"Applied cluster topology (version {}, {} nodes, {} erasure sets)",
|
||||
topology.version,
|
||||
topology.nodes.len(),
|
||||
topology.erasure_sets.len(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"Join rejected: {}",
|
||||
join_resp.error.unwrap_or_default()
|
||||
)
|
||||
}
|
||||
}
|
||||
ClusterResponse::Error(e) => {
|
||||
anyhow::bail!("Join error: {} - {}", e.code, e.message)
|
||||
}
|
||||
_ => anyhow::bail!("Unexpected response to join request"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the heartbeat loop. Sends heartbeats to all peers periodically.
|
||||
pub async fn heartbeat_loop(self: Arc<Self>, mut shutdown: tokio::sync::watch::Receiver<bool>) {
|
||||
let mut interval = tokio::time::interval(self.heartbeat_interval);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
self.send_heartbeats().await;
|
||||
}
|
||||
_ = shutdown.changed() => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_heartbeats(&self) {
|
||||
let peers = self.state.online_peers().await;
|
||||
let topology_version = self.state.version().await;
|
||||
let mut responded = Vec::new();
|
||||
|
||||
// Collect drive health states
|
||||
let drive_states = self.collect_drive_states().await;
|
||||
|
||||
for peer in &peers {
|
||||
let addr: SocketAddr = match peer.quic_addr.parse() {
|
||||
Ok(a) => a,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let heartbeat = ClusterRequest::Heartbeat(HeartbeatMessage {
|
||||
node_id: self.local_node_info.node_id.clone(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
drive_states: drive_states.clone(),
|
||||
topology_version,
|
||||
});
|
||||
|
||||
match tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
self.send_heartbeat_to_peer(&peer.node_id, addr, &heartbeat),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => {
|
||||
responded.push(peer.node_id.clone());
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
tracing::debug!(
|
||||
peer = %peer.node_id,
|
||||
error = %e,
|
||||
"Heartbeat failed"
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::debug!(peer = %peer.node_id, "Heartbeat timed out");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update state based on responses
|
||||
let status_changes = self.state.tick_heartbeats(&responded).await;
|
||||
for (node_id, status) in &status_changes {
|
||||
tracing::info!(node = %node_id, status = ?status, "Node status changed");
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_heartbeat_to_peer(
|
||||
&self,
|
||||
node_id: &str,
|
||||
addr: SocketAddr,
|
||||
heartbeat: &ClusterRequest,
|
||||
) -> Result<()> {
|
||||
let conn = self.transport.get_connection(node_id, addr).await?;
|
||||
let _response = self.transport.send_request(&conn, heartbeat).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Collect drive health states from the DriveManager, if available.
|
||||
async fn collect_drive_states(&self) -> Vec<DriveStateInfo> {
|
||||
let dm = match &self.drive_manager {
|
||||
Some(dm) => dm,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut manager = dm.lock().await;
|
||||
let results = manager.check_all_drives().await;
|
||||
|
||||
results
|
||||
.into_iter()
|
||||
.map(|(idx, status)| {
|
||||
let status_str = match status {
|
||||
DriveStatus::Online => "online",
|
||||
DriveStatus::Degraded => "degraded",
|
||||
DriveStatus::Offline => "offline",
|
||||
DriveStatus::Healing => "healing",
|
||||
};
|
||||
DriveStateInfo {
|
||||
drive_index: idx as u32,
|
||||
status: status_str.to_string(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
85
rust/src/cluster/metadata.rs
Normal file
85
rust/src/cluster/metadata.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Full manifest describing how an object is stored across erasure-coded shards.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ObjectManifest {
|
||||
/// Bucket name
|
||||
pub bucket: String,
|
||||
/// Object key
|
||||
pub key: String,
|
||||
/// Unique version ID for this write
|
||||
pub version_id: String,
|
||||
/// Total object size in bytes
|
||||
pub size: u64,
|
||||
/// MD5 hex digest of the complete object
|
||||
pub content_md5: String,
|
||||
/// Content type
|
||||
pub content_type: String,
|
||||
/// User metadata (x-amz-meta-*, content-type, etc.)
|
||||
pub metadata: HashMap<String, String>,
|
||||
/// When the object was created
|
||||
pub created_at: String,
|
||||
/// Last modified timestamp
|
||||
pub last_modified: String,
|
||||
/// Number of data shards used
|
||||
pub data_shards: usize,
|
||||
/// Number of parity shards used
|
||||
pub parity_shards: usize,
|
||||
/// Chunk size in bytes (last chunk may be smaller)
|
||||
pub chunk_size: usize,
|
||||
/// Per-chunk shard placement info
|
||||
pub chunks: Vec<ChunkManifest>,
|
||||
}
|
||||
|
||||
/// Describes the shards for a single chunk of an object.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChunkManifest {
|
||||
/// Index of this chunk (0-based)
|
||||
pub chunk_index: u32,
|
||||
/// Actual data size of this chunk (before erasure coding)
|
||||
pub data_size: usize,
|
||||
/// Where each shard was placed
|
||||
pub shard_placements: Vec<ShardPlacement>,
|
||||
}
|
||||
|
||||
/// Describes where a specific shard is stored.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ShardPlacement {
|
||||
/// Shard index within the erasure set (0..data_shards+parity_shards)
|
||||
pub shard_index: u32,
|
||||
/// Node that holds this shard
|
||||
pub node_id: String,
|
||||
/// Drive ID on that node
|
||||
pub drive_id: String,
|
||||
/// CRC32C checksum of the shard data
|
||||
pub checksum: u32,
|
||||
/// Size of the shard data in bytes
|
||||
pub shard_size: usize,
|
||||
}
|
||||
|
||||
/// Manifest for a multipart upload in progress.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MultipartUploadManifest {
|
||||
pub upload_id: String,
|
||||
pub bucket: String,
|
||||
pub key: String,
|
||||
pub initiated: String,
|
||||
pub metadata: HashMap<String, String>,
|
||||
/// Per-part manifests, keyed by part number.
|
||||
pub parts: HashMap<u32, PartManifest>,
|
||||
}
|
||||
|
||||
/// Manifest for a single part of a multipart upload.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PartManifest {
|
||||
pub part_number: u32,
|
||||
pub size: u64,
|
||||
pub md5: String,
|
||||
pub chunks: Vec<ChunkManifest>,
|
||||
}
|
||||
12
rust/src/cluster/mod.rs
Normal file
12
rust/src/cluster/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
pub mod config;
|
||||
pub mod coordinator;
|
||||
pub mod drive_manager;
|
||||
pub mod erasure;
|
||||
pub mod healing;
|
||||
pub mod membership;
|
||||
pub mod metadata;
|
||||
pub mod placement;
|
||||
pub mod protocol;
|
||||
pub mod quic_transport;
|
||||
pub mod shard_store;
|
||||
pub mod state;
|
||||
140
rust/src/cluster/placement.rs
Normal file
140
rust/src/cluster/placement.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use xxhash_rust::xxh64::xxh64;
|
||||
|
||||
/// Determines which erasure set an object belongs to, based on consistent hashing.
|
||||
///
|
||||
/// Uses xxhash64 of "{bucket}/{key}" to deterministically map objects to erasure sets.
|
||||
/// This is stateless — any node can independently compute the placement.
|
||||
pub fn erasure_set_for_object(bucket: &str, key: &str, num_erasure_sets: u32) -> u32 {
|
||||
if num_erasure_sets == 0 {
|
||||
return 0;
|
||||
}
|
||||
let hash_input = format!("{}/{}", bucket, key);
|
||||
let hash = xxh64(hash_input.as_bytes(), 0);
|
||||
(hash % num_erasure_sets as u64) as u32
|
||||
}
|
||||
|
||||
/// Represents a drive location within the cluster topology.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DriveLocation {
|
||||
pub node_id: String,
|
||||
pub drive_index: u32,
|
||||
}
|
||||
|
||||
/// An erasure set: a fixed group of drives that together store one complete
|
||||
/// set of shards for any object placed on them.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ErasureSet {
|
||||
pub set_id: u32,
|
||||
/// Ordered drives: index = shard_index
|
||||
pub drives: Vec<DriveLocation>,
|
||||
}
|
||||
|
||||
/// Form erasure sets from the available drives across all nodes.
|
||||
///
|
||||
/// Interleaves drives from different nodes for fault isolation:
|
||||
/// e.g., with 3 nodes x 4 drives and total_shards=6:
|
||||
/// Set 0: N0-D0, N1-D0, N2-D0, N0-D1, N1-D1, N2-D1
|
||||
/// Set 1: N0-D2, N1-D2, N2-D2, N0-D3, N1-D3, N2-D3
|
||||
pub fn form_erasure_sets(
|
||||
nodes: &[(String, u32)], // (node_id, drive_count)
|
||||
total_shards: usize,
|
||||
) -> Vec<ErasureSet> {
|
||||
// Collect all drives as (node_id, drive_index), interleaved by node
|
||||
let max_drives = nodes.iter().map(|(_, count)| *count).max().unwrap_or(0) as usize;
|
||||
let mut all_drives: Vec<DriveLocation> = Vec::new();
|
||||
|
||||
for drive_idx in 0..max_drives {
|
||||
for (node_id, drive_count) in nodes {
|
||||
if (drive_idx as u32) < *drive_count {
|
||||
all_drives.push(DriveLocation {
|
||||
node_id: node_id.clone(),
|
||||
drive_index: drive_idx as u32,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Form sets of total_shards drives each
|
||||
let num_sets = all_drives.len() / total_shards;
|
||||
let mut sets = Vec::with_capacity(num_sets);
|
||||
|
||||
for set_idx in 0..num_sets {
|
||||
let start = set_idx * total_shards;
|
||||
let end = start + total_shards;
|
||||
let drives = all_drives[start..end].to_vec();
|
||||
|
||||
sets.push(ErasureSet {
|
||||
set_id: set_idx as u32,
|
||||
drives,
|
||||
});
|
||||
}
|
||||
|
||||
sets
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_erasure_set_assignment_deterministic() {
|
||||
let set_a = erasure_set_for_object("mybucket", "mykey", 4);
|
||||
let set_b = erasure_set_for_object("mybucket", "mykey", 4);
|
||||
assert_eq!(set_a, set_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_erasure_set_distribution() {
|
||||
// Check that objects are distributed across sets
|
||||
let num_sets = 4u32;
|
||||
let mut counts = vec![0u32; num_sets as usize];
|
||||
for i in 0..1000 {
|
||||
let key = format!("key-{}", i);
|
||||
let set = erasure_set_for_object("bucket", &key, num_sets);
|
||||
assert!(set < num_sets);
|
||||
counts[set as usize] += 1;
|
||||
}
|
||||
// Each set should have some objects (not all in one set)
|
||||
for count in &counts {
|
||||
assert!(*count > 100, "Expected >100, got {}", count);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_form_erasure_sets_3x4() {
|
||||
// 3 nodes, 4 drives each, 6 shards per set => 2 sets
|
||||
let nodes = vec![
|
||||
("node1".to_string(), 4),
|
||||
("node2".to_string(), 4),
|
||||
("node3".to_string(), 4),
|
||||
];
|
||||
let sets = form_erasure_sets(&nodes, 6);
|
||||
assert_eq!(sets.len(), 2);
|
||||
|
||||
// Set 0 should interleave across nodes
|
||||
let set0_nodes: Vec<&str> = sets[0].drives.iter().map(|d| d.node_id.as_str()).collect();
|
||||
assert_eq!(set0_nodes, vec!["node1", "node2", "node3", "node1", "node2", "node3"]);
|
||||
|
||||
// Set 1 should also interleave
|
||||
let set1_nodes: Vec<&str> = sets[1].drives.iter().map(|d| d.node_id.as_str()).collect();
|
||||
assert_eq!(set1_nodes, vec!["node1", "node2", "node3", "node1", "node2", "node3"]);
|
||||
|
||||
// Drive indices should be different between sets
|
||||
let set0_drives: Vec<u32> = sets[0].drives.iter().map(|d| d.drive_index).collect();
|
||||
let set1_drives: Vec<u32> = sets[1].drives.iter().map(|d| d.drive_index).collect();
|
||||
assert_eq!(set0_drives, vec![0, 0, 0, 1, 1, 1]);
|
||||
assert_eq!(set1_drives, vec![2, 2, 2, 3, 3, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_form_erasure_sets_remainder() {
|
||||
// 2 nodes, 3 drives each, 4 shards => 1 set (2 drives left over)
|
||||
let nodes = vec![
|
||||
("a".to_string(), 3),
|
||||
("b".to_string(), 3),
|
||||
];
|
||||
let sets = form_erasure_sets(&nodes, 4);
|
||||
assert_eq!(sets.len(), 1);
|
||||
assert_eq!(sets[0].drives.len(), 4);
|
||||
}
|
||||
}
|
||||
384
rust/src/cluster/protocol.rs
Normal file
384
rust/src/cluster/protocol.rs
Normal file
@@ -0,0 +1,384 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::metadata::ObjectManifest;
|
||||
|
||||
/// All inter-node cluster messages, serialized with bincode over QUIC streams.
|
||||
///
|
||||
/// Each message type gets its own bidirectional QUIC stream.
|
||||
/// For shard data transfers, the header is sent first (bincode),
|
||||
/// then raw shard bytes follow on the same stream.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ClusterRequest {
|
||||
// ============================
|
||||
// Shard operations
|
||||
// ============================
|
||||
|
||||
/// Write a shard to a specific drive on the target node.
|
||||
/// Shard data follows after this header on the same stream.
|
||||
ShardWrite(ShardWriteRequest),
|
||||
|
||||
/// Read a shard from the target node.
|
||||
ShardRead(ShardReadRequest),
|
||||
|
||||
/// Delete a shard from the target node.
|
||||
ShardDelete(ShardDeleteRequest),
|
||||
|
||||
/// Check if a shard exists and get its metadata.
|
||||
ShardHead(ShardHeadRequest),
|
||||
|
||||
// ============================
|
||||
// Manifest operations
|
||||
// ============================
|
||||
|
||||
/// Store an object manifest on the target node.
|
||||
ManifestWrite(ManifestWriteRequest),
|
||||
|
||||
/// Retrieve an object manifest from the target node.
|
||||
ManifestRead(ManifestReadRequest),
|
||||
|
||||
/// Delete an object manifest from the target node.
|
||||
ManifestDelete(ManifestDeleteRequest),
|
||||
|
||||
/// List all manifests for a bucket on the target node.
|
||||
ManifestList(ManifestListRequest),
|
||||
|
||||
// ============================
|
||||
// Cluster management
|
||||
// ============================
|
||||
|
||||
/// Periodic heartbeat.
|
||||
Heartbeat(HeartbeatMessage),
|
||||
|
||||
/// Request to join the cluster.
|
||||
JoinRequest(JoinRequestMessage),
|
||||
|
||||
/// Synchronize cluster topology.
|
||||
TopologySync(TopologySyncMessage),
|
||||
|
||||
// ============================
|
||||
// Healing
|
||||
// ============================
|
||||
|
||||
/// Request a shard to be reconstructed and placed on a target drive.
|
||||
HealRequest(HealRequestMessage),
|
||||
}
|
||||
|
||||
/// Responses to cluster requests.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ClusterResponse {
|
||||
// Shard ops
|
||||
ShardWriteAck(ShardWriteAck),
|
||||
ShardReadResponse(ShardReadResponse),
|
||||
ShardDeleteAck(ShardDeleteAck),
|
||||
ShardHeadResponse(ShardHeadResponse),
|
||||
|
||||
// Manifest ops
|
||||
ManifestWriteAck(ManifestWriteAck),
|
||||
ManifestReadResponse(ManifestReadResponse),
|
||||
ManifestDeleteAck(ManifestDeleteAck),
|
||||
ManifestListResponse(ManifestListResponse),
|
||||
|
||||
// Cluster mgmt
|
||||
HeartbeatAck(HeartbeatAckMessage),
|
||||
JoinResponse(JoinResponseMessage),
|
||||
TopologySyncAck(TopologySyncAckMessage),
|
||||
|
||||
// Healing
|
||||
HealResponse(HealResponseMessage),
|
||||
|
||||
// Error
|
||||
Error(ErrorResponse),
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Shard operation messages
|
||||
// ============================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShardWriteRequest {
|
||||
pub request_id: String,
|
||||
pub bucket: String,
|
||||
pub key: String,
|
||||
pub chunk_index: u32,
|
||||
pub shard_index: u32,
|
||||
pub shard_data_length: u64,
|
||||
pub checksum: u32, // crc32c of shard data
|
||||
pub object_metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShardWriteAck {
|
||||
pub request_id: String,
|
||||
pub success: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShardReadRequest {
|
||||
pub request_id: String,
|
||||
pub bucket: String,
|
||||
pub key: String,
|
||||
pub chunk_index: u32,
|
||||
pub shard_index: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShardReadResponse {
|
||||
pub request_id: String,
|
||||
pub found: bool,
|
||||
pub shard_data_length: u64,
|
||||
pub checksum: u32,
|
||||
// Shard data follows on the stream after this header
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShardDeleteRequest {
|
||||
pub request_id: String,
|
||||
pub bucket: String,
|
||||
pub key: String,
|
||||
pub chunk_index: u32,
|
||||
pub shard_index: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShardDeleteAck {
|
||||
pub request_id: String,
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShardHeadRequest {
|
||||
pub request_id: String,
|
||||
pub bucket: String,
|
||||
pub key: String,
|
||||
pub chunk_index: u32,
|
||||
pub shard_index: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShardHeadResponse {
|
||||
pub request_id: String,
|
||||
pub found: bool,
|
||||
pub data_size: u64,
|
||||
pub checksum: u32,
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Manifest operation messages
|
||||
// ============================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManifestWriteRequest {
|
||||
pub request_id: String,
|
||||
pub manifest: ObjectManifest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManifestWriteAck {
|
||||
pub request_id: String,
|
||||
pub success: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManifestReadRequest {
|
||||
pub request_id: String,
|
||||
pub bucket: String,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManifestReadResponse {
|
||||
pub request_id: String,
|
||||
pub found: bool,
|
||||
pub manifest: Option<ObjectManifest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManifestDeleteRequest {
|
||||
pub request_id: String,
|
||||
pub bucket: String,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManifestDeleteAck {
|
||||
pub request_id: String,
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManifestListRequest {
|
||||
pub request_id: String,
|
||||
pub bucket: String,
|
||||
pub prefix: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ManifestListResponse {
|
||||
pub request_id: String,
|
||||
pub manifests: Vec<ObjectManifest>,
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Cluster management messages
|
||||
// ============================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DriveStateInfo {
|
||||
pub drive_index: u32,
|
||||
pub status: String, // "online", "degraded", "offline", "healing"
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HeartbeatMessage {
|
||||
pub node_id: String,
|
||||
pub timestamp: String,
|
||||
pub drive_states: Vec<DriveStateInfo>,
|
||||
pub topology_version: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HeartbeatAckMessage {
|
||||
pub node_id: String,
|
||||
pub timestamp: String,
|
||||
pub topology_version: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodeInfo {
|
||||
pub node_id: String,
|
||||
pub quic_addr: String,
|
||||
pub s3_addr: String,
|
||||
pub drive_count: u32,
|
||||
pub status: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JoinRequestMessage {
|
||||
pub node_info: NodeInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClusterTopology {
|
||||
pub version: u64,
|
||||
pub cluster_id: String,
|
||||
pub nodes: Vec<NodeInfo>,
|
||||
pub erasure_sets: Vec<ErasureSetInfo>,
|
||||
pub data_shards: usize,
|
||||
pub parity_shards: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ErasureSetInfo {
|
||||
pub set_id: u32,
|
||||
pub drives: Vec<DriveLocationInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DriveLocationInfo {
|
||||
pub node_id: String,
|
||||
pub drive_index: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JoinResponseMessage {
|
||||
pub accepted: bool,
|
||||
pub topology: Option<ClusterTopology>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TopologySyncMessage {
|
||||
pub topology: ClusterTopology,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TopologySyncAckMessage {
|
||||
pub accepted: bool,
|
||||
pub current_version: u64,
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Healing messages
|
||||
// ============================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HealRequestMessage {
|
||||
pub request_id: String,
|
||||
pub bucket: String,
|
||||
pub key: String,
|
||||
pub chunk_index: u32,
|
||||
pub shard_index: u32,
|
||||
pub target_node_id: String,
|
||||
pub target_drive_index: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HealResponseMessage {
|
||||
pub request_id: String,
|
||||
pub success: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Error response
|
||||
// ============================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub request_id: String,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Wire format helpers
|
||||
// ============================
|
||||
|
||||
/// Serialize a request to bincode bytes with a 4-byte length prefix.
|
||||
pub fn encode_request(req: &ClusterRequest) -> anyhow::Result<Vec<u8>> {
|
||||
let payload = bincode::serialize(req)?;
|
||||
let mut buf = Vec::with_capacity(4 + payload.len());
|
||||
buf.extend_from_slice(&(payload.len() as u32).to_le_bytes());
|
||||
buf.extend_from_slice(&payload);
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Serialize a response to bincode bytes with a 4-byte length prefix.
|
||||
pub fn encode_response(resp: &ClusterResponse) -> anyhow::Result<Vec<u8>> {
|
||||
let payload = bincode::serialize(resp)?;
|
||||
let mut buf = Vec::with_capacity(4 + payload.len());
|
||||
buf.extend_from_slice(&(payload.len() as u32).to_le_bytes());
|
||||
buf.extend_from_slice(&payload);
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Read a length-prefixed bincode message from raw bytes.
|
||||
/// Returns (decoded message, bytes consumed).
|
||||
pub fn decode_request(data: &[u8]) -> anyhow::Result<(ClusterRequest, usize)> {
|
||||
if data.len() < 4 {
|
||||
anyhow::bail!("Not enough data for length prefix");
|
||||
}
|
||||
let len = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
|
||||
if data.len() < 4 + len {
|
||||
anyhow::bail!("Not enough data for message body");
|
||||
}
|
||||
let msg: ClusterRequest = bincode::deserialize(&data[4..4 + len])?;
|
||||
Ok((msg, 4 + len))
|
||||
}
|
||||
|
||||
/// Read a length-prefixed bincode response from raw bytes.
|
||||
pub fn decode_response(data: &[u8]) -> anyhow::Result<(ClusterResponse, usize)> {
|
||||
if data.len() < 4 {
|
||||
anyhow::bail!("Not enough data for length prefix");
|
||||
}
|
||||
let len = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
|
||||
if data.len() < 4 + len {
|
||||
anyhow::bail!("Not enough data for message body");
|
||||
}
|
||||
let msg: ClusterResponse = bincode::deserialize(&data[4..4 + len])?;
|
||||
Ok((msg, 4 + len))
|
||||
}
|
||||
455
rust/src/cluster/quic_transport.rs
Normal file
455
rust/src/cluster/quic_transport.rs
Normal file
@@ -0,0 +1,455 @@
|
||||
use anyhow::Result;
|
||||
use dashmap::DashMap;
|
||||
use quinn::{ClientConfig, Endpoint, ServerConfig as QuinnServerConfig};
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
use super::protocol::{
|
||||
self, ClusterRequest, ClusterResponse, ShardReadResponse, ShardWriteAck, ShardWriteRequest,
|
||||
};
|
||||
use super::shard_store::{ShardId, ShardStore};
|
||||
|
||||
/// QUIC transport layer for inter-node communication.
|
||||
///
|
||||
/// Manages a QUIC endpoint for both sending and receiving cluster messages.
|
||||
/// Uses self-signed TLS certificates generated at init time.
|
||||
/// Maintains a connection pool to peer nodes.
|
||||
pub struct QuicTransport {
|
||||
endpoint: Endpoint,
|
||||
/// Cached connections to peer nodes: node_id -> Connection
|
||||
connections: Arc<DashMap<String, quinn::Connection>>,
|
||||
local_node_id: String,
|
||||
}
|
||||
|
||||
impl QuicTransport {
|
||||
/// Create a new QUIC transport, binding to the specified address.
|
||||
pub async fn new(bind_addr: SocketAddr, local_node_id: String) -> Result<Self> {
|
||||
let (server_config, client_config) = Self::generate_tls_configs()?;
|
||||
|
||||
let endpoint = Endpoint::server(server_config, bind_addr)?;
|
||||
|
||||
// Also configure the endpoint for client connections
|
||||
let mut endpoint_client = endpoint.clone();
|
||||
endpoint_client.set_default_client_config(client_config);
|
||||
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
connections: Arc::new(DashMap::new()),
|
||||
local_node_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get or establish a connection to a peer node.
|
||||
pub async fn get_connection(
|
||||
&self,
|
||||
node_id: &str,
|
||||
addr: SocketAddr,
|
||||
) -> Result<quinn::Connection> {
|
||||
// Check cache first
|
||||
if let Some(conn) = self.connections.get(node_id) {
|
||||
if conn.close_reason().is_none() {
|
||||
return Ok(conn.clone());
|
||||
}
|
||||
// Connection is closed, remove from cache
|
||||
drop(conn);
|
||||
self.connections.remove(node_id);
|
||||
}
|
||||
|
||||
// Establish new connection
|
||||
let conn = self
|
||||
.endpoint
|
||||
.connect(addr, "smartstorage")?
|
||||
.await?;
|
||||
|
||||
self.connections
|
||||
.insert(node_id.to_string(), conn.clone());
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Send a cluster request and receive the response.
|
||||
pub async fn send_request(
|
||||
&self,
|
||||
conn: &quinn::Connection,
|
||||
request: &ClusterRequest,
|
||||
) -> Result<ClusterResponse> {
|
||||
let (mut send, mut recv) = conn.open_bi().await?;
|
||||
|
||||
// Encode and send request
|
||||
let encoded = protocol::encode_request(request)?;
|
||||
send.write_all(&encoded).await?;
|
||||
send.finish()?;
|
||||
|
||||
// Read response
|
||||
let response_data = recv.read_to_end(64 * 1024 * 1024).await?; // 64MB max
|
||||
let (response, _) = protocol::decode_response(&response_data)?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Send a shard write request with streaming data.
|
||||
///
|
||||
/// Sends the request header first, then streams the shard data bytes.
|
||||
pub async fn send_shard_write(
|
||||
&self,
|
||||
conn: &quinn::Connection,
|
||||
request: ShardWriteRequest,
|
||||
shard_data: &[u8],
|
||||
) -> Result<ShardWriteAck> {
|
||||
let (mut send, mut recv) = conn.open_bi().await?;
|
||||
|
||||
// Send request header
|
||||
let encoded = protocol::encode_request(&ClusterRequest::ShardWrite(request))?;
|
||||
send.write_all(&encoded).await?;
|
||||
|
||||
// Stream shard data
|
||||
send.write_all(shard_data).await?;
|
||||
send.finish()?;
|
||||
|
||||
// Read ack
|
||||
let response_data = recv.read_to_end(1024).await?;
|
||||
let (response, _) = protocol::decode_response(&response_data)?;
|
||||
|
||||
match response {
|
||||
ClusterResponse::ShardWriteAck(ack) => Ok(ack),
|
||||
ClusterResponse::Error(e) => {
|
||||
anyhow::bail!("Shard write error: {} - {}", e.code, e.message)
|
||||
}
|
||||
other => anyhow::bail!("Unexpected response to shard write: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a shard read request and receive the shard data.
|
||||
///
|
||||
/// Returns (shard_data, checksum).
|
||||
pub async fn send_shard_read(
|
||||
&self,
|
||||
conn: &quinn::Connection,
|
||||
request: &ClusterRequest,
|
||||
) -> Result<Option<(Vec<u8>, u32)>> {
|
||||
let (mut send, mut recv) = conn.open_bi().await?;
|
||||
|
||||
// Send request
|
||||
let encoded = protocol::encode_request(request)?;
|
||||
send.write_all(&encoded).await?;
|
||||
send.finish()?;
|
||||
|
||||
// Read response header
|
||||
let mut header_len_buf = [0u8; 4];
|
||||
recv.read_exact(&mut header_len_buf).await?;
|
||||
let header_len = u32::from_le_bytes(header_len_buf) as usize;
|
||||
|
||||
let mut header_buf = vec![0u8; header_len];
|
||||
recv.read_exact(&mut header_buf).await?;
|
||||
let response: ClusterResponse = bincode::deserialize(&header_buf)?;
|
||||
|
||||
match response {
|
||||
ClusterResponse::ShardReadResponse(read_resp) => {
|
||||
if !read_resp.found {
|
||||
return Ok(None);
|
||||
}
|
||||
// Read shard data that follows
|
||||
let mut shard_data = vec![0u8; read_resp.shard_data_length as usize];
|
||||
recv.read_exact(&mut shard_data).await?;
|
||||
Ok(Some((shard_data, read_resp.checksum)))
|
||||
}
|
||||
ClusterResponse::Error(e) => {
|
||||
anyhow::bail!("Shard read error: {} - {}", e.code, e.message)
|
||||
}
|
||||
other => anyhow::bail!("Unexpected response to shard read: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept incoming connections and dispatch to the handler.
|
||||
pub async fn accept_loop(
|
||||
self: Arc<Self>,
|
||||
shard_store: Arc<ShardStore>,
|
||||
mut shutdown: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
incoming = self.endpoint.accept() => {
|
||||
match incoming {
|
||||
Some(incoming_conn) => {
|
||||
let transport = self.clone();
|
||||
let store = shard_store.clone();
|
||||
tokio::spawn(async move {
|
||||
match incoming_conn.await {
|
||||
Ok(conn) => {
|
||||
transport.handle_connection(conn, store).await;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to accept QUIC connection: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
_ = shutdown.changed() => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a single QUIC connection (may have multiple streams).
|
||||
async fn handle_connection(
|
||||
&self,
|
||||
conn: quinn::Connection,
|
||||
shard_store: Arc<ShardStore>,
|
||||
) {
|
||||
loop {
|
||||
match conn.accept_bi().await {
|
||||
Ok((send, recv)) => {
|
||||
let store = shard_store.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = Self::handle_stream(send, recv, store).await {
|
||||
tracing::error!("Stream handler error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(quinn::ConnectionError::ApplicationClosed(_)) => break,
|
||||
Err(e) => {
|
||||
tracing::error!("Connection error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a single bidirectional stream (one request-response exchange).
|
||||
async fn handle_stream(
|
||||
mut send: quinn::SendStream,
|
||||
mut recv: quinn::RecvStream,
|
||||
shard_store: Arc<ShardStore>,
|
||||
) -> Result<()> {
|
||||
// Read the length-prefixed request header
|
||||
let mut len_buf = [0u8; 4];
|
||||
recv.read_exact(&mut len_buf).await?;
|
||||
let msg_len = u32::from_le_bytes(len_buf) as usize;
|
||||
|
||||
let mut msg_buf = vec![0u8; msg_len];
|
||||
recv.read_exact(&mut msg_buf).await?;
|
||||
let request: ClusterRequest = bincode::deserialize(&msg_buf)?;
|
||||
|
||||
match request {
|
||||
ClusterRequest::ShardWrite(write_req) => {
|
||||
// Read shard data from the stream
|
||||
let mut shard_data = vec![0u8; write_req.shard_data_length as usize];
|
||||
recv.read_exact(&mut shard_data).await?;
|
||||
|
||||
let shard_id = ShardId {
|
||||
bucket: write_req.bucket,
|
||||
key: write_req.key,
|
||||
chunk_index: write_req.chunk_index,
|
||||
shard_index: write_req.shard_index,
|
||||
};
|
||||
|
||||
let result = shard_store
|
||||
.write_shard(&shard_id, &shard_data, write_req.checksum)
|
||||
.await;
|
||||
|
||||
let ack = ShardWriteAck {
|
||||
request_id: write_req.request_id,
|
||||
success: result.is_ok(),
|
||||
error: result.err().map(|e| e.to_string()),
|
||||
};
|
||||
let response = protocol::encode_response(&ClusterResponse::ShardWriteAck(ack))?;
|
||||
send.write_all(&response).await?;
|
||||
send.finish()?;
|
||||
}
|
||||
|
||||
ClusterRequest::ShardRead(read_req) => {
|
||||
let shard_id = ShardId {
|
||||
bucket: read_req.bucket,
|
||||
key: read_req.key,
|
||||
chunk_index: read_req.chunk_index,
|
||||
shard_index: read_req.shard_index,
|
||||
};
|
||||
|
||||
match shard_store.read_shard(&shard_id).await {
|
||||
Ok((data, checksum)) => {
|
||||
let header = ShardReadResponse {
|
||||
request_id: read_req.request_id,
|
||||
found: true,
|
||||
shard_data_length: data.len() as u64,
|
||||
checksum,
|
||||
};
|
||||
// Send header
|
||||
let header_bytes = bincode::serialize(&ClusterResponse::ShardReadResponse(header))?;
|
||||
send.write_all(&(header_bytes.len() as u32).to_le_bytes()).await?;
|
||||
send.write_all(&header_bytes).await?;
|
||||
// Send shard data
|
||||
send.write_all(&data).await?;
|
||||
send.finish()?;
|
||||
}
|
||||
Err(_) => {
|
||||
let header = ShardReadResponse {
|
||||
request_id: read_req.request_id,
|
||||
found: false,
|
||||
shard_data_length: 0,
|
||||
checksum: 0,
|
||||
};
|
||||
let header_bytes = bincode::serialize(&ClusterResponse::ShardReadResponse(header))?;
|
||||
send.write_all(&(header_bytes.len() as u32).to_le_bytes()).await?;
|
||||
send.write_all(&header_bytes).await?;
|
||||
send.finish()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ClusterRequest::ShardDelete(del_req) => {
|
||||
let shard_id = ShardId {
|
||||
bucket: del_req.bucket,
|
||||
key: del_req.key,
|
||||
chunk_index: del_req.chunk_index,
|
||||
shard_index: del_req.shard_index,
|
||||
};
|
||||
let result = shard_store.delete_shard(&shard_id).await;
|
||||
let ack = protocol::ClusterResponse::ShardDeleteAck(protocol::ShardDeleteAck {
|
||||
request_id: del_req.request_id,
|
||||
success: result.is_ok(),
|
||||
});
|
||||
let response = protocol::encode_response(&ack)?;
|
||||
send.write_all(&response).await?;
|
||||
send.finish()?;
|
||||
}
|
||||
|
||||
ClusterRequest::ShardHead(head_req) => {
|
||||
let shard_id = ShardId {
|
||||
bucket: head_req.bucket,
|
||||
key: head_req.key,
|
||||
chunk_index: head_req.chunk_index,
|
||||
shard_index: head_req.shard_index,
|
||||
};
|
||||
let resp = match shard_store.head_shard(&shard_id).await {
|
||||
Ok(Some(meta)) => protocol::ShardHeadResponse {
|
||||
request_id: head_req.request_id,
|
||||
found: true,
|
||||
data_size: meta.data_size,
|
||||
checksum: meta.checksum,
|
||||
},
|
||||
_ => protocol::ShardHeadResponse {
|
||||
request_id: head_req.request_id,
|
||||
found: false,
|
||||
data_size: 0,
|
||||
checksum: 0,
|
||||
},
|
||||
};
|
||||
let response =
|
||||
protocol::encode_response(&ClusterResponse::ShardHeadResponse(resp))?;
|
||||
send.write_all(&response).await?;
|
||||
send.finish()?;
|
||||
}
|
||||
|
||||
// Heartbeat, Join, TopologySync, Heal, and Manifest operations
|
||||
// will be handled by the membership and coordinator modules.
|
||||
// For now, send a generic ack.
|
||||
_ => {
|
||||
let response_data = recv.read_to_end(0).await.unwrap_or_default();
|
||||
drop(response_data);
|
||||
let err = protocol::ErrorResponse {
|
||||
request_id: String::new(),
|
||||
code: "NotImplemented".to_string(),
|
||||
message: "This cluster operation is not yet implemented".to_string(),
|
||||
};
|
||||
let response = protocol::encode_response(&ClusterResponse::Error(err))?;
|
||||
send.write_all(&response).await?;
|
||||
send.finish()?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generate self-signed TLS certificates for cluster-internal communication.
|
||||
fn generate_tls_configs() -> Result<(QuinnServerConfig, ClientConfig)> {
|
||||
// Generate self-signed certificate
|
||||
let cert = rcgen::generate_simple_self_signed(vec!["smartstorage".to_string()])?;
|
||||
let cert_der = CertificateDer::from(cert.cert);
|
||||
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der()));
|
||||
|
||||
// Server config
|
||||
let mut server_crypto = rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![cert_der.clone()], key_der.clone_key())?;
|
||||
server_crypto.alpn_protocols = vec![b"smartstorage".to_vec()];
|
||||
let server_config = QuinnServerConfig::with_crypto(Arc::new(
|
||||
quinn::crypto::rustls::QuicServerConfig::try_from(server_crypto)?,
|
||||
));
|
||||
|
||||
// Client config: skip server certificate verification (cluster-internal)
|
||||
let mut client_crypto = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(SkipServerVerification))
|
||||
.with_no_client_auth();
|
||||
client_crypto.alpn_protocols = vec![b"smartstorage".to_vec()];
|
||||
let client_config = ClientConfig::new(Arc::new(
|
||||
quinn::crypto::rustls::QuicClientConfig::try_from(client_crypto)?,
|
||||
));
|
||||
|
||||
Ok((server_config, client_config))
|
||||
}
|
||||
|
||||
/// Close the QUIC endpoint gracefully.
|
||||
pub fn close(&self) {
|
||||
self.endpoint
|
||||
.close(quinn::VarInt::from_u32(0), b"shutdown");
|
||||
}
|
||||
|
||||
/// Get the local node ID.
|
||||
pub fn local_node_id(&self) -> &str {
|
||||
&self.local_node_id
|
||||
}
|
||||
}
|
||||
|
||||
/// Certificate verifier that skips verification (for cluster-internal self-signed certs).
|
||||
#[derive(Debug)]
|
||||
struct SkipServerVerification;
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for SkipServerVerification {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &CertificateDer<'_>,
|
||||
_intermediates: &[CertificateDer<'_>],
|
||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: rustls::pki_types::UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
vec![
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA384,
|
||||
rustls::SignatureScheme::RSA_PKCS1_SHA512,
|
||||
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
||||
rustls::SignatureScheme::ED25519,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA256,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA384,
|
||||
rustls::SignatureScheme::RSA_PSS_SHA512,
|
||||
]
|
||||
}
|
||||
}
|
||||
226
rust/src/cluster/shard_store.rs
Normal file
226
rust/src/cluster/shard_store.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
/// Identifies a specific shard on disk.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub struct ShardId {
|
||||
pub bucket: String,
|
||||
pub key: String,
|
||||
pub chunk_index: u32,
|
||||
pub shard_index: u32,
|
||||
}
|
||||
|
||||
/// Per-shard metadata stored alongside shard data.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShardMeta {
|
||||
pub shard_index: u32,
|
||||
pub chunk_index: u32,
|
||||
pub data_size: u64,
|
||||
pub checksum: u32, // crc32c
|
||||
}
|
||||
|
||||
/// Manages shard storage on a single drive.
|
||||
///
|
||||
/// Layout on disk:
|
||||
/// ```text
|
||||
/// {base_path}/.smartstorage/data/{bucket}/{key_prefix}/{key}/
|
||||
/// chunk-{N}/shard-{M}.dat (shard data)
|
||||
/// chunk-{N}/shard-{M}.meta (shard metadata JSON)
|
||||
/// ```
|
||||
pub struct ShardStore {
|
||||
base_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ShardStore {
|
||||
pub fn new(base_path: PathBuf) -> Self {
|
||||
Self { base_path }
|
||||
}
|
||||
|
||||
/// Write a shard to disk atomically (write to temp file, then rename).
|
||||
pub async fn write_shard(
|
||||
&self,
|
||||
shard_id: &ShardId,
|
||||
data: &[u8],
|
||||
checksum: u32,
|
||||
) -> Result<()> {
|
||||
let shard_path = self.shard_data_path(shard_id);
|
||||
let meta_path = self.shard_meta_path(shard_id);
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = shard_path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
// Write data atomically via temp file + rename
|
||||
let temp_data_path = shard_path.with_extension("dat.tmp");
|
||||
{
|
||||
let mut file = fs::File::create(&temp_data_path).await?;
|
||||
file.write_all(data).await?;
|
||||
file.flush().await?;
|
||||
file.sync_all().await?;
|
||||
}
|
||||
fs::rename(&temp_data_path, &shard_path).await?;
|
||||
|
||||
// Write metadata
|
||||
let meta = ShardMeta {
|
||||
shard_index: shard_id.shard_index,
|
||||
chunk_index: shard_id.chunk_index,
|
||||
data_size: data.len() as u64,
|
||||
checksum,
|
||||
};
|
||||
let meta_json = serde_json::to_string(&meta)?;
|
||||
let temp_meta_path = meta_path.with_extension("meta.tmp");
|
||||
fs::write(&temp_meta_path, meta_json).await?;
|
||||
fs::rename(&temp_meta_path, &meta_path).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a shard's data from disk.
|
||||
pub async fn read_shard(&self, shard_id: &ShardId) -> Result<(Vec<u8>, u32)> {
|
||||
let shard_path = self.shard_data_path(shard_id);
|
||||
let meta_path = self.shard_meta_path(shard_id);
|
||||
|
||||
let data = fs::read(&shard_path).await?;
|
||||
let meta_json = fs::read_to_string(&meta_path).await?;
|
||||
let meta: ShardMeta = serde_json::from_str(&meta_json)?;
|
||||
|
||||
Ok((data, meta.checksum))
|
||||
}
|
||||
|
||||
/// Check if a shard exists and return its metadata.
|
||||
pub async fn head_shard(&self, shard_id: &ShardId) -> Result<Option<ShardMeta>> {
|
||||
let meta_path = self.shard_meta_path(shard_id);
|
||||
if !meta_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let meta_json = fs::read_to_string(&meta_path).await?;
|
||||
let meta: ShardMeta = serde_json::from_str(&meta_json)?;
|
||||
Ok(Some(meta))
|
||||
}
|
||||
|
||||
/// Delete a shard and its metadata.
|
||||
pub async fn delete_shard(&self, shard_id: &ShardId) -> Result<()> {
|
||||
let shard_path = self.shard_data_path(shard_id);
|
||||
let meta_path = self.shard_meta_path(shard_id);
|
||||
|
||||
let _ = fs::remove_file(&shard_path).await;
|
||||
let _ = fs::remove_file(&meta_path).await;
|
||||
|
||||
// Clean up empty parent directories
|
||||
self.cleanup_empty_dirs(shard_id).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all shard IDs for a given bucket and key (across all chunks).
|
||||
pub async fn list_shards_for_object(
|
||||
&self,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
) -> Result<Vec<ShardId>> {
|
||||
let key_dir = self.key_dir(bucket, key);
|
||||
if !key_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
let mut entries = fs::read_dir(&key_dir).await?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.starts_with("chunk-") || !entry.metadata().await?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let chunk_index: u32 = match name.strip_prefix("chunk-").and_then(|s| s.parse().ok()) {
|
||||
Some(idx) => idx,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let mut chunk_entries = fs::read_dir(entry.path()).await?;
|
||||
while let Some(shard_entry) = chunk_entries.next_entry().await? {
|
||||
let shard_name = shard_entry.file_name().to_string_lossy().to_string();
|
||||
if shard_name.starts_with("shard-") && shard_name.ends_with(".dat") {
|
||||
let shard_index: u32 = match shard_name
|
||||
.strip_prefix("shard-")
|
||||
.and_then(|s| s.strip_suffix(".dat"))
|
||||
.and_then(|s| s.parse().ok())
|
||||
{
|
||||
Some(idx) => idx,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
result.push(ShardId {
|
||||
bucket: bucket.to_string(),
|
||||
key: key.to_string(),
|
||||
chunk_index,
|
||||
shard_index,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.sort_by(|a, b| {
|
||||
a.chunk_index
|
||||
.cmp(&b.chunk_index)
|
||||
.then(a.shard_index.cmp(&b.shard_index))
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Path helpers
|
||||
// ============================
|
||||
|
||||
fn data_root(&self) -> PathBuf {
|
||||
self.base_path.join(".smartstorage").join("data")
|
||||
}
|
||||
|
||||
fn key_prefix(key: &str) -> String {
|
||||
// Use first 2 hex chars of a simple hash for directory fan-out
|
||||
let hash = xxhash_rust::xxh64::xxh64(key.as_bytes(), 0);
|
||||
format!("{:02x}", hash & 0xFF)
|
||||
}
|
||||
|
||||
fn key_dir(&self, bucket: &str, key: &str) -> PathBuf {
|
||||
self.data_root()
|
||||
.join(bucket)
|
||||
.join(Self::key_prefix(key))
|
||||
.join(key)
|
||||
}
|
||||
|
||||
fn chunk_dir(&self, shard_id: &ShardId) -> PathBuf {
|
||||
self.key_dir(&shard_id.bucket, &shard_id.key)
|
||||
.join(format!("chunk-{}", shard_id.chunk_index))
|
||||
}
|
||||
|
||||
fn shard_data_path(&self, shard_id: &ShardId) -> PathBuf {
|
||||
self.chunk_dir(shard_id)
|
||||
.join(format!("shard-{}.dat", shard_id.shard_index))
|
||||
}
|
||||
|
||||
fn shard_meta_path(&self, shard_id: &ShardId) -> PathBuf {
|
||||
self.chunk_dir(shard_id)
|
||||
.join(format!("shard-{}.meta", shard_id.shard_index))
|
||||
}
|
||||
|
||||
async fn cleanup_empty_dirs(&self, shard_id: &ShardId) {
|
||||
// Try to remove chunk dir if empty
|
||||
let chunk_dir = self.chunk_dir(shard_id);
|
||||
let _ = fs::remove_dir(&chunk_dir).await; // fails silently if not empty
|
||||
|
||||
// Try to remove key dir if empty
|
||||
let key_dir = self.key_dir(&shard_id.bucket, &shard_id.key);
|
||||
let _ = fs::remove_dir(&key_dir).await;
|
||||
|
||||
// Try to remove prefix dir if empty
|
||||
if let Some(prefix_dir) = key_dir.parent() {
|
||||
let _ = fs::remove_dir(prefix_dir).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
291
rust/src/cluster/state.rs
Normal file
291
rust/src/cluster/state.rs
Normal file
@@ -0,0 +1,291 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use super::placement::{DriveLocation, ErasureSet};
|
||||
use super::protocol::{ClusterTopology, ErasureSetInfo, DriveLocationInfo, NodeInfo};
|
||||
|
||||
/// Node status for tracking liveness.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum NodeStatus {
|
||||
Online,
|
||||
Suspect, // missed 2+ heartbeats
|
||||
Offline, // missed 5+ heartbeats
|
||||
}
|
||||
|
||||
/// Tracked state for a peer node.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NodeState {
|
||||
pub info: NodeInfo,
|
||||
pub status: NodeStatus,
|
||||
pub missed_heartbeats: u32,
|
||||
pub last_heartbeat: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// Shared cluster state, protected by RwLock for concurrent access.
|
||||
pub struct ClusterState {
|
||||
inner: Arc<RwLock<ClusterStateInner>>,
|
||||
local_node_id: String,
|
||||
}
|
||||
|
||||
struct ClusterStateInner {
|
||||
cluster_id: String,
|
||||
version: u64,
|
||||
nodes: HashMap<String, NodeState>,
|
||||
erasure_sets: Vec<ErasureSet>,
|
||||
data_shards: usize,
|
||||
parity_shards: usize,
|
||||
}
|
||||
|
||||
impl ClusterState {
|
||||
pub fn new(
|
||||
local_node_id: String,
|
||||
cluster_id: String,
|
||||
data_shards: usize,
|
||||
parity_shards: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(ClusterStateInner {
|
||||
cluster_id,
|
||||
version: 0,
|
||||
nodes: HashMap::new(),
|
||||
erasure_sets: Vec::new(),
|
||||
data_shards,
|
||||
parity_shards,
|
||||
})),
|
||||
local_node_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_node_id(&self) -> &str {
|
||||
&self.local_node_id
|
||||
}
|
||||
|
||||
/// Register a node in the cluster.
|
||||
pub async fn add_node(&self, info: NodeInfo) {
|
||||
let mut inner = self.inner.write().await;
|
||||
let node_id = info.node_id.clone();
|
||||
inner.nodes.insert(
|
||||
node_id,
|
||||
NodeState {
|
||||
info,
|
||||
status: NodeStatus::Online,
|
||||
missed_heartbeats: 0,
|
||||
last_heartbeat: chrono::Utc::now(),
|
||||
},
|
||||
);
|
||||
inner.version += 1;
|
||||
}
|
||||
|
||||
/// Remove a node from the cluster.
|
||||
pub async fn remove_node(&self, node_id: &str) {
|
||||
let mut inner = self.inner.write().await;
|
||||
inner.nodes.remove(node_id);
|
||||
inner.version += 1;
|
||||
}
|
||||
|
||||
/// Update heartbeat for a node (reset missed count).
|
||||
pub async fn record_heartbeat(&self, node_id: &str) {
|
||||
let mut inner = self.inner.write().await;
|
||||
if let Some(node) = inner.nodes.get_mut(node_id) {
|
||||
node.missed_heartbeats = 0;
|
||||
node.status = NodeStatus::Online;
|
||||
node.last_heartbeat = chrono::Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Increment missed heartbeat count for all nodes, updating status.
|
||||
/// Called by the heartbeat checker when a round completes.
|
||||
pub async fn tick_heartbeats(&self, responded_nodes: &[String]) -> Vec<(String, NodeStatus)> {
|
||||
let mut inner = self.inner.write().await;
|
||||
let mut status_changes = Vec::new();
|
||||
|
||||
for (node_id, node) in inner.nodes.iter_mut() {
|
||||
if *node_id == self.local_node_id {
|
||||
continue; // Don't track self
|
||||
}
|
||||
|
||||
if responded_nodes.contains(node_id) {
|
||||
node.missed_heartbeats = 0;
|
||||
if node.status != NodeStatus::Online {
|
||||
node.status = NodeStatus::Online;
|
||||
status_changes.push((node_id.clone(), NodeStatus::Online));
|
||||
}
|
||||
} else {
|
||||
node.missed_heartbeats += 1;
|
||||
let new_status = if node.missed_heartbeats >= 5 {
|
||||
NodeStatus::Offline
|
||||
} else if node.missed_heartbeats >= 2 {
|
||||
NodeStatus::Suspect
|
||||
} else {
|
||||
NodeStatus::Online
|
||||
};
|
||||
|
||||
if new_status != node.status {
|
||||
node.status = new_status.clone();
|
||||
status_changes.push((node_id.clone(), new_status));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
status_changes
|
||||
}
|
||||
|
||||
/// Set erasure sets (typically done once during cluster formation).
|
||||
pub async fn set_erasure_sets(&self, sets: Vec<ErasureSet>) {
|
||||
let mut inner = self.inner.write().await;
|
||||
inner.erasure_sets = sets;
|
||||
inner.version += 1;
|
||||
}
|
||||
|
||||
/// Get the erasure set for a given object based on consistent hashing.
|
||||
pub async fn get_erasure_set_for_object(&self, bucket: &str, key: &str) -> Option<ErasureSet> {
|
||||
let inner = self.inner.read().await;
|
||||
if inner.erasure_sets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let set_idx = super::placement::erasure_set_for_object(
|
||||
bucket,
|
||||
key,
|
||||
inner.erasure_sets.len() as u32,
|
||||
);
|
||||
inner.erasure_sets.get(set_idx as usize).cloned()
|
||||
}
|
||||
|
||||
/// Get all erasure sets.
|
||||
pub async fn erasure_sets(&self) -> Vec<ErasureSet> {
|
||||
self.inner.read().await.erasure_sets.clone()
|
||||
}
|
||||
|
||||
/// Get current topology version.
|
||||
pub async fn version(&self) -> u64 {
|
||||
self.inner.read().await.version
|
||||
}
|
||||
|
||||
/// Get all online node IDs (excluding self).
|
||||
pub async fn online_peers(&self) -> Vec<NodeInfo> {
|
||||
let inner = self.inner.read().await;
|
||||
inner
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| n.status == NodeStatus::Online && n.info.node_id != self.local_node_id)
|
||||
.map(|n| n.info.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all nodes.
|
||||
pub async fn all_nodes(&self) -> Vec<NodeState> {
|
||||
self.inner.read().await.nodes.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Get node info by ID.
|
||||
pub async fn get_node(&self, node_id: &str) -> Option<NodeInfo> {
|
||||
self.inner
|
||||
.read()
|
||||
.await
|
||||
.nodes
|
||||
.get(node_id)
|
||||
.map(|n| n.info.clone())
|
||||
}
|
||||
|
||||
/// Get offline node IDs.
|
||||
pub async fn offline_nodes(&self) -> Vec<String> {
|
||||
self.inner
|
||||
.read()
|
||||
.await
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| n.status == NodeStatus::Offline)
|
||||
.map(|n| n.info.node_id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check if a majority of nodes are reachable (for split-brain prevention).
|
||||
pub async fn has_majority(&self) -> bool {
|
||||
let inner = self.inner.read().await;
|
||||
let total = inner.nodes.len();
|
||||
if total == 0 {
|
||||
return true;
|
||||
}
|
||||
let online = inner
|
||||
.nodes
|
||||
.values()
|
||||
.filter(|n| n.status == NodeStatus::Online)
|
||||
.count();
|
||||
online > total / 2
|
||||
}
|
||||
|
||||
/// Export the current topology as a protocol message.
|
||||
pub async fn to_topology(&self) -> ClusterTopology {
|
||||
let inner = self.inner.read().await;
|
||||
ClusterTopology {
|
||||
version: inner.version,
|
||||
cluster_id: inner.cluster_id.clone(),
|
||||
nodes: inner.nodes.values().map(|n| n.info.clone()).collect(),
|
||||
erasure_sets: inner
|
||||
.erasure_sets
|
||||
.iter()
|
||||
.map(|set| ErasureSetInfo {
|
||||
set_id: set.set_id,
|
||||
drives: set
|
||||
.drives
|
||||
.iter()
|
||||
.map(|d| DriveLocationInfo {
|
||||
node_id: d.node_id.clone(),
|
||||
drive_index: d.drive_index,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
data_shards: inner.data_shards,
|
||||
parity_shards: inner.parity_shards,
|
||||
}
|
||||
}
|
||||
|
||||
/// Import topology from a protocol message (e.g., received from a peer during join).
|
||||
pub async fn apply_topology(&self, topology: &ClusterTopology) {
|
||||
let mut inner = self.inner.write().await;
|
||||
|
||||
// Only apply if newer
|
||||
if topology.version <= inner.version {
|
||||
return;
|
||||
}
|
||||
|
||||
inner.cluster_id = topology.cluster_id.clone();
|
||||
inner.version = topology.version;
|
||||
inner.data_shards = topology.data_shards;
|
||||
inner.parity_shards = topology.parity_shards;
|
||||
|
||||
// Update nodes
|
||||
for node_info in &topology.nodes {
|
||||
if !inner.nodes.contains_key(&node_info.node_id) {
|
||||
inner.nodes.insert(
|
||||
node_info.node_id.clone(),
|
||||
NodeState {
|
||||
info: node_info.clone(),
|
||||
status: NodeStatus::Online,
|
||||
missed_heartbeats: 0,
|
||||
last_heartbeat: chrono::Utc::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update erasure sets
|
||||
inner.erasure_sets = topology
|
||||
.erasure_sets
|
||||
.iter()
|
||||
.map(|set| ErasureSet {
|
||||
set_id: set.set_id,
|
||||
drives: set
|
||||
.drives
|
||||
.iter()
|
||||
.map(|d| DriveLocation {
|
||||
node_id: d.node_id.clone(),
|
||||
drive_index: d.drive_index,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
88
rust/src/config.rs
Normal file
88
rust/src/config.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cluster::config::ClusterConfig;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SmartStorageConfig {
|
||||
pub server: ServerConfig,
|
||||
pub storage: StorageConfig,
|
||||
pub auth: AuthConfig,
|
||||
pub cors: CorsConfig,
|
||||
pub logging: LoggingConfig,
|
||||
pub limits: LimitsConfig,
|
||||
pub multipart: MultipartConfig,
|
||||
#[serde(default)]
|
||||
pub cluster: Option<ClusterConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerConfig {
|
||||
pub port: u16,
|
||||
pub address: String,
|
||||
pub silent: bool,
|
||||
#[serde(default = "default_region")]
|
||||
pub region: String,
|
||||
}
|
||||
|
||||
fn default_region() -> String {
|
||||
"us-east-1".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StorageConfig {
|
||||
pub directory: String,
|
||||
pub clean_slate: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AuthConfig {
|
||||
pub enabled: bool,
|
||||
pub credentials: Vec<Credential>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Credential {
|
||||
#[serde(rename = "accessKeyId")]
|
||||
pub access_key_id: String,
|
||||
#[serde(rename = "secretAccessKey")]
|
||||
pub secret_access_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CorsConfig {
|
||||
pub enabled: bool,
|
||||
pub allowed_origins: Option<Vec<String>>,
|
||||
pub allowed_methods: Option<Vec<String>>,
|
||||
pub allowed_headers: Option<Vec<String>>,
|
||||
pub exposed_headers: Option<Vec<String>>,
|
||||
pub max_age: Option<u64>,
|
||||
pub allow_credentials: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoggingConfig {
|
||||
pub level: Option<String>,
|
||||
pub format: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LimitsConfig {
|
||||
pub max_object_size: Option<u64>,
|
||||
pub max_metadata_size: Option<u64>,
|
||||
pub request_timeout: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MultipartConfig {
|
||||
pub expiration_days: Option<u64>,
|
||||
pub cleanup_interval_minutes: Option<u64>,
|
||||
}
|
||||
106
rust/src/error.rs
Normal file
106
rust/src/error.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use hyper::StatusCode;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("StorageError({code}): {message}")]
|
||||
pub struct StorageError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
pub status: StatusCode,
|
||||
}
|
||||
|
||||
impl StorageError {
|
||||
pub fn new(code: &str, message: &str, status: StatusCode) -> Self {
|
||||
Self {
|
||||
code: code.to_string(),
|
||||
message: message.to_string(),
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn no_such_key() -> Self {
|
||||
Self::new("NoSuchKey", "The specified key does not exist.", StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
pub fn no_such_bucket() -> Self {
|
||||
Self::new("NoSuchBucket", "The specified bucket does not exist", StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
pub fn bucket_not_empty() -> Self {
|
||||
Self::new("BucketNotEmpty", "The bucket you tried to delete is not empty", StatusCode::CONFLICT)
|
||||
}
|
||||
|
||||
pub fn access_denied() -> Self {
|
||||
Self::new("AccessDenied", "Access Denied", StatusCode::FORBIDDEN)
|
||||
}
|
||||
|
||||
pub fn no_such_upload() -> Self {
|
||||
Self::new("NoSuchUpload", "The specified upload does not exist", StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
pub fn invalid_part_number() -> Self {
|
||||
Self::new("InvalidPartNumber", "Part number must be between 1 and 10000", StatusCode::BAD_REQUEST)
|
||||
}
|
||||
|
||||
pub fn internal_error(msg: &str) -> Self {
|
||||
Self::new("InternalError", msg, StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
pub fn invalid_request(msg: &str) -> Self {
|
||||
Self::new("InvalidRequest", msg, StatusCode::BAD_REQUEST)
|
||||
}
|
||||
|
||||
pub fn signature_does_not_match() -> Self {
|
||||
Self::new(
|
||||
"SignatureDoesNotMatch",
|
||||
"The request signature we calculated does not match the signature you provided.",
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn invalid_access_key_id() -> Self {
|
||||
Self::new(
|
||||
"InvalidAccessKeyId",
|
||||
"The AWS Access Key Id you provided does not exist in our records.",
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn request_time_too_skewed() -> Self {
|
||||
Self::new(
|
||||
"RequestTimeTooSkewed",
|
||||
"The difference between the request time and the current time is too large.",
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn authorization_header_malformed() -> Self {
|
||||
Self::new(
|
||||
"AuthorizationHeaderMalformed",
|
||||
"The authorization header is malformed.",
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn missing_security_header(msg: &str) -> Self {
|
||||
Self::new("MissingSecurityHeader", msg, StatusCode::BAD_REQUEST)
|
||||
}
|
||||
|
||||
pub fn no_such_bucket_policy() -> Self {
|
||||
Self::new(
|
||||
"NoSuchBucketPolicy",
|
||||
"The bucket policy does not exist.",
|
||||
StatusCode::NOT_FOUND,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn malformed_policy(msg: &str) -> Self {
|
||||
Self::new("MalformedPolicy", msg, StatusCode::BAD_REQUEST)
|
||||
}
|
||||
|
||||
pub fn to_xml(&self) -> String {
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Error><Code>{}</Code><Message>{}</Message></Error>",
|
||||
self.code, self.message
|
||||
)
|
||||
}
|
||||
}
|
||||
47
rust/src/main.rs
Normal file
47
rust/src/main.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
mod action;
|
||||
mod auth;
|
||||
mod cluster;
|
||||
mod config;
|
||||
mod management;
|
||||
mod policy;
|
||||
mod error;
|
||||
mod server;
|
||||
mod storage;
|
||||
mod xml_response;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "ruststorage", about = "High-performance S3-compatible storage server")]
|
||||
struct Cli {
|
||||
/// Run in management mode (IPC via stdin/stdout)
|
||||
#[arg(long)]
|
||||
management: bool,
|
||||
|
||||
/// Log level
|
||||
#[arg(long, default_value = "info")]
|
||||
log_level: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
if cli.management {
|
||||
// Init tracing to stderr only (stdout reserved for IPC)
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_new(&cli.log_level)
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
management::management_loop().await?;
|
||||
} else {
|
||||
eprintln!("ruststorage: use --management flag for IPC mode");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
164
rust/src/management.rs
Normal file
164
rust/src/management.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::io::Write;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
use crate::config::SmartStorageConfig;
|
||||
use crate::server::StorageServer;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct IpcRequest {
|
||||
id: String,
|
||||
method: String,
|
||||
params: Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct IpcResponse {
|
||||
id: String,
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct IpcEvent {
|
||||
event: String,
|
||||
data: Value,
|
||||
}
|
||||
|
||||
fn send_line(value: &impl Serialize) {
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
serde_json::to_writer(&mut stdout, value).ok();
|
||||
stdout.write_all(b"\n").ok();
|
||||
stdout.flush().ok();
|
||||
}
|
||||
|
||||
fn send_response(id: String, result: Value) {
|
||||
send_line(&IpcResponse {
|
||||
id,
|
||||
success: true,
|
||||
result: Some(result),
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
fn send_error(id: String, message: String) {
|
||||
send_line(&IpcResponse {
|
||||
id,
|
||||
success: false,
|
||||
result: None,
|
||||
error: Some(message),
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn management_loop() -> Result<()> {
|
||||
// Emit ready event
|
||||
send_line(&IpcEvent {
|
||||
event: "ready".to_string(),
|
||||
data: serde_json::json!({}),
|
||||
});
|
||||
|
||||
let mut server: Option<StorageServer> = None;
|
||||
let stdin = BufReader::new(tokio::io::stdin());
|
||||
let mut lines = stdin.lines();
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let line = line.trim().to_string();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let req: IpcRequest = match serde_json::from_str(&line) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("Invalid IPC request: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let id = req.id.clone();
|
||||
let method = req.method.as_str();
|
||||
|
||||
match method {
|
||||
"start" => {
|
||||
#[derive(Deserialize)]
|
||||
struct StartParams {
|
||||
config: SmartStorageConfig,
|
||||
}
|
||||
match serde_json::from_value::<StartParams>(req.params) {
|
||||
Ok(params) => {
|
||||
match StorageServer::start(params.config).await {
|
||||
Ok(s) => {
|
||||
server = Some(s);
|
||||
send_response(id, serde_json::json!({}));
|
||||
}
|
||||
Err(e) => {
|
||||
send_error(id, format!("Failed to start server: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
send_error(id, format!("Invalid start params: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
"stop" => {
|
||||
if let Some(s) = server.take() {
|
||||
s.stop().await;
|
||||
}
|
||||
send_response(id, serde_json::json!({}));
|
||||
}
|
||||
"createBucket" => {
|
||||
#[derive(Deserialize)]
|
||||
struct CreateBucketParams {
|
||||
name: String,
|
||||
}
|
||||
match serde_json::from_value::<CreateBucketParams>(req.params) {
|
||||
Ok(params) => {
|
||||
if let Some(ref s) = server {
|
||||
match s.store().create_bucket(¶ms.name).await {
|
||||
Ok(()) => {
|
||||
send_response(id, serde_json::json!({}));
|
||||
}
|
||||
Err(e) => {
|
||||
send_error(
|
||||
id,
|
||||
format!("Failed to create bucket: {}", e),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
send_error(id, "Server not started".to_string());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
send_error(id, format!("Invalid createBucket params: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
"clusterStatus" => {
|
||||
send_response(
|
||||
id,
|
||||
serde_json::json!({
|
||||
"status": "ok",
|
||||
"message": "Cluster status endpoint ready"
|
||||
}),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
send_error(id, format!("Unknown method: {}", method));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean shutdown
|
||||
if let Some(s) = server.take() {
|
||||
s.stop().await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
429
rust/src/policy.rs
Normal file
429
rust/src/policy.rs
Normal file
@@ -0,0 +1,429 @@
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::action::RequestContext;
|
||||
use crate::auth::AuthenticatedIdentity;
|
||||
use crate::error::StorageError;
|
||||
|
||||
// ============================
|
||||
// Policy data model
|
||||
// ============================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BucketPolicy {
|
||||
#[serde(rename = "Version")]
|
||||
pub version: String,
|
||||
#[serde(rename = "Statement")]
|
||||
pub statements: Vec<PolicyStatement>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PolicyStatement {
|
||||
#[serde(rename = "Sid", default, skip_serializing_if = "Option::is_none")]
|
||||
pub sid: Option<String>,
|
||||
#[serde(rename = "Effect")]
|
||||
pub effect: PolicyEffect,
|
||||
#[serde(rename = "Principal", deserialize_with = "deserialize_principal")]
|
||||
pub principal: Principal,
|
||||
#[serde(rename = "Action", deserialize_with = "deserialize_string_or_vec")]
|
||||
pub action: Vec<String>,
|
||||
#[serde(rename = "Resource", deserialize_with = "deserialize_string_or_vec")]
|
||||
pub resource: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum PolicyEffect {
|
||||
Allow,
|
||||
Deny,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Principal {
|
||||
Wildcard,
|
||||
Aws(Vec<String>),
|
||||
}
|
||||
|
||||
impl Serialize for Principal {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
match self {
|
||||
Principal::Wildcard => serializer.serialize_str("*"),
|
||||
Principal::Aws(ids) => {
|
||||
use serde::ser::SerializeMap;
|
||||
let mut map = serializer.serialize_map(Some(1))?;
|
||||
if ids.len() == 1 {
|
||||
map.serialize_entry("AWS", &ids[0])?;
|
||||
} else {
|
||||
map.serialize_entry("AWS", ids)?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_principal<'de, D>(deserializer: D) -> Result<Principal, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum PrincipalRaw {
|
||||
Star(String),
|
||||
Map(HashMap<String, StringOrVec>),
|
||||
}
|
||||
|
||||
let raw = PrincipalRaw::deserialize(deserializer)?;
|
||||
match raw {
|
||||
PrincipalRaw::Star(s) if s == "*" => Ok(Principal::Wildcard),
|
||||
PrincipalRaw::Star(_) => Err(serde::de::Error::custom(
|
||||
"Principal string must be \"*\"",
|
||||
)),
|
||||
PrincipalRaw::Map(map) => {
|
||||
if let Some(aws) = map.get("AWS") {
|
||||
Ok(Principal::Aws(aws.clone().into_vec()))
|
||||
} else {
|
||||
Err(serde::de::Error::custom("Principal map must contain \"AWS\" key"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum StringOrVec {
|
||||
Single(String),
|
||||
Multiple(Vec<String>),
|
||||
}
|
||||
|
||||
impl StringOrVec {
|
||||
fn into_vec(self) -> Vec<String> {
|
||||
match self {
|
||||
StringOrVec::Single(s) => vec![s],
|
||||
StringOrVec::Multiple(v) => v,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_string_or_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let raw = StringOrVec::deserialize(deserializer)?;
|
||||
Ok(raw.into_vec())
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Policy evaluation
|
||||
// ============================
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PolicyDecision {
|
||||
Allow,
|
||||
Deny,
|
||||
NoOpinion,
|
||||
}
|
||||
|
||||
/// Evaluate a bucket policy against a request context and caller identity.
|
||||
pub fn evaluate_policy(
|
||||
policy: &BucketPolicy,
|
||||
ctx: &RequestContext,
|
||||
identity: Option<&AuthenticatedIdentity>,
|
||||
) -> PolicyDecision {
|
||||
let resource_arn = ctx.resource_arn();
|
||||
let iam_action = ctx.action.iam_action();
|
||||
let mut has_allow = false;
|
||||
|
||||
for stmt in &policy.statements {
|
||||
// Check principal match
|
||||
if !principal_matches(&stmt.principal, identity) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check action match
|
||||
if !action_matches(&stmt.action, iam_action) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check resource match
|
||||
if !resource_matches(&stmt.resource, &resource_arn, ctx.bucket.as_deref()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Statement matches — apply effect
|
||||
match stmt.effect {
|
||||
PolicyEffect::Deny => return PolicyDecision::Deny,
|
||||
PolicyEffect::Allow => has_allow = true,
|
||||
}
|
||||
}
|
||||
|
||||
if has_allow {
|
||||
PolicyDecision::Allow
|
||||
} else {
|
||||
PolicyDecision::NoOpinion
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the principal matches the caller.
|
||||
fn principal_matches(principal: &Principal, identity: Option<&AuthenticatedIdentity>) -> bool {
|
||||
match principal {
|
||||
Principal::Wildcard => true,
|
||||
Principal::Aws(ids) => {
|
||||
if let Some(id) = identity {
|
||||
ids.iter().any(|arn| {
|
||||
// Match against full ARN or just the access key ID
|
||||
arn == "*" || arn.ends_with(&id.access_key_id)
|
||||
})
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the action matches. Supports wildcard `s3:*` and `*`.
|
||||
fn action_matches(policy_actions: &[String], request_action: &str) -> bool {
|
||||
for pa in policy_actions {
|
||||
if pa == "*" || pa == "s3:*" {
|
||||
return true;
|
||||
}
|
||||
if pa.eq_ignore_ascii_case(request_action) {
|
||||
return true;
|
||||
}
|
||||
// Simple prefix wildcard: "s3:Get*" matches "s3:GetObject"
|
||||
if let Some(prefix) = pa.strip_suffix('*') {
|
||||
if request_action
|
||||
.to_lowercase()
|
||||
.starts_with(&prefix.to_lowercase())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if the resource matches. Supports wildcard patterns.
|
||||
fn resource_matches(policy_resources: &[String], request_arn: &str, bucket: Option<&str>) -> bool {
|
||||
for pr in policy_resources {
|
||||
if pr == "*" {
|
||||
return true;
|
||||
}
|
||||
if arn_pattern_matches(pr, request_arn) {
|
||||
return true;
|
||||
}
|
||||
// Also check bucket-level ARN if the request is for an object
|
||||
if let Some(b) = bucket {
|
||||
let bucket_arn = format!("arn:aws:s3:::{}", b);
|
||||
if arn_pattern_matches(pr, &bucket_arn) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Simple ARN pattern matching with `*` and `?` wildcards.
|
||||
fn arn_pattern_matches(pattern: &str, value: &str) -> bool {
|
||||
// Handle trailing /* specifically: arn:aws:s3:::bucket/* matches arn:aws:s3:::bucket/anything
|
||||
if pattern.ends_with("/*") {
|
||||
let prefix = &pattern[..pattern.len() - 1]; // Remove trailing *
|
||||
if value.starts_with(prefix) {
|
||||
return true;
|
||||
}
|
||||
// Also match exact bucket without trailing /
|
||||
let bucket_only = &pattern[..pattern.len() - 2];
|
||||
if value == bucket_only {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
simple_wildcard_match(pattern, value)
|
||||
}
|
||||
|
||||
fn simple_wildcard_match(pattern: &str, value: &str) -> bool {
|
||||
let pat_bytes = pattern.as_bytes();
|
||||
let val_bytes = value.as_bytes();
|
||||
let mut pi = 0;
|
||||
let mut vi = 0;
|
||||
let mut star_pi = usize::MAX;
|
||||
let mut star_vi = 0;
|
||||
|
||||
while vi < val_bytes.len() {
|
||||
if pi < pat_bytes.len() && (pat_bytes[pi] == b'?' || pat_bytes[pi] == val_bytes[vi]) {
|
||||
pi += 1;
|
||||
vi += 1;
|
||||
} else if pi < pat_bytes.len() && pat_bytes[pi] == b'*' {
|
||||
star_pi = pi;
|
||||
star_vi = vi;
|
||||
pi += 1;
|
||||
} else if star_pi != usize::MAX {
|
||||
pi = star_pi + 1;
|
||||
star_vi += 1;
|
||||
vi = star_vi;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
while pi < pat_bytes.len() && pat_bytes[pi] == b'*' {
|
||||
pi += 1;
|
||||
}
|
||||
|
||||
pi == pat_bytes.len()
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Policy validation
|
||||
// ============================
|
||||
|
||||
const MAX_POLICY_SIZE: usize = 20 * 1024; // 20 KB
|
||||
|
||||
pub fn validate_policy(json: &str) -> Result<BucketPolicy, StorageError> {
|
||||
if json.len() > MAX_POLICY_SIZE {
|
||||
return Err(StorageError::malformed_policy("Policy exceeds maximum size of 20KB"));
|
||||
}
|
||||
|
||||
let policy: BucketPolicy =
|
||||
serde_json::from_str(json).map_err(|e| StorageError::malformed_policy(&e.to_string()))?;
|
||||
|
||||
if policy.version != "2012-10-17" {
|
||||
return Err(StorageError::malformed_policy(
|
||||
"Policy version must be \"2012-10-17\"",
|
||||
));
|
||||
}
|
||||
|
||||
if policy.statements.is_empty() {
|
||||
return Err(StorageError::malformed_policy(
|
||||
"Policy must contain at least one statement",
|
||||
));
|
||||
}
|
||||
|
||||
for (i, stmt) in policy.statements.iter().enumerate() {
|
||||
if stmt.action.is_empty() {
|
||||
return Err(StorageError::malformed_policy(&format!(
|
||||
"Statement {} has no actions",
|
||||
i
|
||||
)));
|
||||
}
|
||||
for action in &stmt.action {
|
||||
if action != "*" && !action.starts_with("s3:") {
|
||||
return Err(StorageError::malformed_policy(&format!(
|
||||
"Action \"{}\" must start with \"s3:\"",
|
||||
action
|
||||
)));
|
||||
}
|
||||
}
|
||||
if stmt.resource.is_empty() {
|
||||
return Err(StorageError::malformed_policy(&format!(
|
||||
"Statement {} has no resources",
|
||||
i
|
||||
)));
|
||||
}
|
||||
for resource in &stmt.resource {
|
||||
if resource != "*" && !resource.starts_with("arn:aws:s3:::") {
|
||||
return Err(StorageError::malformed_policy(&format!(
|
||||
"Resource \"{}\" must start with \"arn:aws:s3:::\"",
|
||||
resource
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
// ============================
|
||||
// PolicyStore — in-memory cache + disk
|
||||
// ============================
|
||||
|
||||
pub struct PolicyStore {
|
||||
policies: RwLock<HashMap<String, BucketPolicy>>,
|
||||
policies_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl PolicyStore {
|
||||
pub fn new(policies_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
policies: RwLock::new(HashMap::new()),
|
||||
policies_dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all policies from disk into cache.
|
||||
pub async fn load_from_disk(&self) -> anyhow::Result<()> {
|
||||
let dir = &self.policies_dir;
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut entries = fs::read_dir(dir).await?;
|
||||
let mut policies = HashMap::new();
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if let Some(bucket) = name.strip_suffix(".policy.json") {
|
||||
match fs::read_to_string(entry.path()).await {
|
||||
Ok(json) => match serde_json::from_str::<BucketPolicy>(&json) {
|
||||
Ok(policy) => {
|
||||
tracing::info!("Loaded policy for bucket: {}", bucket);
|
||||
policies.insert(bucket.to_string(), policy);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to parse policy for {}: {}", bucket, e);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to read policy file {}: {}", name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut cache = self.policies.write().await;
|
||||
*cache = policies;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a policy for a bucket.
|
||||
pub async fn get_policy(&self, bucket: &str) -> Option<BucketPolicy> {
|
||||
let cache = self.policies.read().await;
|
||||
cache.get(bucket).cloned()
|
||||
}
|
||||
|
||||
/// Store a policy for a bucket (atomic write + cache update).
|
||||
pub async fn put_policy(&self, bucket: &str, policy: BucketPolicy) -> anyhow::Result<()> {
|
||||
let json = serde_json::to_string_pretty(&policy)?;
|
||||
|
||||
// Atomic write: temp file + rename
|
||||
let policy_path = self.policies_dir.join(format!("{}.policy.json", bucket));
|
||||
let temp_path = self
|
||||
.policies_dir
|
||||
.join(format!("{}.policy.json.tmp", bucket));
|
||||
|
||||
fs::write(&temp_path, &json).await?;
|
||||
fs::rename(&temp_path, &policy_path).await?;
|
||||
|
||||
// Update cache
|
||||
let mut cache = self.policies.write().await;
|
||||
cache.insert(bucket.to_string(), policy);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a policy for a bucket.
|
||||
pub async fn delete_policy(&self, bucket: &str) -> anyhow::Result<()> {
|
||||
let policy_path = self.policies_dir.join(format!("{}.policy.json", bucket));
|
||||
let _ = fs::remove_file(&policy_path).await;
|
||||
|
||||
let mut cache = self.policies.write().await;
|
||||
cache.remove(bucket);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
1125
rust/src/server.rs
Normal file
1125
rust/src/server.rs
Normal file
File diff suppressed because it is too large
Load Diff
1033
rust/src/storage.rs
Normal file
1033
rust/src/storage.rs
Normal file
File diff suppressed because it is too large
Load Diff
211
rust/src/xml_response.rs
Normal file
211
rust/src/xml_response.rs
Normal file
@@ -0,0 +1,211 @@
|
||||
use crate::storage::{BucketInfo, ListObjectsResult, MultipartUploadInfo};
|
||||
|
||||
const XML_DECL: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>";
|
||||
const STORAGE_NS: &str = "http://s3.amazonaws.com/doc/2006-03-01/";
|
||||
|
||||
fn xml_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
pub fn list_buckets_xml(buckets: &[BucketInfo]) -> String {
|
||||
let mut xml = format!(
|
||||
"{}\n<ListAllMyBucketsResult xmlns=\"{}\">\
|
||||
<Owner><ID>123456789000</ID><DisplayName>Storage</DisplayName></Owner>\
|
||||
<Buckets>",
|
||||
XML_DECL, STORAGE_NS
|
||||
);
|
||||
|
||||
for b in buckets {
|
||||
xml.push_str(&format!(
|
||||
"<Bucket><Name>{}</Name><CreationDate>{}</CreationDate></Bucket>",
|
||||
xml_escape(&b.name),
|
||||
b.creation_date.to_rfc3339()
|
||||
));
|
||||
}
|
||||
|
||||
xml.push_str("</Buckets></ListAllMyBucketsResult>");
|
||||
xml
|
||||
}
|
||||
|
||||
pub fn list_objects_v1_xml(bucket: &str, result: &ListObjectsResult) -> String {
|
||||
let mut xml = format!(
|
||||
"{}\n<ListBucketResult xmlns=\"{}\">\
|
||||
<Name>{}</Name>\
|
||||
<Prefix>{}</Prefix>\
|
||||
<MaxKeys>{}</MaxKeys>\
|
||||
<IsTruncated>{}</IsTruncated>",
|
||||
XML_DECL,
|
||||
STORAGE_NS,
|
||||
xml_escape(bucket),
|
||||
xml_escape(&result.prefix),
|
||||
result.max_keys,
|
||||
result.is_truncated
|
||||
);
|
||||
|
||||
if !result.delimiter.is_empty() {
|
||||
xml.push_str(&format!("<Delimiter>{}</Delimiter>", xml_escape(&result.delimiter)));
|
||||
}
|
||||
|
||||
for entry in &result.contents {
|
||||
xml.push_str(&format!(
|
||||
"<Contents>\
|
||||
<Key>{}</Key>\
|
||||
<LastModified>{}</LastModified>\
|
||||
<ETag>\"{}\"</ETag>\
|
||||
<Size>{}</Size>\
|
||||
<StorageClass>STANDARD</StorageClass>\
|
||||
</Contents>",
|
||||
xml_escape(&entry.key),
|
||||
entry.last_modified.to_rfc3339(),
|
||||
xml_escape(&entry.md5),
|
||||
entry.size
|
||||
));
|
||||
}
|
||||
|
||||
for cp in &result.common_prefixes {
|
||||
xml.push_str(&format!(
|
||||
"<CommonPrefixes><Prefix>{}</Prefix></CommonPrefixes>",
|
||||
xml_escape(cp)
|
||||
));
|
||||
}
|
||||
|
||||
xml.push_str("</ListBucketResult>");
|
||||
xml
|
||||
}
|
||||
|
||||
pub fn list_objects_v2_xml(bucket: &str, result: &ListObjectsResult) -> String {
|
||||
let mut xml = format!(
|
||||
"{}\n<ListBucketResult xmlns=\"{}\">\
|
||||
<Name>{}</Name>\
|
||||
<Prefix>{}</Prefix>\
|
||||
<MaxKeys>{}</MaxKeys>\
|
||||
<KeyCount>{}</KeyCount>\
|
||||
<IsTruncated>{}</IsTruncated>",
|
||||
XML_DECL,
|
||||
STORAGE_NS,
|
||||
xml_escape(bucket),
|
||||
xml_escape(&result.prefix),
|
||||
result.max_keys,
|
||||
result.contents.len(),
|
||||
result.is_truncated
|
||||
);
|
||||
|
||||
if !result.delimiter.is_empty() {
|
||||
xml.push_str(&format!("<Delimiter>{}</Delimiter>", xml_escape(&result.delimiter)));
|
||||
}
|
||||
|
||||
if let Some(ref token) = result.next_continuation_token {
|
||||
xml.push_str(&format!(
|
||||
"<NextContinuationToken>{}</NextContinuationToken>",
|
||||
xml_escape(token)
|
||||
));
|
||||
}
|
||||
|
||||
for entry in &result.contents {
|
||||
xml.push_str(&format!(
|
||||
"<Contents>\
|
||||
<Key>{}</Key>\
|
||||
<LastModified>{}</LastModified>\
|
||||
<ETag>\"{}\"</ETag>\
|
||||
<Size>{}</Size>\
|
||||
<StorageClass>STANDARD</StorageClass>\
|
||||
</Contents>",
|
||||
xml_escape(&entry.key),
|
||||
entry.last_modified.to_rfc3339(),
|
||||
xml_escape(&entry.md5),
|
||||
entry.size
|
||||
));
|
||||
}
|
||||
|
||||
for cp in &result.common_prefixes {
|
||||
xml.push_str(&format!(
|
||||
"<CommonPrefixes><Prefix>{}</Prefix></CommonPrefixes>",
|
||||
xml_escape(cp)
|
||||
));
|
||||
}
|
||||
|
||||
xml.push_str("</ListBucketResult>");
|
||||
xml
|
||||
}
|
||||
|
||||
pub fn copy_object_result_xml(etag: &str, last_modified: &str) -> String {
|
||||
format!(
|
||||
"{}\n<CopyObjectResult>\
|
||||
<LastModified>{}</LastModified>\
|
||||
<ETag>\"{}\"</ETag>\
|
||||
</CopyObjectResult>",
|
||||
XML_DECL,
|
||||
xml_escape(last_modified),
|
||||
xml_escape(etag)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn initiate_multipart_xml(bucket: &str, key: &str, upload_id: &str) -> String {
|
||||
format!(
|
||||
"{}\n<InitiateMultipartUploadResult xmlns=\"{}\">\
|
||||
<Bucket>{}</Bucket>\
|
||||
<Key>{}</Key>\
|
||||
<UploadId>{}</UploadId>\
|
||||
</InitiateMultipartUploadResult>",
|
||||
XML_DECL,
|
||||
STORAGE_NS,
|
||||
xml_escape(bucket),
|
||||
xml_escape(key),
|
||||
xml_escape(upload_id)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn complete_multipart_xml(bucket: &str, key: &str, etag: &str) -> String {
|
||||
format!(
|
||||
"{}\n<CompleteMultipartUploadResult xmlns=\"{}\">\
|
||||
<Location>/{}/{}</Location>\
|
||||
<Bucket>{}</Bucket>\
|
||||
<Key>{}</Key>\
|
||||
<ETag>\"{}\"</ETag>\
|
||||
</CompleteMultipartUploadResult>",
|
||||
XML_DECL,
|
||||
STORAGE_NS,
|
||||
xml_escape(bucket),
|
||||
xml_escape(key),
|
||||
xml_escape(bucket),
|
||||
xml_escape(key),
|
||||
xml_escape(etag)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_multipart_uploads_xml(bucket: &str, uploads: &[MultipartUploadInfo]) -> String {
|
||||
let mut xml = format!(
|
||||
"{}\n<ListMultipartUploadsResult xmlns=\"{}\">\
|
||||
<Bucket>{}</Bucket>\
|
||||
<KeyMarker></KeyMarker>\
|
||||
<UploadIdMarker></UploadIdMarker>\
|
||||
<MaxUploads>1000</MaxUploads>\
|
||||
<IsTruncated>false</IsTruncated>",
|
||||
XML_DECL,
|
||||
STORAGE_NS,
|
||||
xml_escape(bucket)
|
||||
);
|
||||
|
||||
for u in uploads {
|
||||
xml.push_str(&format!(
|
||||
"<Upload>\
|
||||
<Key>{}</Key>\
|
||||
<UploadId>{}</UploadId>\
|
||||
<Initiator><ID>STORAGE</ID><DisplayName>STORAGE</DisplayName></Initiator>\
|
||||
<Owner><ID>STORAGE</ID><DisplayName>STORAGE</DisplayName></Owner>\
|
||||
<StorageClass>STANDARD</StorageClass>\
|
||||
<Initiated>{}</Initiated>\
|
||||
</Upload>",
|
||||
xml_escape(&u.key),
|
||||
xml_escape(&u.upload_id),
|
||||
u.initiated.to_rfc3339()
|
||||
));
|
||||
}
|
||||
|
||||
xml.push_str("</ListMultipartUploadsResult>");
|
||||
xml
|
||||
}
|
||||
301
test/test.auth.node.ts
Normal file
301
test/test.auth.node.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
S3Client,
|
||||
CreateBucketCommand,
|
||||
ListBucketsCommand,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
DeleteBucketCommand,
|
||||
PutBucketPolicyCommand,
|
||||
GetBucketPolicyCommand,
|
||||
DeleteBucketPolicyCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { Readable } from 'stream';
|
||||
import * as smartstorage from '../ts/index.js';
|
||||
|
||||
let testSmartStorageInstance: smartstorage.SmartStorage;
|
||||
let authClient: S3Client;
|
||||
let wrongClient: S3Client;
|
||||
|
||||
const TEST_PORT = 3344;
|
||||
const ACCESS_KEY = 'TESTAKID';
|
||||
const SECRET_KEY = 'TESTSECRETKEY123';
|
||||
|
||||
async function streamToString(stream: Readable): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
});
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Server setup
|
||||
// ============================
|
||||
|
||||
tap.test('should start storage server with auth enabled', async () => {
|
||||
testSmartStorageInstance = await smartstorage.SmartStorage.createAndStart({
|
||||
server: {
|
||||
port: TEST_PORT,
|
||||
silent: true,
|
||||
region: 'us-east-1',
|
||||
},
|
||||
storage: {
|
||||
cleanSlate: true,
|
||||
},
|
||||
auth: {
|
||||
enabled: true,
|
||||
credentials: [
|
||||
{
|
||||
accessKeyId: ACCESS_KEY,
|
||||
secretAccessKey: SECRET_KEY,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Authenticated client with correct credentials
|
||||
authClient = new S3Client({
|
||||
endpoint: `http://localhost:${TEST_PORT}`,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: ACCESS_KEY,
|
||||
secretAccessKey: SECRET_KEY,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
// Client with wrong credentials
|
||||
wrongClient = new S3Client({
|
||||
endpoint: `http://localhost:${TEST_PORT}`,
|
||||
region: 'us-east-1',
|
||||
credentials: {
|
||||
accessKeyId: 'WRONGKEY',
|
||||
secretAccessKey: 'WRONGSECRET',
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Authenticated CRUD
|
||||
// ============================
|
||||
|
||||
tap.test('authenticated: should list buckets', async () => {
|
||||
const response = await authClient.send(new ListBucketsCommand({}));
|
||||
expect(response.$metadata.httpStatusCode).toEqual(200);
|
||||
expect(Array.isArray(response.Buckets)).toEqual(true);
|
||||
});
|
||||
|
||||
tap.test('authenticated: should create a bucket', async () => {
|
||||
const response = await authClient.send(new CreateBucketCommand({ Bucket: 'auth-test-bucket' }));
|
||||
expect(response.$metadata.httpStatusCode).toEqual(200);
|
||||
});
|
||||
|
||||
tap.test('authenticated: should upload an object', async () => {
|
||||
const response = await authClient.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
Key: 'hello.txt',
|
||||
Body: 'Hello authenticated world!',
|
||||
ContentType: 'text/plain',
|
||||
}),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(200);
|
||||
});
|
||||
|
||||
tap.test('authenticated: should download the object', async () => {
|
||||
const response = await authClient.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
Key: 'hello.txt',
|
||||
}),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(200);
|
||||
const content = await streamToString(response.Body as Readable);
|
||||
expect(content).toEqual('Hello authenticated world!');
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Wrong credentials → 403
|
||||
// ============================
|
||||
|
||||
tap.test('wrong credentials: should fail to list buckets', async () => {
|
||||
await expect(wrongClient.send(new ListBucketsCommand({}))).rejects.toThrow();
|
||||
});
|
||||
|
||||
tap.test('wrong credentials: should fail to get object', async () => {
|
||||
await expect(
|
||||
wrongClient.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
Key: 'hello.txt',
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Anonymous → 403 (no policy yet)
|
||||
// ============================
|
||||
|
||||
tap.test('anonymous: should fail to list buckets', async () => {
|
||||
const resp = await fetch(`http://localhost:${TEST_PORT}/`);
|
||||
expect(resp.status).toEqual(403);
|
||||
});
|
||||
|
||||
tap.test('anonymous: should fail to get object (no policy)', async () => {
|
||||
const resp = await fetch(`http://localhost:${TEST_PORT}/auth-test-bucket/hello.txt`);
|
||||
expect(resp.status).toEqual(403);
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Bucket policy: public read
|
||||
// ============================
|
||||
|
||||
tap.test('should PUT a public-read bucket policy', async () => {
|
||||
const policy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Sid: 'PublicRead',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: ['s3:GetObject'],
|
||||
Resource: [`arn:aws:s3:::auth-test-bucket/*`],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
Policy: JSON.stringify(policy),
|
||||
}),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('should GET the bucket policy', async () => {
|
||||
const response = await authClient.send(
|
||||
new GetBucketPolicyCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
}),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(200);
|
||||
const policy = JSON.parse(response.Policy!);
|
||||
expect(policy.Statement[0].Sid).toEqual('PublicRead');
|
||||
});
|
||||
|
||||
tap.test('anonymous: should GET object after public-read policy', async () => {
|
||||
const resp = await fetch(`http://localhost:${TEST_PORT}/auth-test-bucket/hello.txt`);
|
||||
expect(resp.status).toEqual(200);
|
||||
const content = await resp.text();
|
||||
expect(content).toEqual('Hello authenticated world!');
|
||||
});
|
||||
|
||||
tap.test('anonymous: should still fail to PUT object (policy only allows GET)', async () => {
|
||||
const resp = await fetch(`http://localhost:${TEST_PORT}/auth-test-bucket/anon-file.txt`, {
|
||||
method: 'PUT',
|
||||
body: 'should fail',
|
||||
});
|
||||
expect(resp.status).toEqual(403);
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Deny policy
|
||||
// ============================
|
||||
|
||||
tap.test('should PUT a deny policy that blocks authenticated delete', async () => {
|
||||
const policy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Sid: 'PublicRead',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: ['s3:GetObject'],
|
||||
Resource: [`arn:aws:s3:::auth-test-bucket/*`],
|
||||
},
|
||||
{
|
||||
Sid: 'DenyDelete',
|
||||
Effect: 'Deny',
|
||||
Principal: '*',
|
||||
Action: ['s3:DeleteObject'],
|
||||
Resource: [`arn:aws:s3:::auth-test-bucket/*`],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
Policy: JSON.stringify(policy),
|
||||
}),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('authenticated: should be denied delete by policy', async () => {
|
||||
await expect(
|
||||
authClient.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
Key: 'hello.txt',
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// DELETE bucket policy
|
||||
// ============================
|
||||
|
||||
tap.test('should DELETE the bucket policy', async () => {
|
||||
const response = await authClient.send(
|
||||
new DeleteBucketPolicyCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
}),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('should GET policy → 404 after deletion', async () => {
|
||||
await expect(
|
||||
authClient.send(
|
||||
new GetBucketPolicyCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Cleanup
|
||||
// ============================
|
||||
|
||||
tap.test('authenticated: delete object after policy removed', async () => {
|
||||
const response = await authClient.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: 'auth-test-bucket',
|
||||
Key: 'hello.txt',
|
||||
}),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('authenticated: delete the bucket', async () => {
|
||||
const response = await authClient.send(
|
||||
new DeleteBucketCommand({ Bucket: 'auth-test-bucket' }),
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('should stop the storage server', async () => {
|
||||
await testSmartStorageInstance.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -1,9 +1,9 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { S3Client, CreateBucketCommand, ListBucketsCommand, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, DeleteBucketCommand } from '@aws-sdk/client-s3';
|
||||
import { Readable } from 'stream';
|
||||
import * as smarts3 from '../ts/index.js';
|
||||
import * as smartstorage from '../ts/index.js';
|
||||
|
||||
let testSmarts3Instance: smarts3.Smarts3;
|
||||
let testSmartStorageInstance: smartstorage.SmartStorage;
|
||||
let s3Client: S3Client;
|
||||
|
||||
// Helper to convert stream to string
|
||||
@@ -16,14 +16,18 @@ async function streamToString(stream: Readable): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
tap.test('should start the S3 server and configure client', async () => {
|
||||
testSmarts3Instance = await smarts3.Smarts3.createAndStart({
|
||||
port: 3337,
|
||||
cleanSlate: true,
|
||||
silent: true,
|
||||
tap.test('should start the storage server and configure client', async () => {
|
||||
testSmartStorageInstance = await smartstorage.SmartStorage.createAndStart({
|
||||
server: {
|
||||
port: 3337,
|
||||
silent: true,
|
||||
},
|
||||
storage: {
|
||||
cleanSlate: true,
|
||||
},
|
||||
});
|
||||
|
||||
const descriptor = await testSmarts3Instance.getS3Descriptor();
|
||||
const descriptor = await testSmartStorageInstance.getStorageDescriptor();
|
||||
|
||||
s3Client = new S3Client({
|
||||
endpoint: `http://${descriptor.endpoint}:${descriptor.port}`,
|
||||
@@ -97,8 +101,8 @@ tap.test('should delete the bucket', async () => {
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('should stop the S3 server', async () => {
|
||||
await testSmarts3Instance.stop();
|
||||
tap.test('should stop the storage server', async () => {
|
||||
await testSmartStorageInstance.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
|
||||
335
test/test.policy-actions.node.ts
Normal file
335
test/test.policy-actions.node.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
S3Client,
|
||||
CreateBucketCommand,
|
||||
DeleteBucketCommand,
|
||||
ListBucketsCommand,
|
||||
ListObjectsV2Command,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
CopyObjectCommand,
|
||||
HeadBucketCommand,
|
||||
PutBucketPolicyCommand,
|
||||
GetBucketPolicyCommand,
|
||||
DeleteBucketPolicyCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import * as smartstorage from '../ts/index.js';
|
||||
|
||||
let testSmartStorageInstance: smartstorage.SmartStorage;
|
||||
let authClient: S3Client;
|
||||
|
||||
const TEST_PORT = 3347;
|
||||
const ACCESS_KEY = 'TESTAKID';
|
||||
const SECRET_KEY = 'TESTSECRETKEY123';
|
||||
const BUCKET = 'actions-bucket';
|
||||
const BASE_URL = `http://localhost:${TEST_PORT}`;
|
||||
|
||||
async function putPolicy(statements: any[]) {
|
||||
await authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: JSON.stringify({ Version: '2012-10-17', Statement: statements }),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function clearPolicy() {
|
||||
await authClient.send(new DeleteBucketPolicyCommand({ Bucket: BUCKET }));
|
||||
}
|
||||
|
||||
function denyStatement(action: string) {
|
||||
return {
|
||||
Sid: `Deny_${action.replace(':', '_')}`,
|
||||
Effect: 'Deny' as const,
|
||||
Principal: '*',
|
||||
Action: action,
|
||||
Resource: [
|
||||
`arn:aws:s3:::${BUCKET}`,
|
||||
`arn:aws:s3:::${BUCKET}/*`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Server setup
|
||||
// ============================
|
||||
|
||||
tap.test('setup: start server, create bucket, upload object', async () => {
|
||||
testSmartStorageInstance = await smartstorage.SmartStorage.createAndStart({
|
||||
server: { port: TEST_PORT, silent: true, region: 'us-east-1' },
|
||||
storage: { cleanSlate: true },
|
||||
auth: {
|
||||
enabled: true,
|
||||
credentials: [{ accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY }],
|
||||
},
|
||||
});
|
||||
|
||||
authClient = new S3Client({
|
||||
endpoint: BASE_URL,
|
||||
region: 'us-east-1',
|
||||
credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
await authClient.send(new CreateBucketCommand({ Bucket: BUCKET }));
|
||||
await authClient.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: 'obj.txt',
|
||||
Body: 'test content for actions',
|
||||
ContentType: 'text/plain',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Per-action deny enforcement
|
||||
// ============================
|
||||
|
||||
tap.test('Deny s3:ListBucket → authenticated ListObjects fails', async () => {
|
||||
await putPolicy([denyStatement('s3:ListBucket')]);
|
||||
|
||||
await expect(
|
||||
authClient.send(new ListObjectsV2Command({ Bucket: BUCKET }))
|
||||
).rejects.toThrow();
|
||||
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Deny s3:CreateBucket → authenticated CreateBucket on new bucket fails', async () => {
|
||||
// We need to create a policy on the target bucket, but the target doesn't exist yet.
|
||||
// Instead, we use a different approach: deny on existing bucket and test HeadBucket works
|
||||
// but for CreateBucket, use fetch to target a new bucket name with the deny check.
|
||||
// Actually, CreateBucket has no bucket policy to evaluate against (the bucket doesn't exist yet).
|
||||
// The deny would need to be on the bucket being created.
|
||||
// Since the bucket doesn't exist, there's no policy to load — so CreateBucket can't be denied via policy.
|
||||
// This is expected AWS behavior. Skip this test and note it.
|
||||
|
||||
// Verify CreateBucket still works (no policy can deny it since bucket doesn't exist yet)
|
||||
await authClient.send(new CreateBucketCommand({ Bucket: 'new-test-bucket' }));
|
||||
await authClient.send(new DeleteBucketCommand({ Bucket: 'new-test-bucket' }));
|
||||
});
|
||||
|
||||
tap.test('Deny s3:DeleteBucket → authenticated DeleteBucket fails', async () => {
|
||||
await putPolicy([denyStatement('s3:DeleteBucket')]);
|
||||
|
||||
await expect(
|
||||
authClient.send(new DeleteBucketCommand({ Bucket: BUCKET }))
|
||||
).rejects.toThrow();
|
||||
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Deny s3:GetObject → authenticated GetObject fails', async () => {
|
||||
await putPolicy([denyStatement('s3:GetObject')]);
|
||||
|
||||
await expect(
|
||||
authClient.send(new GetObjectCommand({ Bucket: BUCKET, Key: 'obj.txt' }))
|
||||
).rejects.toThrow();
|
||||
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Deny s3:PutObject → authenticated PutObject fails', async () => {
|
||||
await putPolicy([denyStatement('s3:PutObject')]);
|
||||
|
||||
await expect(
|
||||
authClient.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: 'new-obj.txt',
|
||||
Body: 'should fail',
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Deny s3:DeleteObject → authenticated DeleteObject fails', async () => {
|
||||
await putPolicy([denyStatement('s3:DeleteObject')]);
|
||||
|
||||
await expect(
|
||||
authClient.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'obj.txt' }))
|
||||
).rejects.toThrow();
|
||||
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Deny s3:PutObject → authenticated CopyObject fails (maps to s3:PutObject)', async () => {
|
||||
await putPolicy([denyStatement('s3:PutObject')]);
|
||||
|
||||
await expect(
|
||||
authClient.send(
|
||||
new CopyObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: 'obj-copy.txt',
|
||||
CopySource: `${BUCKET}/obj.txt`,
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Deny s3:GetBucketPolicy → authenticated GetBucketPolicy fails', async () => {
|
||||
// First put a policy that denies GetBucketPolicy
|
||||
// We need to be careful: put the deny policy, then try to get it
|
||||
await putPolicy([denyStatement('s3:GetBucketPolicy')]);
|
||||
|
||||
await expect(
|
||||
authClient.send(new GetBucketPolicyCommand({ Bucket: BUCKET }))
|
||||
).rejects.toThrow();
|
||||
|
||||
// Clear using direct delete (which isn't denied)
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Deny s3:PutBucketPolicy → authenticated PutBucketPolicy fails (for second policy)', async () => {
|
||||
// First put a policy that denies PutBucketPolicy
|
||||
await putPolicy([denyStatement('s3:PutBucketPolicy')]);
|
||||
|
||||
// Now try to put another policy — should fail
|
||||
await expect(
|
||||
authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Sid: 'SomeOtherPolicy',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Deny s3:DeleteBucketPolicy → authenticated DeleteBucketPolicy fails', async () => {
|
||||
await putPolicy([denyStatement('s3:DeleteBucketPolicy')]);
|
||||
|
||||
await expect(
|
||||
authClient.send(new DeleteBucketPolicyCommand({ Bucket: BUCKET }))
|
||||
).rejects.toThrow();
|
||||
|
||||
// We need another way to clean up — use fetch with auth to bypass? No, the deny is on all principals.
|
||||
// Actually, we can't clear the policy via SDK since delete is denied.
|
||||
// The server still denies it. We need to stop and restart or use a different mechanism.
|
||||
// For test cleanup, just stop the server at end and it will be wiped with cleanSlate on next start.
|
||||
});
|
||||
|
||||
tap.test('Recovery: remove deny policy → authenticated operations resume working', async () => {
|
||||
// The previous test left a deny policy on DeleteBucketPolicy.
|
||||
// But we can work around it by stopping/restarting or if the deny is still in place.
|
||||
// Actually, we denied s3:DeleteBucketPolicy but NOT s3:PutBucketPolicy.
|
||||
// So we can overwrite the policy with an empty-ish one, then delete.
|
||||
await authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Sid: 'AllowAll',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:*',
|
||||
Resource: [`arn:aws:s3:::${BUCKET}`, `arn:aws:s3:::${BUCKET}/*`],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// Now all operations should work again
|
||||
const getResp = await authClient.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: 'obj.txt' })
|
||||
);
|
||||
expect(getResp.$metadata.httpStatusCode).toEqual(200);
|
||||
|
||||
const listResp = await authClient.send(
|
||||
new ListObjectsV2Command({ Bucket: BUCKET })
|
||||
);
|
||||
expect(listResp.$metadata.httpStatusCode).toEqual(200);
|
||||
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Special cases
|
||||
// ============================
|
||||
|
||||
tap.test('ListAllMyBuckets always requires auth → anonymous fetch to / returns 403', async () => {
|
||||
const resp = await fetch(`${BASE_URL}/`);
|
||||
expect(resp.status).toEqual(403);
|
||||
});
|
||||
|
||||
tap.test('Auth disabled mode → anonymous full access works', async () => {
|
||||
// Start a second server with auth disabled
|
||||
const noAuthInstance = await smartstorage.SmartStorage.createAndStart({
|
||||
server: { port: 3348, silent: true, region: 'us-east-1' },
|
||||
storage: { cleanSlate: true },
|
||||
auth: { enabled: false, credentials: [] },
|
||||
});
|
||||
|
||||
// Anonymous operations should all work
|
||||
const listResp = await fetch('http://localhost:3348/');
|
||||
expect(listResp.status).toEqual(200);
|
||||
|
||||
// Create bucket via fetch
|
||||
const createResp = await fetch('http://localhost:3348/anon-bucket', { method: 'PUT' });
|
||||
expect(createResp.status).toEqual(200);
|
||||
|
||||
// Put object
|
||||
const putResp = await fetch('http://localhost:3348/anon-bucket/file.txt', {
|
||||
method: 'PUT',
|
||||
body: 'hello anon',
|
||||
});
|
||||
expect(putResp.status).toEqual(200);
|
||||
|
||||
// Get object
|
||||
const getResp = await fetch('http://localhost:3348/anon-bucket/file.txt');
|
||||
expect(getResp.status).toEqual(200);
|
||||
const text = await getResp.text();
|
||||
expect(text).toEqual('hello anon');
|
||||
|
||||
// Delete object
|
||||
const delObjResp = await fetch('http://localhost:3348/anon-bucket/file.txt', { method: 'DELETE' });
|
||||
expect(delObjResp.status).toEqual(204);
|
||||
|
||||
// Delete bucket
|
||||
const delBucketResp = await fetch('http://localhost:3348/anon-bucket', { method: 'DELETE' });
|
||||
expect(delBucketResp.status).toEqual(204);
|
||||
|
||||
await noAuthInstance.stop();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Teardown
|
||||
// ============================
|
||||
|
||||
tap.test('teardown: clean up and stop server', async () => {
|
||||
// Clean up any remaining objects
|
||||
try {
|
||||
await authClient.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'obj.txt' }));
|
||||
} catch {
|
||||
// May already be deleted
|
||||
}
|
||||
try {
|
||||
await authClient.send(new DeleteBucketCommand({ Bucket: BUCKET }));
|
||||
} catch {
|
||||
// May already be deleted
|
||||
}
|
||||
await testSmartStorageInstance.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
252
test/test.policy-crud.node.ts
Normal file
252
test/test.policy-crud.node.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
S3Client,
|
||||
CreateBucketCommand,
|
||||
DeleteBucketCommand,
|
||||
PutBucketPolicyCommand,
|
||||
GetBucketPolicyCommand,
|
||||
DeleteBucketPolicyCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import * as smartstorage from '../ts/index.js';
|
||||
|
||||
let testSmartStorageInstance: smartstorage.SmartStorage;
|
||||
let authClient: S3Client;
|
||||
|
||||
const TEST_PORT = 3345;
|
||||
const ACCESS_KEY = 'TESTAKID';
|
||||
const SECRET_KEY = 'TESTSECRETKEY123';
|
||||
const BUCKET = 'policy-crud-bucket';
|
||||
|
||||
function makePolicy(statements: any[]) {
|
||||
return JSON.stringify({ Version: '2012-10-17', Statement: statements });
|
||||
}
|
||||
|
||||
const validStatement = {
|
||||
Sid: 'Test1',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: ['s3:GetObject'],
|
||||
Resource: [`arn:aws:s3:::${BUCKET}/*`],
|
||||
};
|
||||
|
||||
// ============================
|
||||
// Server setup
|
||||
// ============================
|
||||
|
||||
tap.test('setup: start storage server with auth enabled', async () => {
|
||||
testSmartStorageInstance = await smartstorage.SmartStorage.createAndStart({
|
||||
server: { port: TEST_PORT, silent: true, region: 'us-east-1' },
|
||||
storage: { cleanSlate: true },
|
||||
auth: {
|
||||
enabled: true,
|
||||
credentials: [{ accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY }],
|
||||
},
|
||||
});
|
||||
|
||||
authClient = new S3Client({
|
||||
endpoint: `http://localhost:${TEST_PORT}`,
|
||||
region: 'us-east-1',
|
||||
credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('setup: create bucket', async () => {
|
||||
await authClient.send(new CreateBucketCommand({ Bucket: BUCKET }));
|
||||
});
|
||||
|
||||
// ============================
|
||||
// CRUD tests
|
||||
// ============================
|
||||
|
||||
tap.test('GET policy on bucket with no policy → throws (NoSuchBucketPolicy)', async () => {
|
||||
await expect(
|
||||
authClient.send(new GetBucketPolicyCommand({ Bucket: BUCKET }))
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
tap.test('PUT valid policy → 204', async () => {
|
||||
const response = await authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: makePolicy([validStatement]),
|
||||
})
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('GET policy back → returns matching JSON', async () => {
|
||||
const response = await authClient.send(
|
||||
new GetBucketPolicyCommand({ Bucket: BUCKET })
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(200);
|
||||
const policy = JSON.parse(response.Policy!);
|
||||
expect(policy.Version).toEqual('2012-10-17');
|
||||
expect(policy.Statement[0].Sid).toEqual('Test1');
|
||||
expect(policy.Statement[0].Effect).toEqual('Allow');
|
||||
});
|
||||
|
||||
tap.test('PUT updated policy (overwrite) → 204, GET returns new version', async () => {
|
||||
const updatedStatement = {
|
||||
Sid: 'Updated',
|
||||
Effect: 'Deny',
|
||||
Principal: '*',
|
||||
Action: ['s3:DeleteObject'],
|
||||
Resource: [`arn:aws:s3:::${BUCKET}/*`],
|
||||
};
|
||||
|
||||
const putResp = await authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: makePolicy([updatedStatement]),
|
||||
})
|
||||
);
|
||||
expect(putResp.$metadata.httpStatusCode).toEqual(204);
|
||||
|
||||
const getResp = await authClient.send(
|
||||
new GetBucketPolicyCommand({ Bucket: BUCKET })
|
||||
);
|
||||
const policy = JSON.parse(getResp.Policy!);
|
||||
expect(policy.Statement[0].Sid).toEqual('Updated');
|
||||
expect(policy.Statement[0].Effect).toEqual('Deny');
|
||||
});
|
||||
|
||||
tap.test('DELETE policy → 204', async () => {
|
||||
const response = await authClient.send(
|
||||
new DeleteBucketPolicyCommand({ Bucket: BUCKET })
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('DELETE policy again (idempotent) → 204', async () => {
|
||||
const response = await authClient.send(
|
||||
new DeleteBucketPolicyCommand({ Bucket: BUCKET })
|
||||
);
|
||||
expect(response.$metadata.httpStatusCode).toEqual(204);
|
||||
});
|
||||
|
||||
tap.test('GET policy after delete → throws', async () => {
|
||||
await expect(
|
||||
authClient.send(new GetBucketPolicyCommand({ Bucket: BUCKET }))
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
tap.test('PUT policy on non-existent bucket → throws (NoSuchBucket)', async () => {
|
||||
await expect(
|
||||
authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: 'nonexistent-bucket-xyz',
|
||||
Policy: makePolicy([validStatement]),
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
tap.test('PUT invalid JSON → throws (MalformedPolicy)', async () => {
|
||||
await expect(
|
||||
authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: '{not valid json!!!',
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
tap.test('PUT policy with wrong version → throws (MalformedPolicy)', async () => {
|
||||
await expect(
|
||||
authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: JSON.stringify({
|
||||
Version: '2023-01-01',
|
||||
Statement: [validStatement],
|
||||
}),
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
tap.test('PUT policy with empty statements array → throws (MalformedPolicy)', async () => {
|
||||
await expect(
|
||||
authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: JSON.stringify({
|
||||
Version: '2012-10-17',
|
||||
Statement: [],
|
||||
}),
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
tap.test('PUT policy with action missing s3: prefix → throws (MalformedPolicy)', async () => {
|
||||
await expect(
|
||||
authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: makePolicy([
|
||||
{
|
||||
Sid: 'BadAction',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: ['GetObject'],
|
||||
Resource: [`arn:aws:s3:::${BUCKET}/*`],
|
||||
},
|
||||
]),
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
tap.test('PUT policy with resource missing arn:aws:s3::: prefix → throws (MalformedPolicy)', async () => {
|
||||
await expect(
|
||||
authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: makePolicy([
|
||||
{
|
||||
Sid: 'BadResource',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: ['s3:GetObject'],
|
||||
Resource: ['policy-crud-bucket/*'],
|
||||
},
|
||||
]),
|
||||
})
|
||||
)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
tap.test('Bucket deletion cleans up associated policy', async () => {
|
||||
// PUT a policy
|
||||
await authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: makePolicy([validStatement]),
|
||||
})
|
||||
);
|
||||
|
||||
// Delete the bucket
|
||||
await authClient.send(new DeleteBucketCommand({ Bucket: BUCKET }));
|
||||
|
||||
// Re-create the bucket
|
||||
await authClient.send(new CreateBucketCommand({ Bucket: BUCKET }));
|
||||
|
||||
// GET policy should now be gone
|
||||
await expect(
|
||||
authClient.send(new GetBucketPolicyCommand({ Bucket: BUCKET }))
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Teardown
|
||||
// ============================
|
||||
|
||||
tap.test('teardown: delete bucket and stop server', async () => {
|
||||
await authClient.send(new DeleteBucketCommand({ Bucket: BUCKET }));
|
||||
await testSmartStorageInstance.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
517
test/test.policy-eval.node.ts
Normal file
517
test/test.policy-eval.node.ts
Normal file
@@ -0,0 +1,517 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import {
|
||||
S3Client,
|
||||
CreateBucketCommand,
|
||||
DeleteBucketCommand,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
PutBucketPolicyCommand,
|
||||
DeleteBucketPolicyCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { Readable } from 'stream';
|
||||
import * as smartstorage from '../ts/index.js';
|
||||
|
||||
let testSmartStorageInstance: smartstorage.SmartStorage;
|
||||
let authClient: S3Client;
|
||||
|
||||
const TEST_PORT = 3346;
|
||||
const ACCESS_KEY = 'TESTAKID';
|
||||
const SECRET_KEY = 'TESTSECRETKEY123';
|
||||
const BUCKET = 'eval-bucket';
|
||||
const BASE_URL = `http://localhost:${TEST_PORT}`;
|
||||
|
||||
async function streamToString(stream: Readable): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
stream.on('error', reject);
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
});
|
||||
}
|
||||
|
||||
async function putPolicy(statements: any[]) {
|
||||
await authClient.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: BUCKET,
|
||||
Policy: JSON.stringify({ Version: '2012-10-17', Statement: statements }),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function clearPolicy() {
|
||||
await authClient.send(new DeleteBucketPolicyCommand({ Bucket: BUCKET }));
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Server setup
|
||||
// ============================
|
||||
|
||||
tap.test('setup: start server, create bucket, upload object', async () => {
|
||||
testSmartStorageInstance = await smartstorage.SmartStorage.createAndStart({
|
||||
server: { port: TEST_PORT, silent: true, region: 'us-east-1' },
|
||||
storage: { cleanSlate: true },
|
||||
auth: {
|
||||
enabled: true,
|
||||
credentials: [{ accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY }],
|
||||
},
|
||||
});
|
||||
|
||||
authClient = new S3Client({
|
||||
endpoint: BASE_URL,
|
||||
region: 'us-east-1',
|
||||
credentials: { accessKeyId: ACCESS_KEY, secretAccessKey: SECRET_KEY },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
await authClient.send(new CreateBucketCommand({ Bucket: BUCKET }));
|
||||
await authClient.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: 'test-obj.txt',
|
||||
Body: 'hello policy eval',
|
||||
ContentType: 'text/plain',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Principal matching
|
||||
// ============================
|
||||
|
||||
tap.test('Principal: "*" → anonymous fetch GET succeeds', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'PrincipalWildcard',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(resp.status).toEqual(200);
|
||||
const text = await resp.text();
|
||||
expect(text).toEqual('hello policy eval');
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Principal: {"AWS": "*"} → anonymous GET fails, authenticated GET succeeds', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'AwsWildcard',
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: '*' },
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
// Anonymous → no identity → Principal AWS:* doesn't match anonymous → NoOpinion → denied
|
||||
const anonResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(anonResp.status).toEqual(403);
|
||||
|
||||
// Authenticated → has identity → Principal AWS:* matches → Allow
|
||||
const authResp = await authClient.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: 'test-obj.txt' })
|
||||
);
|
||||
expect(authResp.$metadata.httpStatusCode).toEqual(200);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Principal: {"AWS": "arn:aws:iam::TESTAKID"} → authenticated GET succeeds', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'SpecificPrincipal',
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: `arn:aws:iam::${ACCESS_KEY}` },
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const resp = await authClient.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: 'test-obj.txt' })
|
||||
);
|
||||
expect(resp.$metadata.httpStatusCode).toEqual(200);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Principal: {"AWS": "arn:aws:iam::WRONGKEY"} → authenticated GET still succeeds (default allow)', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'WrongPrincipal',
|
||||
Effect: 'Allow',
|
||||
Principal: { AWS: 'arn:aws:iam::WRONGKEY' },
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
// Principal doesn't match our key → NoOpinion → default allow for authenticated
|
||||
const resp = await authClient.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: 'test-obj.txt' })
|
||||
);
|
||||
expect(resp.$metadata.httpStatusCode).toEqual(200);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Action matching
|
||||
// ============================
|
||||
|
||||
tap.test('Action: "s3:*" → anonymous can GET and PUT (wildcard matches all)', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'S3Wildcard',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:*',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const getResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(getResp.status).toEqual(200);
|
||||
|
||||
const putResp = await fetch(`${BASE_URL}/${BUCKET}/anon-wildcard.txt`, {
|
||||
method: 'PUT',
|
||||
body: 'wildcard put',
|
||||
});
|
||||
expect(putResp.status).toEqual(200);
|
||||
|
||||
// Clean up the object we created
|
||||
await authClient.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'anon-wildcard.txt' }));
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Action: "*" → global wildcard matches all actions', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'GlobalWildcard',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: '*',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const getResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(getResp.status).toEqual(200);
|
||||
|
||||
const putResp = await fetch(`${BASE_URL}/${BUCKET}/anon-global.txt`, {
|
||||
method: 'PUT',
|
||||
body: 'global wildcard',
|
||||
});
|
||||
expect(putResp.status).toEqual(200);
|
||||
|
||||
await authClient.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'anon-global.txt' }));
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Action: "s3:Get*" → anonymous can GET but not PUT (prefix wildcard)', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'PrefixWildcard',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:Get*',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const getResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(getResp.status).toEqual(200);
|
||||
|
||||
const putResp = await fetch(`${BASE_URL}/${BUCKET}/anon-prefix.txt`, {
|
||||
method: 'PUT',
|
||||
body: 'should fail',
|
||||
});
|
||||
expect(putResp.status).toEqual(403);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Action: ["s3:GetObject", "s3:PutObject"] → anonymous can GET and PUT but not DELETE', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'MultiAction',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: ['s3:GetObject', 's3:PutObject'],
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const getResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(getResp.status).toEqual(200);
|
||||
|
||||
const putResp = await fetch(`${BASE_URL}/${BUCKET}/anon-multi.txt`, {
|
||||
method: 'PUT',
|
||||
body: 'multi action',
|
||||
});
|
||||
expect(putResp.status).toEqual(200);
|
||||
|
||||
const delResp = await fetch(`${BASE_URL}/${BUCKET}/anon-multi.txt`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
expect(delResp.status).toEqual(403);
|
||||
|
||||
// Clean up
|
||||
await authClient.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'anon-multi.txt' }));
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Resource ARN matching
|
||||
// ============================
|
||||
|
||||
tap.test('Resource: "arn:aws:s3:::eval-bucket/*" → anonymous GET of object succeeds', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'ResourceWildcard',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(resp.status).toEqual(200);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Resource: exact key → anonymous GET of that key succeeds, other key fails', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'ExactResource',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/test-obj.txt`,
|
||||
},
|
||||
]);
|
||||
|
||||
const goodResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(goodResp.status).toEqual(200);
|
||||
|
||||
// Other key → resource doesn't match → NoOpinion → denied for anonymous
|
||||
const badResp = await fetch(`${BASE_URL}/${BUCKET}/nonexistent.txt`);
|
||||
expect(badResp.status).toEqual(403);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Resource: wrong bucket ARN → NoOpinion → anonymous GET denied', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'WrongBucket',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: 'arn:aws:s3:::other-bucket/*',
|
||||
},
|
||||
]);
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(resp.status).toEqual(403);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Resource: "*" → matches everything, anonymous GET succeeds', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'StarResource',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: '*',
|
||||
},
|
||||
]);
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(resp.status).toEqual(200);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Deny-over-Allow priority
|
||||
// ============================
|
||||
|
||||
tap.test('Allow + Deny same action → anonymous GET denied', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'AllowGet',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
{
|
||||
Sid: 'DenyGet',
|
||||
Effect: 'Deny',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(resp.status).toEqual(403);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Allow s3:* + Deny s3:DeleteObject → anonymous GET succeeds, DELETE denied', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'AllowAll',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:*',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
{
|
||||
Sid: 'DenyDelete',
|
||||
Effect: 'Deny',
|
||||
Principal: '*',
|
||||
Action: 's3:DeleteObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const getResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(getResp.status).toEqual(200);
|
||||
|
||||
const delResp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`, { method: 'DELETE' });
|
||||
expect(delResp.status).toEqual(403);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Statement order does not matter: Deny first, Allow second → still denied', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'DenyFirst',
|
||||
Effect: 'Deny',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
{
|
||||
Sid: 'AllowSecond',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(resp.status).toEqual(403);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// NoOpinion fallback
|
||||
// ============================
|
||||
|
||||
tap.test('NoOpinion: policy allows PutObject only → authenticated GET falls through (default allow)', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'AllowPutOnly',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:PutObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
// Authenticated → NoOpinion → default allow
|
||||
const resp = await authClient.send(
|
||||
new GetObjectCommand({ Bucket: BUCKET, Key: 'test-obj.txt' })
|
||||
);
|
||||
expect(resp.$metadata.httpStatusCode).toEqual(200);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('NoOpinion: same policy → anonymous GET falls through → default deny (403)', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'AllowPutOnly',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:PutObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
// Anonymous → NoOpinion for GetObject → default deny
|
||||
const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`);
|
||||
expect(resp.status).toEqual(403);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// IAM action mapping
|
||||
// ============================
|
||||
|
||||
tap.test('Policy allows s3:GetObject → anonymous HEAD object succeeds (HeadObject maps to s3:GetObject)', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'AllowGet',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:GetObject',
|
||||
Resource: `arn:aws:s3:::${BUCKET}/*`,
|
||||
},
|
||||
]);
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/${BUCKET}/test-obj.txt`, { method: 'HEAD' });
|
||||
expect(resp.status).toEqual(200);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Policy allows s3:ListBucket → anonymous HEAD bucket succeeds', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'AllowList',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:ListBucket',
|
||||
Resource: `arn:aws:s3:::${BUCKET}`,
|
||||
},
|
||||
]);
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/${BUCKET}`, { method: 'HEAD' });
|
||||
expect(resp.status).toEqual(200);
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
tap.test('Policy allows s3:ListBucket → anonymous GET bucket (list objects) succeeds', async () => {
|
||||
await putPolicy([
|
||||
{
|
||||
Sid: 'AllowList',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: 's3:ListBucket',
|
||||
Resource: `arn:aws:s3:::${BUCKET}`,
|
||||
},
|
||||
]);
|
||||
|
||||
const resp = await fetch(`${BASE_URL}/${BUCKET}`);
|
||||
expect(resp.status).toEqual(200);
|
||||
const text = await resp.text();
|
||||
expect(text).toInclude('ListBucketResult');
|
||||
await clearPolicy();
|
||||
});
|
||||
|
||||
// ============================
|
||||
// Teardown
|
||||
// ============================
|
||||
|
||||
tap.test('teardown: clean up and stop server', async () => {
|
||||
await authClient.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'test-obj.txt' }));
|
||||
await authClient.send(new DeleteBucketCommand({ Bucket: BUCKET }));
|
||||
await testSmartStorageInstance.stop();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
20
test/test.ts
20
test/test.ts
@@ -1,14 +1,18 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as plugins from './plugins.js';
|
||||
|
||||
import * as smarts3 from '../ts/index.js';
|
||||
import * as smartstorage from '../ts/index.js';
|
||||
|
||||
let testSmarts3Instance: smarts3.Smarts3;
|
||||
let testSmartStorageInstance: smartstorage.SmartStorage;
|
||||
|
||||
tap.test('should create a smarts3 instance and run it', async (toolsArg) => {
|
||||
testSmarts3Instance = await smarts3.Smarts3.createAndStart({
|
||||
port: 3333,
|
||||
cleanSlate: true,
|
||||
tap.test('should create a smartstorage instance and run it', async (toolsArg) => {
|
||||
testSmartStorageInstance = await smartstorage.SmartStorage.createAndStart({
|
||||
server: {
|
||||
port: 3333,
|
||||
},
|
||||
storage: {
|
||||
cleanSlate: true,
|
||||
},
|
||||
});
|
||||
console.log(`Let the instance run for 2 seconds`);
|
||||
await toolsArg.delayFor(2000);
|
||||
@@ -16,7 +20,7 @@ tap.test('should create a smarts3 instance and run it', async (toolsArg) => {
|
||||
|
||||
tap.test('should be able to access buckets', async () => {
|
||||
const smartbucketInstance = new plugins.smartbucket.SmartBucket(
|
||||
await testSmarts3Instance.getS3Descriptor(),
|
||||
await testSmartStorageInstance.getStorageDescriptor(),
|
||||
);
|
||||
const bucket = await smartbucketInstance.createBucket('testbucket');
|
||||
const baseDirectory = await bucket.getBaseDirectory();
|
||||
@@ -27,7 +31,7 @@ tap.test('should be able to access buckets', async () => {
|
||||
});
|
||||
|
||||
tap.test('should stop the instance', async () => {
|
||||
await testSmarts3Instance.stop();
|
||||
await testSmartStorageInstance.stop();
|
||||
});
|
||||
|
||||
tap.start();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smarts3',
|
||||
version: '3.0.0',
|
||||
description: 'A Node.js TypeScript package to create a local S3 endpoint for simulating AWS S3 operations using mapped local directories for development and testing purposes.'
|
||||
name: '@push.rocks/smartstorage',
|
||||
version: '6.2.0',
|
||||
description: 'A Node.js TypeScript package to create a local S3-compatible storage server using mapped local directories for development and testing purposes.'
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { S3Error } from './s3-error.js';
|
||||
import { createXml } from '../utils/xml.utils.js';
|
||||
import type { FilesystemStore } from './filesystem-store.js';
|
||||
import type { Readable } from 'stream';
|
||||
|
||||
/**
|
||||
* S3 request context with helper methods
|
||||
*/
|
||||
export class S3Context {
|
||||
public method: string;
|
||||
public url: URL;
|
||||
public headers: plugins.http.IncomingHttpHeaders;
|
||||
public params: Record<string, string> = {};
|
||||
public query: Record<string, string> = {};
|
||||
public store: FilesystemStore;
|
||||
|
||||
private req: plugins.http.IncomingMessage;
|
||||
private res: plugins.http.ServerResponse;
|
||||
private statusCode: number = 200;
|
||||
private responseHeaders: Record<string, string> = {};
|
||||
|
||||
constructor(
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse,
|
||||
store: FilesystemStore
|
||||
) {
|
||||
this.req = req;
|
||||
this.res = res;
|
||||
this.store = store;
|
||||
this.method = req.method || 'GET';
|
||||
this.headers = req.headers;
|
||||
|
||||
// Parse URL and query string
|
||||
const fullUrl = `http://${req.headers.host || 'localhost'}${req.url || '/'}`;
|
||||
this.url = new URL(fullUrl);
|
||||
|
||||
// Parse query string into object
|
||||
this.url.searchParams.forEach((value, key) => {
|
||||
this.query[key] = value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set response status code
|
||||
*/
|
||||
public status(code: number): this {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set response header
|
||||
*/
|
||||
public setHeader(name: string, value: string | number): this {
|
||||
this.responseHeaders[name] = value.toString();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send response body (string, Buffer, or Stream)
|
||||
*/
|
||||
public async send(body: string | Buffer | Readable | NodeJS.ReadableStream): Promise<void> {
|
||||
// Write status and headers
|
||||
this.res.writeHead(this.statusCode, this.responseHeaders);
|
||||
|
||||
// Handle different body types
|
||||
if (typeof body === 'string' || body instanceof Buffer) {
|
||||
this.res.end(body);
|
||||
} else if (body && typeof (body as any).pipe === 'function') {
|
||||
// It's a stream
|
||||
(body as Readable).pipe(this.res);
|
||||
} else {
|
||||
this.res.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send XML response
|
||||
*/
|
||||
public async sendXML(obj: any): Promise<void> {
|
||||
const xml = createXml(obj, { format: true });
|
||||
this.setHeader('Content-Type', 'application/xml');
|
||||
this.setHeader('Content-Length', Buffer.byteLength(xml));
|
||||
await this.send(xml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw an S3 error
|
||||
*/
|
||||
public throw(code: string, message: string, detail?: Record<string, any>): never {
|
||||
throw new S3Error(code, message, detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse request body as string
|
||||
*/
|
||||
public async readBody(): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
this.req.on('data', (chunk) => chunks.push(chunk));
|
||||
this.req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
this.req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the request stream (for streaming uploads)
|
||||
*/
|
||||
public getRequestStream(): NodeJS.ReadableStream {
|
||||
return this.req;
|
||||
}
|
||||
}
|
||||
@@ -1,495 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { S3Error } from './s3-error.js';
|
||||
import type { Readable } from 'stream';
|
||||
|
||||
export interface IS3Bucket {
|
||||
name: string;
|
||||
creationDate: Date;
|
||||
}
|
||||
|
||||
export interface IS3Object {
|
||||
key: string;
|
||||
size: number;
|
||||
lastModified: Date;
|
||||
md5: string;
|
||||
metadata: Record<string, string>;
|
||||
content?: Readable;
|
||||
}
|
||||
|
||||
export interface IListObjectsOptions {
|
||||
prefix?: string;
|
||||
delimiter?: string;
|
||||
maxKeys?: number;
|
||||
continuationToken?: string;
|
||||
}
|
||||
|
||||
export interface IListObjectsResult {
|
||||
contents: IS3Object[];
|
||||
commonPrefixes: string[];
|
||||
isTruncated: boolean;
|
||||
nextContinuationToken?: string;
|
||||
prefix: string;
|
||||
delimiter: string;
|
||||
maxKeys: number;
|
||||
}
|
||||
|
||||
export interface IRangeOptions {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filesystem-backed storage for S3 objects
|
||||
*/
|
||||
export class FilesystemStore {
|
||||
constructor(private rootDir: string) {}
|
||||
|
||||
/**
|
||||
* Initialize store (ensure root directory exists)
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
await plugins.fs.promises.mkdir(this.rootDir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset store (delete all buckets)
|
||||
*/
|
||||
public async reset(): Promise<void> {
|
||||
await plugins.smartfile.fs.ensureEmptyDir(this.rootDir);
|
||||
}
|
||||
|
||||
// ============================
|
||||
// BUCKET OPERATIONS
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* List all buckets
|
||||
*/
|
||||
public async listBuckets(): Promise<IS3Bucket[]> {
|
||||
const dirs = await plugins.smartfile.fs.listFolders(this.rootDir);
|
||||
const buckets: IS3Bucket[] = [];
|
||||
|
||||
for (const dir of dirs) {
|
||||
const bucketPath = plugins.path.join(this.rootDir, dir);
|
||||
const stats = await plugins.smartfile.fs.stat(bucketPath);
|
||||
|
||||
buckets.push({
|
||||
name: dir,
|
||||
creationDate: stats.birthtime,
|
||||
});
|
||||
}
|
||||
|
||||
return buckets.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if bucket exists
|
||||
*/
|
||||
public async bucketExists(bucket: string): Promise<boolean> {
|
||||
const bucketPath = this.getBucketPath(bucket);
|
||||
return plugins.smartfile.fs.isDirectory(bucketPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create bucket
|
||||
*/
|
||||
public async createBucket(bucket: string): Promise<void> {
|
||||
const bucketPath = this.getBucketPath(bucket);
|
||||
await plugins.fs.promises.mkdir(bucketPath, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete bucket (must be empty)
|
||||
*/
|
||||
public async deleteBucket(bucket: string): Promise<void> {
|
||||
const bucketPath = this.getBucketPath(bucket);
|
||||
|
||||
// Check if bucket exists
|
||||
if (!(await this.bucketExists(bucket))) {
|
||||
throw new S3Error('NoSuchBucket', 'The specified bucket does not exist');
|
||||
}
|
||||
|
||||
// Check if bucket is empty
|
||||
const files = await plugins.smartfile.fs.listFileTree(bucketPath, '**/*');
|
||||
if (files.length > 0) {
|
||||
throw new S3Error('BucketNotEmpty', 'The bucket you tried to delete is not empty');
|
||||
}
|
||||
|
||||
await plugins.smartfile.fs.remove(bucketPath);
|
||||
}
|
||||
|
||||
// ============================
|
||||
// OBJECT OPERATIONS
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* List objects in bucket
|
||||
*/
|
||||
public async listObjects(
|
||||
bucket: string,
|
||||
options: IListObjectsOptions = {}
|
||||
): Promise<IListObjectsResult> {
|
||||
const bucketPath = this.getBucketPath(bucket);
|
||||
|
||||
if (!(await this.bucketExists(bucket))) {
|
||||
throw new S3Error('NoSuchBucket', 'The specified bucket does not exist');
|
||||
}
|
||||
|
||||
const {
|
||||
prefix = '',
|
||||
delimiter = '',
|
||||
maxKeys = 1000,
|
||||
continuationToken,
|
||||
} = options;
|
||||
|
||||
// List all object files
|
||||
const objectPattern = '**/*._S3_object';
|
||||
const objectFiles = await plugins.smartfile.fs.listFileTree(bucketPath, objectPattern);
|
||||
|
||||
// Convert file paths to keys
|
||||
let keys = objectFiles.map((filePath) => {
|
||||
const relativePath = plugins.path.relative(bucketPath, filePath);
|
||||
const key = this.decodeKey(relativePath.replace(/\._S3_object$/, ''));
|
||||
return key;
|
||||
});
|
||||
|
||||
// Apply prefix filter
|
||||
if (prefix) {
|
||||
keys = keys.filter((key) => key.startsWith(prefix));
|
||||
}
|
||||
|
||||
// Sort keys
|
||||
keys = keys.sort();
|
||||
|
||||
// Handle continuation token (simple implementation using key name)
|
||||
if (continuationToken) {
|
||||
const startIndex = keys.findIndex((key) => key > continuationToken);
|
||||
if (startIndex > 0) {
|
||||
keys = keys.slice(startIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle delimiter (common prefixes)
|
||||
const commonPrefixes: Set<string> = new Set();
|
||||
const contents: IS3Object[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
if (delimiter) {
|
||||
// Find first delimiter after prefix
|
||||
const remainingKey = key.slice(prefix.length);
|
||||
const delimiterIndex = remainingKey.indexOf(delimiter);
|
||||
|
||||
if (delimiterIndex !== -1) {
|
||||
// This key has a delimiter, add to common prefixes
|
||||
const commonPrefix = prefix + remainingKey.slice(0, delimiterIndex + delimiter.length);
|
||||
commonPrefixes.add(commonPrefix);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Add to contents (limited by maxKeys)
|
||||
if (contents.length >= maxKeys) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const objectInfo = await this.getObjectInfo(bucket, key);
|
||||
contents.push(objectInfo);
|
||||
} catch (err) {
|
||||
// Skip if object no longer exists
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const isTruncated = keys.length > contents.length + commonPrefixes.size;
|
||||
const nextContinuationToken = isTruncated
|
||||
? contents[contents.length - 1]?.key
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
contents,
|
||||
commonPrefixes: Array.from(commonPrefixes).sort(),
|
||||
isTruncated,
|
||||
nextContinuationToken,
|
||||
prefix,
|
||||
delimiter,
|
||||
maxKeys,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get object info (without content)
|
||||
*/
|
||||
private async getObjectInfo(bucket: string, key: string): Promise<IS3Object> {
|
||||
const objectPath = this.getObjectPath(bucket, key);
|
||||
const metadataPath = `${objectPath}.metadata.json`;
|
||||
const md5Path = `${objectPath}.md5`;
|
||||
|
||||
const [stats, metadata, md5] = await Promise.all([
|
||||
plugins.smartfile.fs.stat(objectPath),
|
||||
this.readMetadata(metadataPath),
|
||||
this.readMD5(objectPath, md5Path),
|
||||
]);
|
||||
|
||||
return {
|
||||
key,
|
||||
size: stats.size,
|
||||
lastModified: stats.mtime,
|
||||
md5,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if object exists
|
||||
*/
|
||||
public async objectExists(bucket: string, key: string): Promise<boolean> {
|
||||
const objectPath = this.getObjectPath(bucket, key);
|
||||
return plugins.smartfile.fs.fileExists(objectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Put object (upload with streaming)
|
||||
*/
|
||||
public async putObject(
|
||||
bucket: string,
|
||||
key: string,
|
||||
stream: NodeJS.ReadableStream,
|
||||
metadata: Record<string, string> = {}
|
||||
): Promise<{ size: number; md5: string }> {
|
||||
const objectPath = this.getObjectPath(bucket, key);
|
||||
|
||||
// Ensure bucket exists
|
||||
if (!(await this.bucketExists(bucket))) {
|
||||
throw new S3Error('NoSuchBucket', 'The specified bucket does not exist');
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
await plugins.fs.promises.mkdir(plugins.path.dirname(objectPath), { recursive: true });
|
||||
|
||||
// Write with MD5 calculation
|
||||
const result = await this.writeStreamWithMD5(stream, objectPath);
|
||||
|
||||
// Save metadata
|
||||
const metadataPath = `${objectPath}.metadata.json`;
|
||||
await plugins.fs.promises.writeFile(metadataPath, JSON.stringify(metadata, null, 2));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get object (download with streaming)
|
||||
*/
|
||||
public async getObject(
|
||||
bucket: string,
|
||||
key: string,
|
||||
range?: IRangeOptions
|
||||
): Promise<IS3Object> {
|
||||
const objectPath = this.getObjectPath(bucket, key);
|
||||
|
||||
if (!(await this.objectExists(bucket, key))) {
|
||||
throw new S3Error('NoSuchKey', 'The specified key does not exist');
|
||||
}
|
||||
|
||||
const info = await this.getObjectInfo(bucket, key);
|
||||
|
||||
// Create read stream with optional range (using native fs for range support)
|
||||
const stream = range
|
||||
? plugins.fs.createReadStream(objectPath, { start: range.start, end: range.end })
|
||||
: plugins.fs.createReadStream(objectPath);
|
||||
|
||||
return {
|
||||
...info,
|
||||
content: stream,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete object
|
||||
*/
|
||||
public async deleteObject(bucket: string, key: string): Promise<void> {
|
||||
const objectPath = this.getObjectPath(bucket, key);
|
||||
const metadataPath = `${objectPath}.metadata.json`;
|
||||
const md5Path = `${objectPath}.md5`;
|
||||
|
||||
// S3 doesn't throw error if object doesn't exist
|
||||
await Promise.all([
|
||||
plugins.smartfile.fs.remove(objectPath).catch(() => {}),
|
||||
plugins.smartfile.fs.remove(metadataPath).catch(() => {}),
|
||||
plugins.smartfile.fs.remove(md5Path).catch(() => {}),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy object
|
||||
*/
|
||||
public async copyObject(
|
||||
srcBucket: string,
|
||||
srcKey: string,
|
||||
destBucket: string,
|
||||
destKey: string,
|
||||
metadataDirective: 'COPY' | 'REPLACE' = 'COPY',
|
||||
newMetadata?: Record<string, string>
|
||||
): Promise<{ size: number; md5: string }> {
|
||||
const srcObjectPath = this.getObjectPath(srcBucket, srcKey);
|
||||
const destObjectPath = this.getObjectPath(destBucket, destKey);
|
||||
|
||||
// Check source exists
|
||||
if (!(await this.objectExists(srcBucket, srcKey))) {
|
||||
throw new S3Error('NoSuchKey', 'The specified key does not exist');
|
||||
}
|
||||
|
||||
// Ensure dest bucket exists
|
||||
if (!(await this.bucketExists(destBucket))) {
|
||||
throw new S3Error('NoSuchBucket', 'The specified bucket does not exist');
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
await plugins.fs.promises.mkdir(plugins.path.dirname(destObjectPath), { recursive: true });
|
||||
|
||||
// Copy object file
|
||||
await plugins.smartfile.fs.copy(srcObjectPath, destObjectPath);
|
||||
|
||||
// Handle metadata
|
||||
if (metadataDirective === 'COPY') {
|
||||
// Copy metadata
|
||||
const srcMetadataPath = `${srcObjectPath}.metadata.json`;
|
||||
const destMetadataPath = `${destObjectPath}.metadata.json`;
|
||||
await plugins.smartfile.fs.copy(srcMetadataPath, destMetadataPath).catch(() => {});
|
||||
} else if (newMetadata) {
|
||||
// Replace with new metadata
|
||||
const destMetadataPath = `${destObjectPath}.metadata.json`;
|
||||
await plugins.fs.promises.writeFile(destMetadataPath, JSON.stringify(newMetadata, null, 2));
|
||||
}
|
||||
|
||||
// Copy MD5
|
||||
const srcMD5Path = `${srcObjectPath}.md5`;
|
||||
const destMD5Path = `${destObjectPath}.md5`;
|
||||
await plugins.smartfile.fs.copy(srcMD5Path, destMD5Path).catch(() => {});
|
||||
|
||||
// Get result info
|
||||
const stats = await plugins.smartfile.fs.stat(destObjectPath);
|
||||
const md5 = await this.readMD5(destObjectPath, destMD5Path);
|
||||
|
||||
return { size: stats.size, md5 };
|
||||
}
|
||||
|
||||
// ============================
|
||||
// HELPER METHODS
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Get bucket directory path
|
||||
*/
|
||||
private getBucketPath(bucket: string): string {
|
||||
return plugins.path.join(this.rootDir, bucket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get object file path
|
||||
*/
|
||||
private getObjectPath(bucket: string, key: string): string {
|
||||
return plugins.path.join(
|
||||
this.rootDir,
|
||||
bucket,
|
||||
this.encodeKey(key) + '._S3_object'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode key for Windows compatibility
|
||||
*/
|
||||
private encodeKey(key: string): string {
|
||||
if (process.platform === 'win32') {
|
||||
// Replace invalid Windows filename chars with hex encoding
|
||||
return key.replace(/[<>:"\\|?*]/g, (ch) =>
|
||||
'&' + Buffer.from(ch, 'utf8').toString('hex')
|
||||
);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode key from filesystem path
|
||||
*/
|
||||
private decodeKey(encodedKey: string): string {
|
||||
if (process.platform === 'win32') {
|
||||
// Decode hex-encoded chars
|
||||
return encodedKey.replace(/&([0-9a-f]{2})/gi, (_, hex) =>
|
||||
Buffer.from(hex, 'hex').toString('utf8')
|
||||
);
|
||||
}
|
||||
return encodedKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write stream to file with MD5 calculation
|
||||
*/
|
||||
private async writeStreamWithMD5(
|
||||
input: NodeJS.ReadableStream,
|
||||
destPath: string
|
||||
): Promise<{ size: number; md5: string }> {
|
||||
const hash = plugins.crypto.createHash('md5');
|
||||
let totalSize = 0;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const output = plugins.fs.createWriteStream(destPath);
|
||||
|
||||
input.on('data', (chunk: Buffer) => {
|
||||
hash.update(chunk);
|
||||
totalSize += chunk.length;
|
||||
});
|
||||
|
||||
input.on('error', reject);
|
||||
output.on('error', reject);
|
||||
|
||||
input.pipe(output).on('finish', async () => {
|
||||
const md5 = hash.digest('hex');
|
||||
|
||||
// Save MD5 to separate file
|
||||
const md5Path = `${destPath}.md5`;
|
||||
await plugins.fs.promises.writeFile(md5Path, md5);
|
||||
|
||||
resolve({ size: totalSize, md5 });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read MD5 hash (calculate if missing)
|
||||
*/
|
||||
private async readMD5(objectPath: string, md5Path: string): Promise<string> {
|
||||
try {
|
||||
// Try to read cached MD5
|
||||
const md5 = await plugins.smartfile.fs.toStringSync(md5Path);
|
||||
return md5.trim();
|
||||
} catch (err) {
|
||||
// Calculate MD5 if not cached
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = plugins.crypto.createHash('md5');
|
||||
const stream = plugins.fs.createReadStream(objectPath);
|
||||
|
||||
stream.on('data', (chunk: Buffer) => hash.update(chunk));
|
||||
stream.on('end', async () => {
|
||||
const md5 = hash.digest('hex');
|
||||
// Cache it
|
||||
await plugins.fs.promises.writeFile(md5Path, md5);
|
||||
resolve(md5);
|
||||
});
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read metadata from JSON file
|
||||
*/
|
||||
private async readMetadata(metadataPath: string): Promise<Record<string, string>> {
|
||||
try {
|
||||
const content = await plugins.smartfile.fs.toStringSync(metadataPath);
|
||||
return JSON.parse(content);
|
||||
} catch (err) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { S3Context } from './context.js';
|
||||
|
||||
export type Middleware = (
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse,
|
||||
ctx: S3Context,
|
||||
next: () => Promise<void>
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Middleware stack for composing request handlers
|
||||
*/
|
||||
export class MiddlewareStack {
|
||||
private middlewares: Middleware[] = [];
|
||||
|
||||
/**
|
||||
* Add middleware to the stack
|
||||
*/
|
||||
public use(middleware: Middleware): void {
|
||||
this.middlewares.push(middleware);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute all middlewares in order
|
||||
*/
|
||||
public async execute(
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse,
|
||||
ctx: S3Context
|
||||
): Promise<void> {
|
||||
let index = 0;
|
||||
|
||||
const next = async (): Promise<void> => {
|
||||
if (index < this.middlewares.length) {
|
||||
const middleware = this.middlewares[index++];
|
||||
await middleware(req, res, ctx, next);
|
||||
}
|
||||
};
|
||||
|
||||
await next();
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { S3Context } from './context.js';
|
||||
|
||||
export type RouteHandler = (
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse,
|
||||
ctx: S3Context,
|
||||
params: Record<string, string>
|
||||
) => Promise<void>;
|
||||
|
||||
export interface IRouteMatch {
|
||||
handler: RouteHandler;
|
||||
params: Record<string, string>;
|
||||
}
|
||||
|
||||
interface IRoute {
|
||||
method: string;
|
||||
pattern: RegExp;
|
||||
paramNames: string[];
|
||||
handler: RouteHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple HTTP router with pattern matching for S3 routes
|
||||
*/
|
||||
export class S3Router {
|
||||
private routes: IRoute[] = [];
|
||||
|
||||
/**
|
||||
* Add a route with pattern matching
|
||||
* Supports patterns like:
|
||||
* - "/" (exact match)
|
||||
* - "/:bucket" (single param)
|
||||
* - "/:bucket/:key*" (param with wildcard - captures everything after)
|
||||
*/
|
||||
public add(method: string, pattern: string, handler: RouteHandler): void {
|
||||
const { regex, paramNames } = this.convertPatternToRegex(pattern);
|
||||
|
||||
this.routes.push({
|
||||
method: method.toUpperCase(),
|
||||
pattern: regex,
|
||||
paramNames,
|
||||
handler,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a request to a route
|
||||
*/
|
||||
public match(method: string, pathname: string): IRouteMatch | null {
|
||||
// Normalize pathname: remove trailing slash unless it's root
|
||||
const normalizedPath = pathname === '/' ? pathname : pathname.replace(/\/$/, '');
|
||||
|
||||
for (const route of this.routes) {
|
||||
if (route.method !== method.toUpperCase()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = normalizedPath.match(route.pattern);
|
||||
if (match) {
|
||||
// Extract params from captured groups
|
||||
const params: Record<string, string> = {};
|
||||
for (let i = 0; i < route.paramNames.length; i++) {
|
||||
params[route.paramNames[i]] = decodeURIComponent(match[i + 1] || '');
|
||||
}
|
||||
|
||||
return {
|
||||
handler: route.handler,
|
||||
params,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert path pattern to RegExp
|
||||
* Examples:
|
||||
* - "/" → /^\/$/
|
||||
* - "/:bucket" → /^\/([^/]+)$/
|
||||
* - "/:bucket/:key*" → /^\/([^/]+)\/(.+)$/
|
||||
*/
|
||||
private convertPatternToRegex(pattern: string): { regex: RegExp; paramNames: string[] } {
|
||||
const paramNames: string[] = [];
|
||||
let regexStr = pattern;
|
||||
|
||||
// Process all params in a single pass to maintain order
|
||||
regexStr = regexStr.replace(/:(\w+)(\*)?/g, (match, paramName, isWildcard) => {
|
||||
paramNames.push(paramName);
|
||||
// :param* captures rest of path, :param captures single segment
|
||||
return isWildcard ? '(.+)' : '([^/]+)';
|
||||
});
|
||||
|
||||
// Escape special regex characters
|
||||
regexStr = regexStr.replace(/\//g, '\\/');
|
||||
|
||||
// Add anchors
|
||||
regexStr = `^${regexStr}$`;
|
||||
|
||||
return {
|
||||
regex: new RegExp(regexStr),
|
||||
paramNames,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience methods for common HTTP methods
|
||||
*/
|
||||
public get(pattern: string, handler: RouteHandler): void {
|
||||
this.add('GET', pattern, handler);
|
||||
}
|
||||
|
||||
public put(pattern: string, handler: RouteHandler): void {
|
||||
this.add('PUT', pattern, handler);
|
||||
}
|
||||
|
||||
public post(pattern: string, handler: RouteHandler): void {
|
||||
this.add('POST', pattern, handler);
|
||||
}
|
||||
|
||||
public delete(pattern: string, handler: RouteHandler): void {
|
||||
this.add('DELETE', pattern, handler);
|
||||
}
|
||||
|
||||
public head(pattern: string, handler: RouteHandler): void {
|
||||
this.add('HEAD', pattern, handler);
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
/**
|
||||
* S3 error codes mapped to HTTP status codes
|
||||
*/
|
||||
const S3_ERROR_CODES: Record<string, number> = {
|
||||
'AccessDenied': 403,
|
||||
'BadDigest': 400,
|
||||
'BadRequest': 400,
|
||||
'BucketAlreadyExists': 409,
|
||||
'BucketAlreadyOwnedByYou': 409,
|
||||
'BucketNotEmpty': 409,
|
||||
'CredentialsNotSupported': 400,
|
||||
'EntityTooSmall': 400,
|
||||
'EntityTooLarge': 400,
|
||||
'ExpiredToken': 400,
|
||||
'IncompleteBody': 400,
|
||||
'IncorrectNumberOfFilesInPostRequest': 400,
|
||||
'InlineDataTooLarge': 400,
|
||||
'InternalError': 500,
|
||||
'InvalidArgument': 400,
|
||||
'InvalidBucketName': 400,
|
||||
'InvalidDigest': 400,
|
||||
'InvalidLocationConstraint': 400,
|
||||
'InvalidPart': 400,
|
||||
'InvalidPartOrder': 400,
|
||||
'InvalidRange': 416,
|
||||
'InvalidRequest': 400,
|
||||
'InvalidSecurity': 403,
|
||||
'InvalidSOAPRequest': 400,
|
||||
'InvalidStorageClass': 400,
|
||||
'InvalidTargetBucketForLogging': 400,
|
||||
'InvalidToken': 400,
|
||||
'InvalidURI': 400,
|
||||
'KeyTooLongError': 400,
|
||||
'MalformedACLError': 400,
|
||||
'MalformedPOSTRequest': 400,
|
||||
'MalformedXML': 400,
|
||||
'MaxMessageLengthExceeded': 400,
|
||||
'MaxPostPreDataLengthExceededError': 400,
|
||||
'MetadataTooLarge': 400,
|
||||
'MethodNotAllowed': 405,
|
||||
'MissingContentLength': 411,
|
||||
'MissingRequestBodyError': 400,
|
||||
'MissingSecurityElement': 400,
|
||||
'MissingSecurityHeader': 400,
|
||||
'NoLoggingStatusForKey': 400,
|
||||
'NoSuchBucket': 404,
|
||||
'NoSuchKey': 404,
|
||||
'NoSuchLifecycleConfiguration': 404,
|
||||
'NoSuchUpload': 404,
|
||||
'NoSuchVersion': 404,
|
||||
'NotImplemented': 501,
|
||||
'NotSignedUp': 403,
|
||||
'OperationAborted': 409,
|
||||
'PermanentRedirect': 301,
|
||||
'PreconditionFailed': 412,
|
||||
'Redirect': 307,
|
||||
'RequestIsNotMultiPartContent': 400,
|
||||
'RequestTimeout': 400,
|
||||
'RequestTimeTooSkewed': 403,
|
||||
'RequestTorrentOfBucketError': 400,
|
||||
'SignatureDoesNotMatch': 403,
|
||||
'ServiceUnavailable': 503,
|
||||
'SlowDown': 503,
|
||||
'TemporaryRedirect': 307,
|
||||
'TokenRefreshRequired': 400,
|
||||
'TooManyBuckets': 400,
|
||||
'UnexpectedContent': 400,
|
||||
'UnresolvableGrantByEmailAddress': 400,
|
||||
'UserKeyMustBeSpecified': 400,
|
||||
};
|
||||
|
||||
/**
|
||||
* S3-compatible error class that formats errors as XML responses
|
||||
*/
|
||||
export class S3Error extends Error {
|
||||
public status: number;
|
||||
public code: string;
|
||||
public detail: Record<string, any>;
|
||||
|
||||
constructor(
|
||||
code: string,
|
||||
message: string,
|
||||
detail: Record<string, any> = {}
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'S3Error';
|
||||
this.code = code;
|
||||
this.status = S3_ERROR_CODES[code] || 500;
|
||||
this.detail = detail;
|
||||
|
||||
// Maintain proper stack trace
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, S3Error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert error to S3-compatible XML format
|
||||
*/
|
||||
public toXML(): string {
|
||||
const smartXmlInstance = new plugins.SmartXml();
|
||||
const errorObj: any = {
|
||||
Error: {
|
||||
Code: this.code,
|
||||
Message: this.message,
|
||||
...this.detail,
|
||||
},
|
||||
};
|
||||
|
||||
const xml = smartXmlInstance.createXmlFromObject(errorObj);
|
||||
|
||||
// Ensure XML declaration
|
||||
if (!xml.startsWith('<?xml')) {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n${xml}`;
|
||||
}
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create S3Error from a generic Error
|
||||
*/
|
||||
public static fromError(err: any): S3Error {
|
||||
if (err instanceof S3Error) {
|
||||
return err;
|
||||
}
|
||||
|
||||
// Map common errors
|
||||
if (err.code === 'ENOENT') {
|
||||
return new S3Error('NoSuchKey', 'The specified key does not exist.');
|
||||
}
|
||||
if (err.code === 'EACCES') {
|
||||
return new S3Error('AccessDenied', 'Access Denied');
|
||||
}
|
||||
|
||||
// Default to internal error
|
||||
return new S3Error(
|
||||
'InternalError',
|
||||
'We encountered an internal error. Please try again.',
|
||||
{ OriginalError: err.message }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import { S3Router } from './router.js';
|
||||
import { MiddlewareStack } from './middleware-stack.js';
|
||||
import { S3Context } from './context.js';
|
||||
import { FilesystemStore } from './filesystem-store.js';
|
||||
import { S3Error } from './s3-error.js';
|
||||
import { ServiceController } from '../controllers/service.controller.js';
|
||||
import { BucketController } from '../controllers/bucket.controller.js';
|
||||
import { ObjectController } from '../controllers/object.controller.js';
|
||||
|
||||
export interface ISmarts3ServerOptions {
|
||||
port?: number;
|
||||
address?: string;
|
||||
directory?: string;
|
||||
cleanSlate?: boolean;
|
||||
silent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom S3-compatible server implementation
|
||||
* Built on native Node.js http module with zero framework dependencies
|
||||
*/
|
||||
export class Smarts3Server {
|
||||
private httpServer?: plugins.http.Server;
|
||||
private router: S3Router;
|
||||
private middlewares: MiddlewareStack;
|
||||
private store: FilesystemStore;
|
||||
private options: Required<ISmarts3ServerOptions>;
|
||||
|
||||
constructor(options: ISmarts3ServerOptions = {}) {
|
||||
this.options = {
|
||||
port: 3000,
|
||||
address: '0.0.0.0',
|
||||
directory: plugins.path.join(process.cwd(), '.nogit/bucketsDir'),
|
||||
cleanSlate: false,
|
||||
silent: false,
|
||||
...options,
|
||||
};
|
||||
|
||||
this.store = new FilesystemStore(this.options.directory);
|
||||
this.router = new S3Router();
|
||||
this.middlewares = new MiddlewareStack();
|
||||
|
||||
this.setupMiddlewares();
|
||||
this.setupRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup middleware stack
|
||||
*/
|
||||
private setupMiddlewares(): void {
|
||||
// Logger middleware
|
||||
if (!this.options.silent) {
|
||||
this.middlewares.use(async (req, res, ctx, next) => {
|
||||
const start = Date.now();
|
||||
console.log(`→ ${req.method} ${req.url}`);
|
||||
console.log(` Headers:`, JSON.stringify(req.headers, null, 2).slice(0, 200));
|
||||
await next();
|
||||
const duration = Date.now() - start;
|
||||
console.log(`← ${req.method} ${req.url} - ${res.statusCode} (${duration}ms)`);
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Add authentication middleware
|
||||
// TODO: Add CORS middleware
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup routes
|
||||
*/
|
||||
private setupRoutes(): void {
|
||||
// Service level (/)
|
||||
this.router.get('/', ServiceController.listBuckets);
|
||||
|
||||
// Bucket level (/:bucket)
|
||||
this.router.put('/:bucket', BucketController.createBucket);
|
||||
this.router.delete('/:bucket', BucketController.deleteBucket);
|
||||
this.router.get('/:bucket', BucketController.listObjects);
|
||||
this.router.head('/:bucket', BucketController.headBucket);
|
||||
|
||||
// Object level (/:bucket/:key*)
|
||||
this.router.put('/:bucket/:key*', ObjectController.putObject);
|
||||
this.router.get('/:bucket/:key*', ObjectController.getObject);
|
||||
this.router.head('/:bucket/:key*', ObjectController.headObject);
|
||||
this.router.delete('/:bucket/:key*', ObjectController.deleteObject);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming HTTP request
|
||||
*/
|
||||
private async handleRequest(
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse
|
||||
): Promise<void> {
|
||||
const context = new S3Context(req, res, this.store);
|
||||
|
||||
try {
|
||||
// Execute middleware stack
|
||||
await this.middlewares.execute(req, res, context);
|
||||
|
||||
// Route to handler
|
||||
const match = this.router.match(context.method, context.url.pathname);
|
||||
|
||||
if (match) {
|
||||
context.params = match.params;
|
||||
await match.handler(req, res, context, match.params);
|
||||
} else {
|
||||
context.throw('NoSuchKey', 'The specified resource does not exist');
|
||||
}
|
||||
} catch (err) {
|
||||
await this.handleError(err, context, res);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle errors and send S3-compatible error responses
|
||||
*/
|
||||
private async handleError(
|
||||
err: any,
|
||||
context: S3Context,
|
||||
res: plugins.http.ServerResponse
|
||||
): Promise<void> {
|
||||
const s3Error = err instanceof S3Error ? err : S3Error.fromError(err);
|
||||
|
||||
if (!this.options.silent) {
|
||||
console.error(`[S3Error] ${s3Error.code}: ${s3Error.message}`);
|
||||
if (s3Error.status >= 500) {
|
||||
console.error(err.stack || err);
|
||||
}
|
||||
}
|
||||
|
||||
// Send error response
|
||||
const errorXml = s3Error.toXML();
|
||||
|
||||
res.writeHead(s3Error.status, {
|
||||
'Content-Type': 'application/xml',
|
||||
'Content-Length': Buffer.byteLength(errorXml),
|
||||
});
|
||||
|
||||
res.end(errorXml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the server
|
||||
*/
|
||||
public async start(): Promise<void> {
|
||||
// Initialize store
|
||||
await this.store.initialize();
|
||||
|
||||
// Clean slate if requested
|
||||
if (this.options.cleanSlate) {
|
||||
await this.store.reset();
|
||||
}
|
||||
|
||||
// Create HTTP server
|
||||
this.httpServer = plugins.http.createServer((req, res) => {
|
||||
this.handleRequest(req, res).catch((err) => {
|
||||
console.error('Fatal error in request handler:', err);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||
res.end('Internal Server Error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Start listening
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.httpServer!.listen(this.options.port, this.options.address, (err?: Error) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
if (!this.options.silent) {
|
||||
console.log(`S3 server listening on ${this.options.address}:${this.options.port}`);
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the server
|
||||
*/
|
||||
public async stop(): Promise<void> {
|
||||
if (!this.httpServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.httpServer!.close((err?: Error) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
if (!this.options.silent) {
|
||||
console.log('S3 server stopped');
|
||||
}
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.httpServer = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server port (useful for testing with random ports)
|
||||
*/
|
||||
public getPort(): number {
|
||||
if (!this.httpServer) {
|
||||
throw new Error('Server not started');
|
||||
}
|
||||
|
||||
const address = this.httpServer.address();
|
||||
if (typeof address === 'string') {
|
||||
throw new Error('Unix socket not supported');
|
||||
}
|
||||
|
||||
return address?.port || this.options.port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get S3 descriptor for client configuration
|
||||
*/
|
||||
public getS3Descriptor(): {
|
||||
accessKey: string;
|
||||
accessSecret: string;
|
||||
endpoint: string;
|
||||
port: number;
|
||||
useSsl: boolean;
|
||||
} {
|
||||
return {
|
||||
accessKey: 'S3RVER',
|
||||
accessSecret: 'S3RVER',
|
||||
endpoint: this.options.address === '0.0.0.0' ? '127.0.0.1' : this.options.address,
|
||||
port: this.getPort(),
|
||||
useSsl: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { S3Context } from '../classes/context.js';
|
||||
|
||||
/**
|
||||
* Bucket-level operations
|
||||
*/
|
||||
export class BucketController {
|
||||
/**
|
||||
* HEAD /:bucket - Check if bucket exists
|
||||
*/
|
||||
public static async headBucket(
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse,
|
||||
ctx: S3Context,
|
||||
params: Record<string, string>
|
||||
): Promise<void> {
|
||||
const { bucket } = params;
|
||||
|
||||
if (await ctx.store.bucketExists(bucket)) {
|
||||
ctx.status(200).send('');
|
||||
} else {
|
||||
ctx.throw('NoSuchBucket', 'The specified bucket does not exist');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /:bucket - Create bucket
|
||||
*/
|
||||
public static async createBucket(
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse,
|
||||
ctx: S3Context,
|
||||
params: Record<string, string>
|
||||
): Promise<void> {
|
||||
const { bucket } = params;
|
||||
|
||||
await ctx.store.createBucket(bucket);
|
||||
ctx.status(200).send('');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /:bucket - Delete bucket
|
||||
*/
|
||||
public static async deleteBucket(
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse,
|
||||
ctx: S3Context,
|
||||
params: Record<string, string>
|
||||
): Promise<void> {
|
||||
const { bucket } = params;
|
||||
|
||||
await ctx.store.deleteBucket(bucket);
|
||||
ctx.status(204).send('');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /:bucket - List objects
|
||||
* Supports both V1 and V2 listing (V2 uses list-type=2 query param)
|
||||
*/
|
||||
public static async listObjects(
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse,
|
||||
ctx: S3Context,
|
||||
params: Record<string, string>
|
||||
): Promise<void> {
|
||||
const { bucket } = params;
|
||||
const isV2 = ctx.query['list-type'] === '2';
|
||||
|
||||
const result = await ctx.store.listObjects(bucket, {
|
||||
prefix: ctx.query.prefix,
|
||||
delimiter: ctx.query.delimiter,
|
||||
maxKeys: ctx.query['max-keys'] ? parseInt(ctx.query['max-keys']) : 1000,
|
||||
continuationToken: ctx.query['continuation-token'],
|
||||
});
|
||||
|
||||
if (isV2) {
|
||||
// List Objects V2 response
|
||||
await ctx.sendXML({
|
||||
ListBucketResult: {
|
||||
'@_xmlns': 'http://s3.amazonaws.com/doc/2006-03-01/',
|
||||
Name: bucket,
|
||||
Prefix: result.prefix || '',
|
||||
MaxKeys: result.maxKeys,
|
||||
KeyCount: result.contents.length,
|
||||
IsTruncated: result.isTruncated,
|
||||
...(result.delimiter && { Delimiter: result.delimiter }),
|
||||
...(result.nextContinuationToken && {
|
||||
NextContinuationToken: result.nextContinuationToken,
|
||||
}),
|
||||
...(result.commonPrefixes.length > 0 && {
|
||||
CommonPrefixes: result.commonPrefixes.map((prefix) => ({
|
||||
Prefix: prefix,
|
||||
})),
|
||||
}),
|
||||
Contents: result.contents.map((obj) => ({
|
||||
Key: obj.key,
|
||||
LastModified: obj.lastModified.toISOString(),
|
||||
ETag: `"${obj.md5}"`,
|
||||
Size: obj.size,
|
||||
StorageClass: 'STANDARD',
|
||||
})),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// List Objects V1 response
|
||||
await ctx.sendXML({
|
||||
ListBucketResult: {
|
||||
'@_xmlns': 'http://s3.amazonaws.com/doc/2006-03-01/',
|
||||
Name: bucket,
|
||||
Prefix: result.prefix || '',
|
||||
MaxKeys: result.maxKeys,
|
||||
IsTruncated: result.isTruncated,
|
||||
...(result.delimiter && { Delimiter: result.delimiter }),
|
||||
...(result.commonPrefixes.length > 0 && {
|
||||
CommonPrefixes: result.commonPrefixes.map((prefix) => ({
|
||||
Prefix: prefix,
|
||||
})),
|
||||
}),
|
||||
Contents: result.contents.map((obj) => ({
|
||||
Key: obj.key,
|
||||
LastModified: obj.lastModified.toISOString(),
|
||||
ETag: `"${obj.md5}"`,
|
||||
Size: obj.size,
|
||||
StorageClass: 'STANDARD',
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { S3Context } from '../classes/context.js';
|
||||
|
||||
/**
|
||||
* Object-level operations
|
||||
*/
|
||||
export class ObjectController {
|
||||
/**
|
||||
* PUT /:bucket/:key* - Upload object or copy object
|
||||
*/
|
||||
public static async putObject(
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse,
|
||||
ctx: S3Context,
|
||||
params: Record<string, string>
|
||||
): Promise<void> {
|
||||
const { bucket, key } = params;
|
||||
|
||||
// Check if this is a COPY operation
|
||||
const copySource = ctx.headers['x-amz-copy-source'] as string | undefined;
|
||||
if (copySource) {
|
||||
return ObjectController.copyObject(req, res, ctx, params);
|
||||
}
|
||||
|
||||
// Extract metadata from headers
|
||||
const metadata: Record<string, string> = {};
|
||||
for (const [header, value] of Object.entries(ctx.headers)) {
|
||||
if (header.startsWith('x-amz-meta-')) {
|
||||
metadata[header] = value as string;
|
||||
}
|
||||
if (header === 'content-type' && value) {
|
||||
metadata['content-type'] = value as string;
|
||||
}
|
||||
if (header === 'cache-control' && value) {
|
||||
metadata['cache-control'] = value as string;
|
||||
}
|
||||
}
|
||||
|
||||
// If no content-type, default to binary/octet-stream
|
||||
if (!metadata['content-type']) {
|
||||
metadata['content-type'] = 'binary/octet-stream';
|
||||
}
|
||||
|
||||
// Stream upload
|
||||
const result = await ctx.store.putObject(bucket, key, ctx.getRequestStream(), metadata);
|
||||
|
||||
ctx.setHeader('ETag', `"${result.md5}"`);
|
||||
ctx.status(200).send('');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /:bucket/:key* - Download object
|
||||
*/
|
||||
public static async getObject(
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse,
|
||||
ctx: S3Context,
|
||||
params: Record<string, string>
|
||||
): Promise<void> {
|
||||
const { bucket, key } = params;
|
||||
|
||||
// Parse Range header if present
|
||||
const rangeHeader = ctx.headers.range as string | undefined;
|
||||
let range: { start: number; end: number } | undefined;
|
||||
|
||||
if (rangeHeader) {
|
||||
const matches = rangeHeader.match(/bytes=(\d+)-(\d*)/);
|
||||
if (matches) {
|
||||
const start = parseInt(matches[1]);
|
||||
const end = matches[2] ? parseInt(matches[2]) : undefined;
|
||||
range = { start, end: end || start + 1024 * 1024 }; // Default to 1MB if no end
|
||||
}
|
||||
}
|
||||
|
||||
// Get object
|
||||
const object = await ctx.store.getObject(bucket, key, range);
|
||||
|
||||
// Set response headers
|
||||
ctx.setHeader('ETag', `"${object.md5}"`);
|
||||
ctx.setHeader('Last-Modified', object.lastModified.toUTCString());
|
||||
ctx.setHeader('Content-Type', object.metadata['content-type'] || 'binary/octet-stream');
|
||||
ctx.setHeader('Accept-Ranges', 'bytes');
|
||||
|
||||
// Handle custom metadata headers
|
||||
for (const [key, value] of Object.entries(object.metadata)) {
|
||||
if (key.startsWith('x-amz-meta-')) {
|
||||
ctx.setHeader(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (range) {
|
||||
ctx.status(206);
|
||||
ctx.setHeader('Content-Length', (range.end - range.start + 1).toString());
|
||||
ctx.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${object.size}`);
|
||||
} else {
|
||||
ctx.status(200);
|
||||
ctx.setHeader('Content-Length', object.size.toString());
|
||||
}
|
||||
|
||||
// Stream response
|
||||
await ctx.send(object.content!);
|
||||
}
|
||||
|
||||
/**
|
||||
* HEAD /:bucket/:key* - Get object metadata
|
||||
*/
|
||||
public static async headObject(
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse,
|
||||
ctx: S3Context,
|
||||
params: Record<string, string>
|
||||
): Promise<void> {
|
||||
const { bucket, key } = params;
|
||||
|
||||
// Get object (without content)
|
||||
const object = await ctx.store.getObject(bucket, key);
|
||||
|
||||
// Set response headers (same as GET but no body)
|
||||
ctx.setHeader('ETag', `"${object.md5}"`);
|
||||
ctx.setHeader('Last-Modified', object.lastModified.toUTCString());
|
||||
ctx.setHeader('Content-Type', object.metadata['content-type'] || 'binary/octet-stream');
|
||||
ctx.setHeader('Content-Length', object.size.toString());
|
||||
ctx.setHeader('Accept-Ranges', 'bytes');
|
||||
|
||||
// Handle custom metadata headers
|
||||
for (const [key, value] of Object.entries(object.metadata)) {
|
||||
if (key.startsWith('x-amz-meta-')) {
|
||||
ctx.setHeader(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.status(200).send('');
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /:bucket/:key* - Delete object
|
||||
*/
|
||||
public static async deleteObject(
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse,
|
||||
ctx: S3Context,
|
||||
params: Record<string, string>
|
||||
): Promise<void> {
|
||||
const { bucket, key } = params;
|
||||
|
||||
await ctx.store.deleteObject(bucket, key);
|
||||
ctx.status(204).send('');
|
||||
}
|
||||
|
||||
/**
|
||||
* COPY operation (PUT with x-amz-copy-source header)
|
||||
*/
|
||||
private static async copyObject(
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse,
|
||||
ctx: S3Context,
|
||||
params: Record<string, string>
|
||||
): Promise<void> {
|
||||
const { bucket: destBucket, key: destKey } = params;
|
||||
const copySource = ctx.headers['x-amz-copy-source'] as string;
|
||||
|
||||
// Parse source bucket and key from copy source
|
||||
// Format: /bucket/key or bucket/key
|
||||
const sourcePath = copySource.startsWith('/') ? copySource.slice(1) : copySource;
|
||||
const firstSlash = sourcePath.indexOf('/');
|
||||
const srcBucket = decodeURIComponent(sourcePath.slice(0, firstSlash));
|
||||
const srcKey = decodeURIComponent(sourcePath.slice(firstSlash + 1));
|
||||
|
||||
// Get metadata directive (COPY or REPLACE)
|
||||
const metadataDirective = (ctx.headers['x-amz-metadata-directive'] as string)?.toUpperCase() || 'COPY';
|
||||
|
||||
// Extract new metadata if REPLACE
|
||||
let newMetadata: Record<string, string> | undefined;
|
||||
if (metadataDirective === 'REPLACE') {
|
||||
newMetadata = {};
|
||||
for (const [header, value] of Object.entries(ctx.headers)) {
|
||||
if (header.startsWith('x-amz-meta-')) {
|
||||
newMetadata[header] = value as string;
|
||||
}
|
||||
if (header === 'content-type' && value) {
|
||||
newMetadata['content-type'] = value as string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Perform copy
|
||||
const result = await ctx.store.copyObject(
|
||||
srcBucket,
|
||||
srcKey,
|
||||
destBucket,
|
||||
destKey,
|
||||
metadataDirective as 'COPY' | 'REPLACE',
|
||||
newMetadata
|
||||
);
|
||||
|
||||
// Send XML response
|
||||
await ctx.sendXML({
|
||||
CopyObjectResult: {
|
||||
LastModified: new Date().toISOString(),
|
||||
ETag: `"${result.md5}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { S3Context } from '../classes/context.js';
|
||||
|
||||
/**
|
||||
* Service-level operations (root /)
|
||||
*/
|
||||
export class ServiceController {
|
||||
/**
|
||||
* GET / - List all buckets
|
||||
*/
|
||||
public static async listBuckets(
|
||||
req: plugins.http.IncomingMessage,
|
||||
res: plugins.http.ServerResponse,
|
||||
ctx: S3Context,
|
||||
params: Record<string, string>
|
||||
): Promise<void> {
|
||||
const buckets = await ctx.store.listBuckets();
|
||||
|
||||
await ctx.sendXML({
|
||||
ListAllMyBucketsResult: {
|
||||
'@_xmlns': 'http://s3.amazonaws.com/doc/2006-03-01/',
|
||||
Owner: {
|
||||
ID: '123456789000',
|
||||
DisplayName: 'S3rver',
|
||||
},
|
||||
Buckets: {
|
||||
Bucket: buckets.map((bucket) => ({
|
||||
Name: bucket.name,
|
||||
CreationDate: bucket.creationDate.toISOString(),
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
283
ts/index.ts
283
ts/index.ts
@@ -1,46 +1,268 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as paths from './paths.js';
|
||||
import { Smarts3Server } from './classes/smarts3-server.js';
|
||||
|
||||
export interface ISmarts3ContructorOptions {
|
||||
/**
|
||||
* Authentication configuration
|
||||
*/
|
||||
export interface IAuthConfig {
|
||||
enabled: boolean;
|
||||
credentials: Array<{
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS configuration
|
||||
*/
|
||||
export interface ICorsConfig {
|
||||
enabled: boolean;
|
||||
allowedOrigins?: string[];
|
||||
allowedMethods?: string[];
|
||||
allowedHeaders?: string[];
|
||||
exposedHeaders?: string[];
|
||||
maxAge?: number;
|
||||
allowCredentials?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logging configuration
|
||||
*/
|
||||
export interface ILoggingConfig {
|
||||
level?: 'error' | 'warn' | 'info' | 'debug';
|
||||
format?: 'text' | 'json';
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request limits configuration
|
||||
*/
|
||||
export interface ILimitsConfig {
|
||||
maxObjectSize?: number;
|
||||
maxMetadataSize?: number;
|
||||
requestTimeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multipart upload configuration
|
||||
*/
|
||||
export interface IMultipartConfig {
|
||||
expirationDays?: number;
|
||||
cleanupIntervalMinutes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server configuration
|
||||
*/
|
||||
export interface IServerConfig {
|
||||
port?: number;
|
||||
address?: string;
|
||||
silent?: boolean;
|
||||
region?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage configuration
|
||||
*/
|
||||
export interface IStorageConfig {
|
||||
directory?: string;
|
||||
cleanSlate?: boolean;
|
||||
}
|
||||
|
||||
export class Smarts3 {
|
||||
/**
|
||||
* Erasure coding configuration
|
||||
*/
|
||||
export interface IErasureConfig {
|
||||
dataShards?: number;
|
||||
parityShards?: number;
|
||||
chunkSizeBytes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive configuration for multi-drive support
|
||||
*/
|
||||
export interface IDriveConfig {
|
||||
paths: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cluster configuration for distributed mode
|
||||
*/
|
||||
export interface IClusterConfig {
|
||||
enabled: boolean;
|
||||
nodeId?: string;
|
||||
quicPort?: number;
|
||||
seedNodes?: string[];
|
||||
erasure?: IErasureConfig;
|
||||
drives?: IDriveConfig;
|
||||
heartbeatIntervalMs?: number;
|
||||
heartbeatTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete smartstorage configuration
|
||||
*/
|
||||
export interface ISmartStorageConfig {
|
||||
server?: IServerConfig;
|
||||
storage?: IStorageConfig;
|
||||
auth?: IAuthConfig;
|
||||
cors?: ICorsConfig;
|
||||
logging?: ILoggingConfig;
|
||||
limits?: ILimitsConfig;
|
||||
multipart?: IMultipartConfig;
|
||||
cluster?: IClusterConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration values
|
||||
*/
|
||||
const DEFAULT_CONFIG: ISmartStorageConfig = {
|
||||
server: {
|
||||
port: 3000,
|
||||
address: '0.0.0.0',
|
||||
silent: false,
|
||||
region: 'us-east-1',
|
||||
},
|
||||
storage: {
|
||||
directory: paths.bucketsDir,
|
||||
cleanSlate: false,
|
||||
},
|
||||
auth: {
|
||||
enabled: false,
|
||||
credentials: [
|
||||
{
|
||||
accessKeyId: 'STORAGE',
|
||||
secretAccessKey: 'STORAGE',
|
||||
},
|
||||
],
|
||||
},
|
||||
cors: {
|
||||
enabled: false,
|
||||
allowedOrigins: ['*'],
|
||||
allowedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS'],
|
||||
allowedHeaders: ['*'],
|
||||
exposedHeaders: ['ETag', 'x-amz-request-id', 'x-amz-version-id'],
|
||||
maxAge: 86400,
|
||||
allowCredentials: false,
|
||||
},
|
||||
logging: {
|
||||
level: 'info',
|
||||
format: 'text',
|
||||
enabled: true,
|
||||
},
|
||||
limits: {
|
||||
maxObjectSize: 5 * 1024 * 1024 * 1024, // 5GB
|
||||
maxMetadataSize: 2048,
|
||||
requestTimeout: 300000, // 5 minutes
|
||||
},
|
||||
multipart: {
|
||||
expirationDays: 7,
|
||||
cleanupIntervalMinutes: 60,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge user config with defaults (deep merge)
|
||||
*/
|
||||
function mergeConfig(userConfig: ISmartStorageConfig): Required<ISmartStorageConfig> {
|
||||
return {
|
||||
server: {
|
||||
...DEFAULT_CONFIG.server!,
|
||||
...(userConfig.server || {}),
|
||||
},
|
||||
storage: {
|
||||
...DEFAULT_CONFIG.storage!,
|
||||
...(userConfig.storage || {}),
|
||||
},
|
||||
auth: {
|
||||
...DEFAULT_CONFIG.auth!,
|
||||
...(userConfig.auth || {}),
|
||||
},
|
||||
cors: {
|
||||
...DEFAULT_CONFIG.cors!,
|
||||
...(userConfig.cors || {}),
|
||||
},
|
||||
logging: {
|
||||
...DEFAULT_CONFIG.logging!,
|
||||
...(userConfig.logging || {}),
|
||||
},
|
||||
limits: {
|
||||
...DEFAULT_CONFIG.limits!,
|
||||
...(userConfig.limits || {}),
|
||||
},
|
||||
multipart: {
|
||||
...DEFAULT_CONFIG.multipart!,
|
||||
...(userConfig.multipart || {}),
|
||||
},
|
||||
...(userConfig.cluster ? { cluster: userConfig.cluster } : {}),
|
||||
} as Required<ISmartStorageConfig>;
|
||||
}
|
||||
|
||||
/**
|
||||
* IPC command type map for RustBridge
|
||||
*/
|
||||
type TRustStorageCommands = {
|
||||
start: { params: { config: Required<ISmartStorageConfig> }; result: {} };
|
||||
stop: { params: {}; result: {} };
|
||||
createBucket: { params: { name: string }; result: {} };
|
||||
};
|
||||
|
||||
/**
|
||||
* Main SmartStorage class - production-ready S3-compatible storage server
|
||||
*/
|
||||
export class SmartStorage {
|
||||
// STATIC
|
||||
public static async createAndStart(
|
||||
optionsArg: ConstructorParameters<typeof Smarts3>[0],
|
||||
) {
|
||||
const smartS3Instance = new Smarts3(optionsArg);
|
||||
await smartS3Instance.start();
|
||||
return smartS3Instance;
|
||||
public static async createAndStart(configArg: ISmartStorageConfig = {}) {
|
||||
const smartStorageInstance = new SmartStorage(configArg);
|
||||
await smartStorageInstance.start();
|
||||
return smartStorageInstance;
|
||||
}
|
||||
|
||||
// INSTANCE
|
||||
public options: ISmarts3ContructorOptions;
|
||||
public s3Instance: Smarts3Server;
|
||||
public config: Required<ISmartStorageConfig>;
|
||||
private bridge: InstanceType<typeof plugins.RustBridge<TRustStorageCommands>>;
|
||||
|
||||
constructor(optionsArg: ISmarts3ContructorOptions) {
|
||||
this.options = optionsArg;
|
||||
constructor(configArg: ISmartStorageConfig = {}) {
|
||||
this.config = mergeConfig(configArg);
|
||||
this.bridge = new plugins.RustBridge<TRustStorageCommands>({
|
||||
binaryName: 'ruststorage',
|
||||
localPaths: [
|
||||
plugins.path.join(paths.packageDir, 'dist_rust', 'ruststorage'),
|
||||
],
|
||||
readyTimeoutMs: 30000,
|
||||
requestTimeoutMs: 300000,
|
||||
});
|
||||
}
|
||||
|
||||
public async start() {
|
||||
this.s3Instance = new Smarts3Server({
|
||||
port: this.options.port || 3000,
|
||||
address: '0.0.0.0',
|
||||
directory: paths.bucketsDir,
|
||||
cleanSlate: this.options.cleanSlate || false,
|
||||
silent: false,
|
||||
});
|
||||
await this.s3Instance.start();
|
||||
console.log('s3 server is running');
|
||||
const spawned = await this.bridge.spawn();
|
||||
if (!spawned) {
|
||||
throw new Error('Failed to spawn ruststorage binary. Make sure it is compiled (pnpm build).');
|
||||
}
|
||||
await this.bridge.sendCommand('start', { config: this.config });
|
||||
|
||||
if (!this.config.server.silent) {
|
||||
console.log('storage server is running');
|
||||
}
|
||||
}
|
||||
|
||||
public async getS3Descriptor(
|
||||
public async getStorageDescriptor(
|
||||
optionsArg?: Partial<plugins.tsclass.storage.IS3Descriptor>,
|
||||
): Promise<plugins.tsclass.storage.IS3Descriptor> {
|
||||
const descriptor = this.s3Instance.getS3Descriptor();
|
||||
const cred = this.config.auth.credentials[0] || {
|
||||
accessKeyId: 'STORAGE',
|
||||
secretAccessKey: 'STORAGE',
|
||||
};
|
||||
|
||||
const descriptor: plugins.tsclass.storage.IS3Descriptor = {
|
||||
endpoint: this.config.server.address === '0.0.0.0' ? 'localhost' : this.config.server.address!,
|
||||
port: this.config.server.port!,
|
||||
useSsl: false,
|
||||
accessKey: cred.accessKeyId,
|
||||
accessSecret: cred.secretAccessKey,
|
||||
bucketName: '',
|
||||
};
|
||||
|
||||
return {
|
||||
...descriptor,
|
||||
...(optionsArg ? optionsArg : {}),
|
||||
@@ -48,17 +270,12 @@ export class Smarts3 {
|
||||
}
|
||||
|
||||
public async createBucket(bucketNameArg: string) {
|
||||
const smartbucketInstance = new plugins.smartbucket.SmartBucket(
|
||||
await this.getS3Descriptor(),
|
||||
);
|
||||
const bucket = await smartbucketInstance.createBucket(bucketNameArg);
|
||||
return bucket;
|
||||
await this.bridge.sendCommand('createBucket', { name: bucketNameArg });
|
||||
return { name: bucketNameArg };
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
await this.s3Instance.stop();
|
||||
await this.bridge.sendCommand('stop', {});
|
||||
this.bridge.kill();
|
||||
}
|
||||
}
|
||||
|
||||
// Export the custom server class for direct use
|
||||
export { Smarts3Server } from './classes/smarts3-server.js';
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
// node native
|
||||
import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
import * as crypto from 'crypto';
|
||||
import * as url from 'url';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export { path, http, crypto, url, fs };
|
||||
export { path };
|
||||
|
||||
// @push.rocks scope
|
||||
import * as smartbucket from '@push.rocks/smartbucket';
|
||||
import * as smartfile from '@push.rocks/smartfile';
|
||||
import * as smartpath from '@push.rocks/smartpath';
|
||||
import { SmartXml } from '@push.rocks/smartxml';
|
||||
import { RustBridge } from '@push.rocks/smartrust';
|
||||
|
||||
export { smartbucket, smartfile, smartpath, SmartXml };
|
||||
export { smartpath, RustBridge };
|
||||
|
||||
// @tsclass scope
|
||||
import * as tsclass from '@tsclass/tsclass';
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
|
||||
// Create a singleton instance of SmartXml
|
||||
const smartXmlInstance = new plugins.SmartXml();
|
||||
|
||||
/**
|
||||
* Parse XML string to JavaScript object
|
||||
*/
|
||||
export function parseXml(xmlString: string): any {
|
||||
return smartXmlInstance.parseXmlToObject(xmlString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert JavaScript object to XML string with XML declaration
|
||||
*/
|
||||
export function createXml(obj: any, options: { format?: boolean } = {}): string {
|
||||
const xml = smartXmlInstance.createXmlFromObject(obj);
|
||||
|
||||
// Ensure XML declaration is present
|
||||
if (!xml.startsWith('<?xml')) {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n${xml}`;
|
||||
}
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create S3-compatible XML responses with proper namespace
|
||||
*/
|
||||
export function createS3Xml(rootElement: string, content: any, namespace = 'http://s3.amazonaws.com/doc/2006-03-01/'): string {
|
||||
const obj: any = {
|
||||
[rootElement]: {
|
||||
'@_xmlns': namespace,
|
||||
...content,
|
||||
},
|
||||
};
|
||||
|
||||
return createXml(obj, { format: true });
|
||||
}
|
||||
Reference in New Issue
Block a user