diff --git a/README.md b/README.md index 1d8b4d0..315f3fa 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ [ACE-Step 1.5 HTTP API](https://github.com/ace-step/ACE-Step-1.5/blob/main/docs/en/API.md) emulator backed by **[acestep.cpp](https://github.com/audiohacking/acestep.cpp)** + **[Bun](https://bun.sh)**. +→ **Full API reference**: [`docs/API.md`](docs/API.md) + ## Bundled acestep.cpp (v0.0.3) `bun run build` downloads the correct asset from **[acestep.cpp releases v0.0.3](https://github.com/audiohacking/acestep.cpp/releases/tag/v0.0.3)** for the **current** OS/arch, installs them under `acestep-runtime/bin/`, compiles `dist/acestep-api`, then copies `acestep-runtime` next to the executable: @@ -48,6 +50,44 @@ export ACESTEP_VAE_MODEL=vae-BF16.gguf Per-request `lm_model_path` and **`ACESTEP_MODEL_MAP`** values use the same resolution rules. +## Multi-model support (GET /v1/models + per-request `model`) + +`GET /v1/models` **automatically scans `ACESTEP_MODELS_DIR`** for `.gguf` files and returns them as the available model list. No extra configuration is required. + +```bash +export ACESTEP_MODELS_DIR="$HOME/models/acestep" +# /v1/models will list every .gguf file found there, e.g.: +# ["acestep-v15-turbo-Q8_0.gguf", "acestep-v15-turbo-shift3-Q8_0.gguf"] +``` + +Use the discovered filename as the `model` value per-request: + +```bash +curl http://localhost:8001/v1/models # discover available names + +curl -X POST http://localhost:8001/release_task \ + -H 'Content-Type: application/json' \ + -d '{"prompt": "jazz piano trio", "model": "acestep-v15-turbo-shift3-Q8_0.gguf"}' +``` + +**Optional: logical names via `ACESTEP_MODEL_MAP`** — map friendly names to GGUF filenames: + +```bash +export ACESTEP_MODELS_DIR="$HOME/models/acestep" +export ACESTEP_MODEL_MAP='{"acestep-v15-turbo":"acestep-v15-turbo-Q8_0.gguf","acestep-v15-turbo-shift3":"acestep-v15-turbo-shift3-Q8_0.gguf"}' +# Now use the short names: "model": "acestep-v15-turbo" +``` + +**Optional: `ACESTEP_MODELS` as a filter/gate** — restrict the list to a subset: + +```bash +export ACESTEP_MODELS_DIR="$HOME/models/acestep" +export ACESTEP_MODELS="acestep-v15-turbo-Q8_0.gguf,acestep-v15-turbo-shift3-Q8_0.gguf" +# Only those two filenames appear in /v1/models even if more .gguf files exist +``` + +Generation parameters (`inference_steps`, `guidance_scale`, `bpm`, etc.) are **always per-request** and are never fixed by environment variables. + ## Run (source) ```bash @@ -97,7 +137,7 @@ Worker uses **`src_audio_path`** when set, otherwise **`reference_audio_path`**; ## API emulation notes -See earlier revisions for full AceStep 1.5 route mapping. **`/format_input`** and **`/create_random_sample`** remain shape-compatible stubs (no separate LM HTTP service). +See [`docs/API.md`](docs/API.md) for the full endpoint reference. **`/format_input`** and **`/create_random_sample`** are shape-compatible stubs (no separate LM HTTP service required). ## GitHub Actions diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..7ee3a84 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,655 @@ +# ACE-Step API Documentation + +This service emulates the [ACE-Step 1.5 HTTP API](https://github.com/ace-step/ACE-Step-1.5/blob/main/docs/en/API.md) backed by **[acestep.cpp](https://github.com/audiohacking/acestep.cpp)** (`ace-lm` + `ace-synth`). + +**Basic workflow:** + +1. Submit a task with `POST /release_task` → receive a `task_id`. +2. Poll `POST /query_result` until `status` is `1` (succeeded) or `2` (failed). +3. Download the audio with `GET /v1/audio?path=...` using the URL returned in the result. + +--- + +## Table of Contents + +- [Authentication](#1-authentication) +- [Response Format](#2-response-format) +- [Task Status Codes](#3-task-status-codes) +- [Create Generation Task](#4-create-generation-task) +- [Batch Query Task Results](#5-batch-query-task-results) +- [Format Input](#6-format-input) +- [Get Random Sample](#7-get-random-sample) +- [List Available Models](#8-list-available-models) +- [Server Statistics](#9-server-statistics) +- [Download Audio Files](#10-download-audio-files) +- [Health Check](#11-health-check) +- [Environment Variables](#12-environment-variables) + +--- + +## 1. Authentication + +API key authentication is optional. When `ACESTEP_API_KEY` is set, every request must supply the key via one of: + +**Body field (`ai_token`)**: +```json +{ "ai_token": "your-api-key", "prompt": "upbeat pop song" } +``` + +**Authorization header**: +``` +Authorization: Bearer your-api-key +``` + +--- + +## 2. Response Format + +All endpoints return a unified wrapper: + +```json +{ + "data": { }, + "code": 200, + "error": null, + "timestamp": 1700000000000, + "extra": null +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `data` | any | Actual response payload | +| `code` | int | Status code (`200` = success) | +| `error` | string\|null | Error message (null on success) | +| `timestamp` | int | Response timestamp (ms) | +| `extra` | any | Extra information (usually null) | + +**Error responses** use `{ "detail": "..." }` with the appropriate HTTP status code. + +--- + +## 3. Task Status Codes + +| Code | Meaning | +|------|---------| +| `0` | Queued or running | +| `1` | Succeeded — result is ready | +| `2` | Failed | + +--- + +## 4. Create Generation Task + +### 4.1 Endpoint + +- **URL**: `POST /release_task` +- **Content-Type**: `application/json`, `multipart/form-data`, or `application/x-www-form-urlencoded` + +### 4.2 Request Parameters + +Both **snake_case** and **camelCase** aliases are accepted. Metadata can also be passed in a nested `metas` / `metadata` / `user_metadata` object. + +#### Basic Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `prompt` | string | `""` | Music description (alias: `caption`) | +| `lyrics` | string | `""` | Lyrics content | +| `thinking` | bool | `false` | Run 5Hz LM to generate audio codes (lm-dit mode) | +| `vocal_language` | string | `"en"` | Lyrics language (`en`, `zh`, `ja`, …) | +| `audio_format` | string | `"mp3"` | Output format: `mp3` or `wav` | + +#### Sample / Description Mode + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `sample_mode` | bool | `false` | Generate from a short natural-language description | +| `sample_query` | string | `""` | Description text (aliases: `description`, `desc`) | +| `use_format` | bool | `false` | Let LM enhance caption and lyrics (aliases: `format`) | + +#### Model Selection + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `model` | string | *(default model)* | DiT model name — use `GET /v1/models` to list available names | + +> When `model` is omitted the server uses the default model. Use `GET /v1/models` to discover available names and `ACESTEP_MODEL_MAP` to register them (see [Environment Variables](#12-environment-variables)). + +#### Music Attributes + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `bpm` | int | null | Tempo in BPM (30–300) | +| `key_scale` | string | `""` | Key/scale (e.g. `"C Major"`, `"Am"`) — aliases: `keyscale`, `keyScale` | +| `time_signature` | string | `""` | `"2"`, `"3"`, `"4"`, or `"6"` — aliases: `timesignature`, `timeSignature` | +| `audio_duration` | float | null | Duration in seconds (10–600) — aliases: `duration`, `target_duration` | + +#### Audio Codes + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `audio_code_string` | string or string[] | `""` | Pre-computed 5Hz audio tokens for lm-dit (alias: `audioCodeString`) | + +#### Generation Control + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `inference_steps` | int | `8` | Diffusion steps (turbo: 1–20; base: 1–200) | +| `guidance_scale` | float | `7.0` | Guidance coefficient (base model only) | +| `use_random_seed` | bool | `true` | Use a random seed | +| `seed` | int | `-1` | Fixed seed (when `use_random_seed=false`) | +| `batch_size` | int | `2` | Number of clips to generate (1–8) | + +#### Advanced DiT Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `shift` | float | `3.0` | Timestep shift (1.0–5.0; base models only) | +| `infer_method` | string | `"ode"` | `"ode"` (Euler) or `"sde"` (stochastic) | +| `timesteps` | string | null | Custom comma-separated timesteps (overrides `inference_steps` + `shift`) | +| `use_adg` | bool | `false` | Adaptive Dual Guidance (base model only) | +| `cfg_interval_start` | float | `0.0` | CFG start ratio (0.0–1.0) | +| `cfg_interval_end` | float | `1.0` | CFG end ratio (0.0–1.0) | + +#### 5Hz LM Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `lm_model_path` | string | null | LM checkpoint name / path override (alias: `lmModelPath`) | +| `lm_temperature` | float | `0.85` | Sampling temperature | +| `lm_cfg_scale` | float | `2.5` | CFG scale (>1 enables CFG) | +| `lm_negative_prompt` | string | `"NO USER INPUT"` | Negative prompt for CFG | +| `lm_top_k` | int | null | Top-k (0/null disables) | +| `lm_top_p` | float | `0.9` | Top-p | +| `lm_repetition_penalty` | float | `1.0` | Repetition penalty | + +#### LM Chain-of-Thought Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `use_cot_caption` | bool | `true` | Let LM rewrite caption via CoT (aliases: `cot_caption`) | +| `use_cot_language` | bool | `true` | Let LM detect vocal language via CoT (aliases: `cot_language`) | +| `constrained_decoding` | bool | `true` | FSM-constrained decoding for structured output (aliases: `constrained`) | + +#### Edit / Reference Audio (JSON path or uploaded file) + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `task_type` | string | `"text2music"` | `text2music`, `cover`, `repaint`, `lego`, `extract`, `complete` | +| `reference_audio_path` | string | null | Server path to reference audio (Style Transfer) | +| `src_audio_path` | string | null | Server path to source audio (Cover / Repainting) | +| `instruction` | string | auto | Edit instruction | +| `repainting_start` | float | `0.0` | Repainting start time (seconds) | +| `repainting_end` | float | null | Repainting end time (-1 = end of audio) | +| `audio_cover_strength` | float | `1.0` | Cover strength (0.0–1.0) | + +#### File Upload (multipart/form-data) + +Supply audio files as form parts instead of server paths: + +| Field | Description | +|-------|-------------| +| `reference_audio` / `ref_audio` | Reference audio file (style transfer) | +| `src_audio` / `ctx_audio` | Source audio file (cover / repaint) | + +> `task_type` values `cover`, `repaint`, and `lego` require either a file upload or the corresponding `_path` field — the API returns **400** otherwise. + +### 4.3 Response + +```json +{ + "data": { + "task_id": "550e8400-e29b-41d4-a716-446655440000", + "status": "queued", + "queue_position": 1 + }, + "code": 200, + "error": null, + "timestamp": 1700000000000, + "extra": null +} +``` + +### 4.4 Examples + +**Basic JSON request:** +```bash +curl -X POST http://localhost:8001/release_task \ + -H 'Content-Type: application/json' \ + -d '{"prompt": "upbeat pop song", "lyrics": "Hello world", "inference_steps": 8}' +``` + +**With `thinking=true` (LM generates codes + fills missing metadata):** +```bash +curl -X POST http://localhost:8001/release_task \ + -H 'Content-Type: application/json' \ + -d '{"prompt": "upbeat pop song", "lyrics": "Hello world", "thinking": true}' +``` + +**Description-driven generation:** +```bash +curl -X POST http://localhost:8001/release_task \ + -H 'Content-Type: application/json' \ + -d '{"sample_query": "a soft Bengali love song for a quiet evening", "thinking": true}' +``` + +**Select a specific model:** +```bash +curl -X POST http://localhost:8001/release_task \ + -H 'Content-Type: application/json' \ + -d '{"prompt": "electronic dance music", "model": "acestep-v15-turbo-shift3", "thinking": true}' +``` + +**Custom timesteps:** +```bash +curl -X POST http://localhost:8001/release_task \ + -H 'Content-Type: application/json' \ + -d '{"prompt": "jazz piano trio", "timesteps": "0.97,0.76,0.615,0.5,0.395,0.28,0.18,0.085,0"}' +``` + +**File upload (cover task):** +```bash +curl -X POST http://localhost:8001/release_task \ + -F "prompt=remix this song" \ + -F "src_audio=@/path/to/local/song.mp3" \ + -F "task_type=repaint" +``` + +--- + +## 5. Batch Query Task Results + +### 5.1 Endpoint + +- **URL**: `POST /query_result` +- **Content-Type**: `application/json` or `application/x-www-form-urlencoded` + +### 5.2 Request Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `task_id_list` | string (JSON array) or array | Task IDs to query | + +### 5.3 Response + +```json +{ + "data": [ + { + "task_id": "550e8400-e29b-41d4-a716-446655440000", + "status": 1, + "result": "[{\"file\": \"/v1/audio?path=...\", \"wave\": \"\", \"status\": 1, \"create_time\": 1700000000, \"env\": \"development\", \"prompt\": \"upbeat pop song\", \"lyrics\": \"Hello world\", \"metas\": {\"bpm\": 120, \"duration\": 30, \"genres\": \"\", \"keyscale\": \"C Major\", \"timesignature\": \"4\"}, \"generation_info\": \"acestep.cpp\", \"seed_value\": \"12345\", \"lm_model\": \"acestep-5Hz-lm-0.6B\", \"dit_model\": \"acestep-v15-turbo\"}]" + } + ], + "code": 200, + "error": null, + "timestamp": 1700000000000, + "extra": null +} +``` + +**`result` field** (JSON string — parse to obtain): + +| Field | Type | Description | +|-------|------|-------------| +| `file` | string | Audio URL for `GET /v1/audio` | +| `wave` | string | Waveform data (empty) | +| `status` | int | `0` in-progress, `1` success, `2` failed | +| `create_time` | int | Unix timestamp | +| `env` | string | Environment identifier | +| `prompt` | string | Caption used | +| `lyrics` | string | Lyrics used | +| `metas` | object | `{bpm, duration, genres, keyscale, timesignature}` | +| `generation_info` | string | Generation summary | +| `seed_value` | string | Seed(s) used | +| `lm_model` | string | LM model name | +| `dit_model` | string | DiT model name | + +### 5.4 Example + +```bash +curl -X POST http://localhost:8001/query_result \ + -H 'Content-Type: application/json' \ + -d '{"task_id_list": ["550e8400-e29b-41d4-a716-446655440000"]}' +``` + +--- + +## 6. Format Input + +### 6.1 Endpoint + +- **URL**: `POST /format_input` +- **Content-Type**: `application/json` or `application/x-www-form-urlencoded` + +Uses LLM to enhance and format user-provided caption and lyrics. *(This is a shape-compatible stub; actual LM enhancement is performed per-task when `use_format=true` in `/release_task`.)* + +### 6.2 Request Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `prompt` | string | `""` | Music description (alias: `caption`) | +| `lyrics` | string | `""` | Lyrics content | +| `temperature` | float | `0.85` | LM sampling temperature | +| `param_obj` | string (JSON) | `"{}"` | Metadata hints: `duration`, `bpm`, `key`, `time_signature`, `language` | + +### 6.3 Response + +```json +{ + "data": { + "caption": "Enhanced music description", + "lyrics": "Formatted lyrics...", + "bpm": 120, + "key_scale": "C Major", + "time_signature": "4", + "duration": 180, + "vocal_language": "en" + }, + "code": 200, + "error": null, + "timestamp": 1700000000000, + "extra": null +} +``` + +### 6.4 Example + +```bash +curl -X POST http://localhost:8001/format_input \ + -H 'Content-Type: application/json' \ + -d '{"prompt": "pop rock", "lyrics": "Walking down the street", "param_obj": "{\"duration\": 180}"}' +``` + +--- + +## 7. Get Random Sample + +### 7.1 Endpoint + +- **URL**: `POST /create_random_sample` +- **Content-Type**: `application/json` or `application/x-www-form-urlencoded` + +Returns a preset sample for form auto-fill. + +### 7.2 Request Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `sample_type` | string | `"simple_mode"` | `"simple_mode"` or `"custom_mode"` | + +### 7.3 Response + +```json +{ + "data": { + "caption": "Upbeat pop song with guitar accompaniment", + "lyrics": "[Verse 1]\nSunshine on my face...", + "bpm": 120, + "key_scale": "G Major", + "time_signature": "4", + "duration": 180, + "vocal_language": "en" + }, + "code": 200, + "error": null, + "timestamp": 1700000000000, + "extra": null +} +``` + +### 7.4 Example + +```bash +curl -X POST http://localhost:8001/create_random_sample \ + -H 'Content-Type: application/json' \ + -d '{"sample_type": "simple_mode"}' +``` + +--- + +## 8. List Available Models + +### 8.1 Endpoint + +- **URL**: `GET /v1/models` + +Returns the DiT models available on this server. The list is discovered automatically by scanning `ACESTEP_MODELS_DIR` for `.gguf` files. `ACESTEP_MODEL_MAP` (if set) overrides discovery with explicit logical names. `ACESTEP_MODELS` acts as a filter/gate on the discovered list. + +### 8.2 Response + +```json +{ + "data": { + "models": [ + { "name": "acestep-v15-turbo-Q8_0.gguf", "is_default": true }, + { "name": "acestep-v15-turbo-shift3-Q8_0.gguf", "is_default": false } + ], + "default_model": "acestep-v15-turbo-Q8_0.gguf" + }, + "code": 200, + "error": null, + "timestamp": 1700000000000, + "extra": null +} +``` + +### 8.3 Example + +```bash +curl http://localhost:8001/v1/models +``` + +### 8.4 Model discovery order + +1. **`ACESTEP_MODEL_MAP`** (explicit) — JSON map of `{"logical-name": "file.gguf", …}`. The logical names are exposed as the model names. Use this when you want human-friendly names instead of raw filenames. +2. **`ACESTEP_MODELS_DIR` scan** (automatic) — `.gguf` files found in the models directory are listed by their filename (e.g. `acestep-v15-turbo-Q8_0.gguf`). Sorted alphabetically. +3. **Fallback** — `[defaultModel]` when no directory is set and no map is configured. + +`ACESTEP_MODELS` (comma-separated names) acts as a **filter/gate** on whichever source is discovered (map keys or scanned filenames). Only names present in the filter are returned. + +### 8.5 Selecting a model per-request + +Use the `model` field in `/release_task` with a name from the list: + +```bash +# Auto-discover — just set the models dir +export ACESTEP_MODELS_DIR="$HOME/models/acestep" + +# List what was found +curl http://localhost:8001/v1/models +# → ["acestep-v15-turbo-Q8_0.gguf", "acestep-v15-turbo-shift3-Q8_0.gguf", ...] + +# Select one per-request +curl -X POST http://localhost:8001/release_task \ + -H 'Content-Type: application/json' \ + -d '{"prompt": "jazz piano trio", "model": "acestep-v15-turbo-shift3-Q8_0.gguf"}' +``` + +Or use `ACESTEP_MODEL_MAP` for logical names: + +```bash +export ACESTEP_MODELS_DIR="$HOME/models/acestep" +export ACESTEP_MODEL_MAP='{"acestep-v15-turbo":"acestep-v15-turbo-Q8_0.gguf","acestep-v15-turbo-shift3":"acestep-v15-turbo-shift3-Q8_0.gguf"}' + +curl -X POST http://localhost:8001/release_task \ + -H 'Content-Type: application/json' \ + -d '{"prompt": "jazz piano trio", "model": "acestep-v15-turbo-shift3"}' +``` + +Or gate the list to a subset: + +```bash +export ACESTEP_MODELS_DIR="$HOME/models/acestep" +export ACESTEP_MODELS="acestep-v15-turbo-Q8_0.gguf,acestep-v15-turbo-shift3-Q8_0.gguf" +``` + +--- + +## 9. Server Statistics + +### 9.1 Endpoint + +- **URL**: `GET /v1/stats` + +### 9.2 Response + +```json +{ + "data": { + "jobs": { + "total": 100, + "queued": 5, + "running": 1, + "succeeded": 90, + "failed": 4 + }, + "queue_size": 5, + "queue_maxsize": 200, + "avg_job_seconds": 8.5 + }, + "code": 200, + "error": null, + "timestamp": 1700000000000, + "extra": null +} +``` + +### 9.3 Example + +```bash +curl http://localhost:8001/v1/stats +``` + +--- + +## 10. Download Audio Files + +### 10.1 Endpoint + +- **URL**: `GET /v1/audio` + +### 10.2 Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `path` | string | URL-encoded path returned in task `result.file` | + +### 10.3 Example + +```bash +curl "http://localhost:8001/v1/audio?path=%2Fabc123.mp3" -o output.mp3 +``` + +--- + +## 11. Health Check + +### 11.1 Endpoint + +- **URL**: `GET /health` + +### 11.2 Response + +```json +{ + "data": { "status": "ok", "service": "ACE-Step API", "version": "1.0" }, + "code": 200, + "error": null, + "timestamp": 1700000000000, + "extra": null +} +``` + +--- + +## 12. Environment Variables + +Only **paths** and server-level settings are configured via environment variables. Generation parameters (steps, guidance scale, BPM, …) are always supplied per-request. + +### Server + +| Variable | Default | Description | +|----------|---------|-------------| +| `ACESTEP_API_HOST` | `127.0.0.1` | Bind host | +| `ACESTEP_API_PORT` | `8001` | Bind port | +| `ACESTEP_API_KEY` | *(empty)* | API key (empty = auth disabled) | +| `ACESTEP_API_WORKERS` / `ACESTEP_QUEUE_WORKERS` | `1` | Queue worker count | + +### Paths + +| Variable | Description | +|----------|-------------| +| `ACESTEP_BIN_DIR` | Directory containing `ace-lm` / `ace-synth` (overrides bundled runtime) | +| `ACESTEP_APP_ROOT` | Root directory for resolving `acestep-runtime/` | +| `ACESTEP_MODELS_DIR` / `ACESTEP_MODEL_PATH` / `MODELS_DIR` | Base directory for bare GGUF filenames | +| `ACESTEP_LM_MODEL` / `ACESTEP_LM_MODEL_PATH` | Default 5Hz LM GGUF path or filename | +| `ACESTEP_EMBEDDING_MODEL` | Embedding model GGUF | +| `ACESTEP_DIT_MODEL` / `ACESTEP_CONFIG_PATH` | Default DiT model GGUF | +| `ACESTEP_VAE_MODEL` | VAE model GGUF | +| `ACESTEP_LORA` / `ACESTEP_LORA_SCALE` | LoRA path / scale for ace-synth | + +### Multi-Model Support + +| Variable | Default | Description | +|----------|---------|-------------| +| `ACESTEP_MODEL_MAP` | `{}` | JSON map of `{"name": "file.gguf", …}` — explicit name→path mapping. Drives both `/v1/models` and per-request `model` validation. Takes precedence over directory scan. | +| `ACESTEP_DEFAULT_MODEL` | first map key / first scanned file / `"acestep-v15-turbo"` | Name used when no `model` is specified per-request | +| `ACESTEP_MODELS` | *(all discovered)* | Comma-separated **filter/gate** applied to the discovered list (map keys or scanned filenames). Only names in this list are returned by `/v1/models`. | + +> **Recommended minimal setup** (no `ACESTEP_MODEL_MAP` needed): +> ```bash +> export ACESTEP_MODELS_DIR="$HOME/models/acestep" +> # /v1/models will automatically list every .gguf file in that directory +> ``` + +### Queue / Storage + +| Variable | Default | Description | +|----------|---------|-------------| +| `ACESTEP_QUEUE_MAXSIZE` | `200` | Maximum queued tasks | +| `ACESTEP_AUDIO_STORAGE` | `./storage/audio` | Audio output directory | +| `ACESTEP_TMPDIR` | `./storage/tmp` | Temporary job directory | +| `ACESTEP_AVG_JOB_SECONDS` | `5.0` | Initial average job time estimate | +| `ACESTEP_AVG_WINDOW` | `50` | Rolling window for job time averaging | +| `ACESTEP_MP3_BITRATE` | `128` | MP3 output bitrate | + +### VAE Tiling + +| Variable | Description | +|----------|-------------| +| `ACESTEP_VAE_CHUNK` | `--vae-chunk` for ace-synth | +| `ACESTEP_VAE_OVERLAP` | `--vae-overlap` for ace-synth | + +--- + +## Error Handling + +| HTTP Status | Meaning | +|-------------|---------| +| `200` | Success | +| `400` | Invalid request (bad JSON, missing required fields) | +| `401` | Unauthorized | +| `404` | Resource not found | +| `415` | Unsupported Content-Type | +| `429` | Queue full | +| `500` | Internal server error | + +Error responses use: +```json +{ "detail": "Error message describing the issue" } +``` + +--- + +## Differences from ACE-Step 1.5 Python Server + +| Feature | ACE-Step 1.5 | acestep-cpp-api | +|---------|-------------|-----------------| +| Backend | Python / PyTorch | acestep.cpp (`ace-lm` + `ace-synth`) | +| `audio_format: "flac"` | Supported | Not supported (returns 415) | +| `/format_input` | Full LM call | Stub (shape-compatible) | +| `/create_random_sample` | Loaded from examples | Fixed presets | +| LM backend | vllm / pt | GGUF via llama.cpp | +| Multi-model | `ACESTEP_CONFIG_PATH{2,3}` | `ACESTEP_MODEL_MAP` JSON | diff --git a/src/config.ts b/src/config.ts index edeadbc..171a092 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,5 @@ /** Env-based config. Binaries: https://github.com/audiohacking/acestep.cpp/releases/tag/v0.0.3 */ -import { resolveModelFile, resolveModelMapPaths, resolveAcestepBinDir } from "./paths"; +import { resolveModelFile, resolveModelMapPaths, resolveAcestepBinDir, listGgufFiles } from "./paths"; function parseModelMap(raw: string): Record { if (!raw.trim()) return {}; @@ -15,7 +15,10 @@ function parseModelMap(raw: string): Record { } } -const modelMapRaw = parseModelMap(process.env.ACESTEP_MODEL_MAP ?? ""); +/** Re-parsed on each access so env changes in tests are reflected. */ +function getModelMapRaw(): Record { + return parseModelMap(process.env.ACESTEP_MODEL_MAP ?? ""); +} export const config = { host: process.env.ACESTEP_API_HOST ?? "127.0.0.1", @@ -46,7 +49,7 @@ export const config = { /** Logical map with paths resolved against `ACESTEP_MODELS_DIR`. */ get modelMap(): Record { - return resolveModelMapPaths(modelMapRaw); + return resolveModelMapPaths(getModelMapRaw()); }, /** Base models directory (informative). */ @@ -77,11 +80,82 @@ export const config = { tmpDir: process.env.ACESTEP_TMPDIR ?? "./storage/tmp", queueMaxSize: parseInt(process.env.ACESTEP_QUEUE_MAXSIZE ?? "200", 10), queueWorkers: parseInt(process.env.ACESTEP_QUEUE_WORKERS ?? process.env.ACESTEP_API_WORKERS ?? "1", 10), - modelsList: (process.env.ACESTEP_MODELS ?? "acestep-v15-turbo,acestep-v15-turbo-shift3") - .split(",") - .map((s) => s.trim()) - .filter(Boolean), - defaultModel: process.env.ACESTEP_DEFAULT_MODEL ?? "acestep-v15-turbo", + + /** + * List of available model names shown by GET /v1/models. + * + * Resolution order: + * 1. `ACESTEP_MODEL_MAP` keys — when an explicit name→path map is configured. + * 2. `.gguf` files found in `modelsDir` — discovered at runtime. + * 3. Fallback: `ACESTEP_MODELS` list as-is (no dir to scan), or `[defaultModel]`. + * + * `ACESTEP_MODELS` (comma-separated) acts as a **filter/gate** on the discovered list + * (steps 1 & 2). When set, only names present in that list are returned. + */ + get modelsList(): string[] { + const filterRaw = process.env.ACESTEP_MODELS?.trim(); + const allowed = filterRaw ? new Set(filterRaw.split(",").map((s) => s.trim()).filter(Boolean)) : null; + + // 1. Explicit MODEL_MAP: use map keys + const mapKeys = Object.keys(getModelMapRaw()); + if (mapKeys.length > 0) { + return allowed ? mapKeys.filter((k) => allowed.has(k)) : mapKeys; + } + + // 2. Scan models directory for .gguf files + const dir = this.modelsDir; + if (dir) { + const scanned = listGgufFiles(dir); + if (scanned.length > 0) { + return allowed ? scanned.filter((n) => allowed.has(n)) : scanned; + } + } + + // 3. Fallback: use ACESTEP_MODELS list directly, or [defaultModel] + if (allowed) return [...allowed]; + const def = this.defaultModel; + return def ? [def] : []; + }, + + /** + * Model name → resolved file path map derived from scanning `modelsDir`. + * Used by `resolveDitPath` so per-request `model` accepts discovered filenames. + * Only populated when `ACESTEP_MODEL_MAP` is not set. + */ + get scannedModelMap(): Record { + if (Object.keys(getModelMapRaw()).length > 0) return {}; + const dir = this.modelsDir; + if (!dir) return {}; + const files = listGgufFiles(dir); + const out: Record = {}; + for (const f of files) { + out[f] = resolveModelFile(f); + } + return out; + }, + + /** + * The default model name (used when no `model` is specified per request). + * + * Resolution order: + * 1. `ACESTEP_DEFAULT_MODEL` — explicit override. + * 2. First key of `ACESTEP_MODEL_MAP` — when a map is configured. + * 3. First `.gguf` file in `modelsDir` — when the directory is scanned. + * 4. `"acestep-v15-turbo"` — hardcoded fallback label. + */ + get defaultModel(): string { + const explicit = process.env.ACESTEP_DEFAULT_MODEL?.trim(); + if (explicit) return explicit; + const mapKeys = Object.keys(getModelMapRaw()); + if (mapKeys.length > 0) return mapKeys[0]; + const dir = this.modelsDir; + if (dir) { + const scanned = listGgufFiles(dir); + if (scanned.length > 0) return scanned[0]; + } + return "acestep-v15-turbo"; + }, + avgJobWindow: parseInt(process.env.ACESTEP_AVG_WINDOW ?? "50", 10), avgJobSecondsDefault: parseFloat(process.env.ACESTEP_AVG_JOB_SECONDS ?? "5.0"), }; diff --git a/src/index.ts b/src/index.ts index 8024c68..370883e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import { mkdir } from "fs/promises"; -import { join } from "path"; +import { join, resolve } from "path"; import { config } from "./config"; import { requireAuth } from "./auth"; import { jsonRes } from "./res"; @@ -8,6 +8,7 @@ import * as store from "./store"; import * as queue from "./queue"; import { generateTaskId } from "./worker"; import { mergeMetadata, parseParamObj } from "./normalize"; +import { isPathWithin } from "./paths"; const AUDIO_PATH_PREFIX = "/"; @@ -187,12 +188,15 @@ async function handle(req: Request): Promise { if (authErr) return authErr; const pathParam = url.searchParams.get("path"); if (!pathParam) return detailRes("path required", 400); - const safePath = parsePath(pathParam).replace(/\.\./g, "").replace(/^\/+/, ""); - const filePath = join(config.audioStorageDir, safePath); + const requestedPath = parsePath(pathParam).replace(/^\/+/, ""); + const filePath = resolve(join(config.audioStorageDir, requestedPath)); + if (!isPathWithin(filePath, config.audioStorageDir)) { + return detailRes("Not Found", 404); + } try { const file = Bun.file(filePath); if (!(await file.exists())) return detailRes("Not Found", 404); - const ext = safePath.endsWith(".wav") ? "wav" : safePath.endsWith(".flac") ? "flac" : "mp3"; + const ext = filePath.endsWith(".wav") ? "wav" : filePath.endsWith(".flac") ? "flac" : "mp3"; const mime = ext === "wav" ? "audio/wav" : ext === "flac" ? "audio/flac" : "audio/mpeg"; return new Response(file, { diff --git a/src/paths.ts b/src/paths.ts index d45b6fa..2d441ce 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -1,5 +1,5 @@ -import { existsSync } from "fs"; -import { dirname, join, resolve, isAbsolute } from "path"; +import { existsSync, readdirSync } from "fs"; +import { dirname, join, resolve, isAbsolute, sep } from "path"; /** * Root used to resolve `acestep-runtime/` and default paths. @@ -74,3 +74,32 @@ export function resolveReferenceAudioPath(pathOrName: string): string { if (isAbsolute(p)) return p; return join(resolve(getResourceRoot()), p); } + +/** + * Returns true when `child` is equal to `parent` or is strictly inside it. + * Both paths are resolved to absolute form before comparison so relative + * segments, symlink-safe strings, and double-slashes are all normalized. + */ +export function isPathWithin(child: string, parent: string): boolean { + const resolvedChild = resolve(child); + const resolvedParent = resolve(parent); + return ( + resolvedChild === resolvedParent || + resolvedChild.startsWith(resolvedParent + sep) + ); +} + +/** + * Returns basenames of `.gguf` files found in `dir`, sorted alphabetically. + * Returns `[]` if `dir` is empty, does not exist, or cannot be read. + */ +export function listGgufFiles(dir: string): string[] { + if (!dir) return []; + try { + return readdirSync(dir) + .filter((f) => f.toLowerCase().endsWith(".gguf")) + .sort(); + } catch { + return []; + } +} diff --git a/src/worker.ts b/src/worker.ts index 8b0ebee..8b7b53b 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,10 +1,10 @@ import { randomUUID } from "crypto"; import { mkdir, writeFile, rename, unlink, readdir, readFile } from "fs/promises"; -import { join } from "path"; +import { join, resolve } from "path"; import { config } from "./config"; import * as store from "./store"; import { mergeMetadata } from "./normalize"; -import { resolveModelFile, resolveReferenceAudioPath } from "./paths"; +import { resolveModelFile, resolveReferenceAudioPath, isPathWithin } from "./paths"; /** API body (snake_case / camelCase) -> acestep.cpp request JSON. */ export function apiToRequestJson(body: Record): Record { @@ -100,15 +100,26 @@ export function shouldRunAceLm(body: Record, reqJson: Record): string { const p = body.lm_model_path ?? body.lmModelPath; - if (typeof p === "string" && p.trim()) return resolveModelFile(p.trim()); + if (typeof p === "string" && p.trim()) { + const resolved = resolveModelFile(p.trim()); + const dir = config.modelsDir; + if (dir && !isPathWithin(resolved, dir)) { + throw new Error( + `lm_model_path is not within the configured models directory` + ); + } + return resolved; + } return config.lmModelPath; } export function resolveDitPath(body: Record): string { const modelName = typeof body.model === "string" ? body.model.trim() : ""; - if (modelName && config.modelMap[modelName]) return config.modelMap[modelName]; - if (modelName && !config.modelMap[modelName]) { - throw new Error(`Unknown model "${modelName}". Set ACESTEP_MODEL_MAP JSON or use a configured name.`); + if (modelName) { + if (config.modelMap[modelName]) return config.modelMap[modelName]; + const scanned = config.scannedModelMap; + if (scanned[modelName]) return scanned[modelName]; + throw new Error(`Unknown model "${modelName}". Use GET /v1/models to list available models.`); } if (!config.ditModelPath) throw new Error("ACESTEP_DIT_MODEL or ACESTEP_CONFIG_PATH not set"); return config.ditModelPath; @@ -228,7 +239,16 @@ export async function runPipeline(taskId: string): Promise { const synthArgs: string[] = []; const rawSrc = String(body.src_audio_path ?? body.reference_audio_path ?? "").trim(); if (rawSrc) { - synthArgs.push("--src-audio", resolveReferenceAudioPath(rawSrc)); + const resolvedSrc = resolveReferenceAudioPath(rawSrc); + if ( + !isPathWithin(resolvedSrc, resolve(config.tmpDir)) && + !isPathWithin(resolvedSrc, resolve(config.audioStorageDir)) + ) { + throw new Error( + "Source audio path must be within the configured storage directories" + ); + } + synthArgs.push("--src-audio", resolvedSrc); } synthArgs.push("--request", ...numbered); synthArgs.push("--embedding", embedding, "--dit", ditPath, "--vae", vae); diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..993f1d8 --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { mkdirSync, writeFileSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +describe("config modelsList / defaultModel", () => { + const envKeys = [ + "ACESTEP_MODELS", + "ACESTEP_DEFAULT_MODEL", + "ACESTEP_MODEL_MAP", + "ACESTEP_DIT_MODEL", + "ACESTEP_CONFIG_PATH", + "ACESTEP_MODELS_DIR", + "ACESTEP_MODEL_PATH", + "MODELS_DIR", + ]; + + let savedEnv: Record = {}; + + beforeEach(() => { + for (const k of envKeys) { + savedEnv[k] = process.env[k]; + delete process.env[k]; + } + }); + + afterEach(() => { + for (const k of envKeys) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + }); + + test("ACESTEP_MODELS acts as a filter on ACESTEP_MODEL_MAP keys", async () => { + process.env.ACESTEP_MODELS = "model-a,model-b"; + const { config } = await import("../src/config"); + expect(config.modelsList).toEqual(["model-a", "model-b"]); + }); + + test("ACESTEP_MODEL_MAP keys are returned when no ACESTEP_MODELS filter", async () => { + delete process.env.ACESTEP_MODELS; + process.env.ACESTEP_MODEL_MAP = JSON.stringify({ + turbo: "turbo.gguf", + "turbo-shift3": "turbo-shift3.gguf", + }); + const { config } = await import("../src/config"); + expect(config.modelsList).toEqual(["turbo", "turbo-shift3"]); + delete process.env.ACESTEP_MODEL_MAP; + }); + + test("ACESTEP_MODELS filters ACESTEP_MODEL_MAP keys", async () => { + process.env.ACESTEP_MODEL_MAP = JSON.stringify({ + "model-a": "a.gguf", + "model-b": "b.gguf", + "model-c": "c.gguf", + }); + process.env.ACESTEP_MODELS = "model-a,model-c"; + const { config } = await import("../src/config"); + expect(config.modelsList).toEqual(["model-a", "model-c"]); + delete process.env.ACESTEP_MODEL_MAP; + }); + + test("scans ACESTEP_MODELS_DIR for .gguf files when no MODEL_MAP", async () => { + const dir = join(tmpdir(), `acestep-test-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "model-a.gguf"), ""); + writeFileSync(join(dir, "model-b.gguf"), ""); + writeFileSync(join(dir, "ignored.txt"), ""); + try { + delete process.env.ACESTEP_MODEL_MAP; + delete process.env.ACESTEP_MODELS; + process.env.ACESTEP_MODELS_DIR = dir; + const { config } = await import("../src/config"); + expect(config.modelsList).toEqual(["model-a.gguf", "model-b.gguf"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("ACESTEP_MODELS filters scanned .gguf files", async () => { + const dir = join(tmpdir(), `acestep-test-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "model-a.gguf"), ""); + writeFileSync(join(dir, "model-b.gguf"), ""); + writeFileSync(join(dir, "model-c.gguf"), ""); + try { + delete process.env.ACESTEP_MODEL_MAP; + process.env.ACESTEP_MODELS_DIR = dir; + process.env.ACESTEP_MODELS = "model-a.gguf,model-c.gguf"; + const { config } = await import("../src/config"); + expect(config.modelsList).toEqual(["model-a.gguf", "model-c.gguf"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("scannedModelMap maps filenames to resolved paths", async () => { + const dir = join(tmpdir(), `acestep-test-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "dit.gguf"), ""); + try { + delete process.env.ACESTEP_MODEL_MAP; + process.env.ACESTEP_MODELS_DIR = dir; + const { config } = await import("../src/config"); + const m = config.scannedModelMap; + expect(Object.keys(m)).toEqual(["dit.gguf"]); + expect(m["dit.gguf"]).toContain("dit.gguf"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("scannedModelMap is empty when MODEL_MAP is set", async () => { + process.env.ACESTEP_MODEL_MAP = JSON.stringify({ turbo: "turbo.gguf" }); + const { config } = await import("../src/config"); + expect(config.scannedModelMap).toEqual({}); + delete process.env.ACESTEP_MODEL_MAP; + }); + + test("defaults to [defaultModel] when no map, no dir, no ACESTEP_MODELS", async () => { + delete process.env.ACESTEP_MODELS; + delete process.env.ACESTEP_MODEL_MAP; + delete process.env.ACESTEP_DEFAULT_MODEL; + const { config } = await import("../src/config"); + expect(config.modelsList).toEqual(["acestep-v15-turbo"]); + }); + + test("ACESTEP_DEFAULT_MODEL is used as the default model name", async () => { + process.env.ACESTEP_DEFAULT_MODEL = "my-custom-model"; + const { config } = await import("../src/config"); + expect(config.defaultModel).toBe("my-custom-model"); + }); + + test("first ACESTEP_MODEL_MAP key becomes defaultModel when ACESTEP_DEFAULT_MODEL is absent", async () => { + delete process.env.ACESTEP_DEFAULT_MODEL; + process.env.ACESTEP_MODEL_MAP = JSON.stringify({ "first-model": "a.gguf", "second-model": "b.gguf" }); + const { config } = await import("../src/config"); + expect(config.defaultModel).toBe("first-model"); + delete process.env.ACESTEP_MODEL_MAP; + }); + + test("first scanned .gguf file becomes defaultModel when no map and no explicit default", async () => { + const dir = join(tmpdir(), `acestep-test-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "alpha.gguf"), ""); + writeFileSync(join(dir, "beta.gguf"), ""); + try { + delete process.env.ACESTEP_DEFAULT_MODEL; + delete process.env.ACESTEP_MODEL_MAP; + process.env.ACESTEP_MODELS_DIR = dir; + const { config } = await import("../src/config"); + expect(config.defaultModel).toBe("alpha.gguf"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("defaultModel falls back to 'acestep-v15-turbo' when nothing is configured", async () => { + delete process.env.ACESTEP_DEFAULT_MODEL; + delete process.env.ACESTEP_MODEL_MAP; + const { config } = await import("../src/config"); + expect(config.defaultModel).toBe("acestep-v15-turbo"); + }); +}); diff --git a/test/paths.test.ts b/test/paths.test.ts index 7c7319e..7d4a223 100644 --- a/test/paths.test.ts +++ b/test/paths.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { resolveModelFile, resolveReferenceAudioPath } from "../src/paths"; +import { resolveModelFile, resolveReferenceAudioPath, isPathWithin } from "../src/paths"; import { isAbsolute } from "path"; import path from "path"; @@ -66,3 +66,38 @@ describe("resolveReferenceAudioPath", () => { ); }); }); + +describe("isPathWithin", () => { + const base = "/storage/audio"; + + test("path equal to parent is within", () => { + expect(isPathWithin("/storage/audio", base)).toBe(true); + }); + + test("child file is within parent", () => { + expect(isPathWithin("/storage/audio/abc123.mp3", base)).toBe(true); + }); + + test("nested child is within parent", () => { + expect(isPathWithin("/storage/audio/sub/file.mp3", base)).toBe(true); + }); + + test("sibling directory is not within parent", () => { + expect(isPathWithin("/storage/other/file.mp3", base)).toBe(false); + }); + + test("path traversal escape is rejected", () => { + expect(isPathWithin("/storage/audio/../../../etc/passwd", base)).toBe(false); + }); + + test("prefix-only match is not within (no sep)", () => { + // /storage/audiovil is NOT within /storage/audio + expect(isPathWithin("/storage/audiovil/file.mp3", base)).toBe(false); + }); + + test("relative paths are resolved before comparison", () => { + // resolve("./storage/audio/file.mp3") should land inside resolve("./storage/audio") + expect(isPathWithin("./storage/audio/file.mp3", "./storage/audio")).toBe(true); + expect(isPathWithin("./storage/other/file.mp3", "./storage/audio")).toBe(false); + }); +}); diff --git a/test/security.test.ts b/test/security.test.ts new file mode 100644 index 0000000..ea978e4 --- /dev/null +++ b/test/security.test.ts @@ -0,0 +1,150 @@ +/** + * Security-focused tests: path traversal prevention and source-path containment. + */ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { resolve, join, sep } from "path"; +import { isPathWithin } from "../src/paths"; +import { resolveLmPath, resolveDitPath } from "../src/worker"; + +// --------------------------------------------------------------------------- +// Helpers: simulate what the /v1/audio handler does after the fix +// --------------------------------------------------------------------------- + +/** Mirrors the logic now in index.ts /v1/audio */ +function audioFilePath(audioStorageDir: string, pathParam: string): string | null { + const decoded = decodeURIComponent(pathParam); + const withPrefix = decoded.startsWith("/") ? decoded : "/" + decoded.replace(/^\/+/, ""); + const requestedPath = withPrefix.replace(/^\/+/, ""); + const filePath = resolve(join(audioStorageDir, requestedPath)); + if (!isPathWithin(filePath, audioStorageDir)) return null; + return filePath; +} + +// --------------------------------------------------------------------------- +// /v1/audio path traversal prevention +// --------------------------------------------------------------------------- + +describe("path traversal prevention in /v1/audio", () => { + // Use path.resolve so the base is always an absolute, platform-native path. + // On Windows, resolve("/storage/audio") → "C:\storage\audio"; on Unix it stays "/storage/audio". + const storageDir = resolve("/storage/audio"); + + test("valid path inside storage dir is allowed", () => { + expect(audioFilePath(storageDir, "/abc123.mp3")).toBe(join(storageDir, "abc123.mp3")); + }); + + test("explicit ../ traversal is blocked", () => { + expect(audioFilePath(storageDir, "../../etc/passwd")).toBeNull(); + }); + + test("URL-encoded traversal is blocked", () => { + expect(audioFilePath(storageDir, "%2e%2e%2fetc%2fpasswd")).toBeNull(); + }); + + test("four-dot literal directory name stays within storage dir (not a traversal)", () => { + // .... is NOT a path traversal — it is a literal directory name with four dots. + // resolve treats it as such, so the result is still within the storage dir. + const result = audioFilePath(storageDir, "....//....//etc/passwd"); + expect(result).not.toBeNull(); + expect(result!.startsWith(storageDir + sep)).toBe(true); + }); + + test("absolute path in query param is contained within storage dir", () => { + // requestedPath strips the leading / so /etc/passwd becomes etc/passwd, + // which is then joined with storageDir to give storageDir/etc/passwd. + const result = audioFilePath(storageDir, "/etc/passwd"); + expect(result).not.toBeNull(); + expect(result!.startsWith(storageDir + sep)).toBe(true); + }); + + test("nested valid path is allowed", () => { + expect(audioFilePath(storageDir, "/sub/file.wav")).toBe(join(storageDir, "sub", "file.wav")); + }); + + test("prefix-only directory name is not confused with parent", () => { + // /storage/audiovil/file.mp3 must NOT be served from /storage/audio + expect(audioFilePath(storageDir, "/../../audiovil/file.mp3")).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveLmPath: per-request lm_model_path must stay within modelsDir +// --------------------------------------------------------------------------- + +describe("resolveLmPath security", () => { + const envKeys = ["ACESTEP_LM_MODEL", "ACESTEP_LM_MODEL_PATH", "ACESTEP_MODELS_DIR", "ACESTEP_MODEL_PATH", "MODELS_DIR"]; + let savedEnv: Record = {}; + + beforeEach(() => { + for (const k of envKeys) { + savedEnv[k] = process.env[k]; + delete process.env[k]; + } + }); + + afterEach(() => { + for (const k of envKeys) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + }); + + test("lm_model_path within modelsDir is accepted", () => { + process.env.ACESTEP_MODELS_DIR = "/models"; + expect(() => resolveLmPath({ lm_model_path: "model.gguf" })).not.toThrow(); + }); + + test("lm_model_path outside modelsDir is rejected when modelsDir is set", () => { + process.env.ACESTEP_MODELS_DIR = "/models"; + expect(() => resolveLmPath({ lm_model_path: "/etc/passwd" })).toThrow( + /not within the configured models directory/ + ); + }); + + test("lm_model_path traversal outside modelsDir is rejected", () => { + process.env.ACESTEP_MODELS_DIR = "/models"; + expect(() => resolveLmPath({ lm_model_path: "../../../etc/shadow" })).toThrow( + /not within the configured models directory/ + ); + }); + + test("lm_model_path is unrestricted when no modelsDir is configured", () => { + // Without a modelsDir, we cannot constrain — path passes through + expect(() => resolveLmPath({ lm_model_path: "/arbitrary/path.gguf" })).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveDitPath: per-request model must be a known registered model +// --------------------------------------------------------------------------- + +describe("resolveDitPath security", () => { + const envKeys = ["ACESTEP_MODEL_MAP", "ACESTEP_DIT_MODEL", "ACESTEP_CONFIG_PATH", "ACESTEP_MODELS_DIR", "ACESTEP_MODEL_PATH", "MODELS_DIR"]; + let savedEnv: Record = {}; + + beforeEach(() => { + for (const k of envKeys) { + savedEnv[k] = process.env[k]; + delete process.env[k]; + } + }); + + afterEach(() => { + for (const k of envKeys) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + }); + + test("unknown model name throws", () => { + process.env.ACESTEP_MODEL_MAP = JSON.stringify({ turbo: "turbo.gguf" }); + process.env.ACESTEP_DIT_MODEL = "/models/default.gguf"; + expect(() => resolveDitPath({ model: "../../etc/passwd" })).toThrow(/Unknown model/); + }); + + test("known model map name does not throw", () => { + process.env.ACESTEP_MODELS_DIR = "/models"; + process.env.ACESTEP_MODEL_MAP = JSON.stringify({ turbo: "turbo.gguf" }); + expect(() => resolveDitPath({ model: "turbo" })).not.toThrow(); + }); +});