diff --git a/.gitignore b/.gitignore
index a63d2bd..9e2f081 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ Cargo.lock
.copilot
*.DS_Store
.direnv
+*.log
diff --git a/Cargo.toml b/Cargo.toml
index dd4f9e1..677ef0e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "irondrop"
-version = "2.6.5"
+version = "2.7.0"
edition = "2024"
license = "MIT"
description = "Drop files, not dependencies - a well tested fully featured & battle-ready server in a single Rust binary with support for indexing through 10M files."
diff --git a/README.md b/README.md
index ac6cbb8..608bfb6 100644
--- a/README.md
+++ b/README.md
@@ -25,15 +25,35 @@ IronDrop focuses on predictable behavior, simplicity, and low overhead. Use it t
- Basic security features: rate limiting, optional Basic Auth, path safety checks
- Native SSL/TLS support via `--ssl-cert` and `--ssl-key` (built-in HTTPS, no reverse proxy required)
- Single binary; templates and assets are embedded
- - Pure standard library networking and file I/O (no external HTTP stack or async runtime)
- - Ultra-compact search index option for very large directory trees (tested up to ~10M entries)
+- Core engine is dependency-free in critical paths: networking, search, and filesystem access are implemented in-house
+- Standard production dependencies are still used where practical (for example `clap`, `log`/`env_logger`, and `rustls`)
+- Ultra-compact search index option for very large directory trees (tested up to ~10M entries)
+- WebDAV (RFC 4918 Class 1 + Class 2 core): `OPTIONS`, `PROPFIND`, `PROPPATCH`, `MKCOL`, `PUT`, `DELETE`, `COPY`, `MOVE`, `LOCK`, `UNLOCK`
+ - Enabled only when `--enable-webdav true` (or equivalent config setting) is provided
+
+## WebDAV RFC 4918 support
+
+IronDrop includes an RFC 4918-focused implementation. The WebDAV core engine is implemented in-house and keeps critical request/response logic dependency-free.
+
+- Supported methods: `OPTIONS`, `PROPFIND`, `PROPPATCH`, `MKCOL`, `PUT`, `DELETE`, `COPY`, `MOVE`, `LOCK`, `UNLOCK`
+- WebDAV is feature-gated and disabled by default; enable explicitly with `--enable-webdav true`
+- Capability headers: `DAV: 1,2`, `Allow`, `MS-Author-Via`
+- `PROPFIND`: `allprop`, `propname`, named `prop`, per-property `propstat` grouping (`200`/`404`), and finite-depth refusal (`403` + `propfind-finite-depth`)
+- `PROPPATCH`: dead-property `set`/`remove` with `207 Multi-Status` results
+- Locking: exclusive write locks, lock refresh, `If` header token evaluation (including `Not` conditions), and token-gated write preconditions
+- Tree operations: lock-aware `DELETE` multistatus behavior (`207` with `423`/`424` where applicable)
+
+Current RFC scope limits:
+
+- ACL/versioning/bindings RFCs are out of scope (`RFC 3744`, `RFC 3253`, `RFC 5842`)
+- Lock and dead-property storage is in-process (non-persistent across server restarts)
## Performance
Designed to keep memory usage steady and to stream large files without buffering them in memory. The ultra-compact search mode reduces memory for very large directory trees.
- Ultra-compact search: approximately ~110 MB of RAM for around 10 million paths; search latency depends on CPU, disk, and query specifics.
-- No-dependency footprint: networking and file streaming are implemented with Rust's `std::net` and `std::fs`, producing a single self-contained binary.
+- Dependency profile: networking/search/filesystem core paths are dependency-free, while operational dependencies such as `clap`, `log`/`env_logger`, and `rustls` are used as stable standard building blocks.
## Security
@@ -197,6 +217,7 @@ IronDrop offers extensive customization through command-line arguments:
| `-l, --listen` | Listen address (default: 127.0.0.1) | `-l 0.0.0.0` |
| `-p, --port` | Port number (default: 8080) | `-p 3000` |
| `--enable-upload` | Enable file uploads | `--enable-upload true` |
+| `--enable-webdav` | Enable WebDAV methods (`OPTIONS`, `PROPFIND`, `PROPPATCH`, `MKCOL`, `PUT`, `DELETE`, `COPY`, `MOVE`, `LOCK`, `UNLOCK`) | `--enable-webdav true` |
| `--username/--password` | Basic authentication | `--username admin --password secret` |
| `-a, --allowed-extensions` | Restrict file types | `-a "*.pdf,*.doc,*.zip"` |
| `-t, --threads` | Worker threads (default: 8) | `-t 16` |
@@ -219,6 +240,19 @@ irondrop --config-file my-server.ini
The configuration file supports all command-line options and more! See the [detailed example](./config/irondrop.ini) with comments explaining every option.
+WebDAV can also be enabled in config:
+
+```ini
+[webdav]
+enable_webdav = true
+```
+
+Quick CLI example:
+
+```bash
+irondrop -d ./shared --enable-webdav true --listen 0.0.0.0
+```
+
**Configuration Priority (highest to lowest):**
1. Command line arguments
2. Environment variables (`IRONDROP_*`)
@@ -273,6 +307,7 @@ IronDrop has extensive documentation covering its architecture, API, and feature
### π§ **Feature Documentation**
* [**Search Feature Deep Dive**](./doc/SEARCH_FEATURE.md) - Ultra-compact search system details
+* [**WebDAV Implementation Guide**](./doc/WEBDAV_IMPLEMENTATION.md) - End-to-end flow and RFC 4918 behavior
* [**Upload Integration Guide**](./doc/UPLOAD_INTEGRATION.md) - File upload system and UI
* [**Direct Upload System**](./doc/MULTIPART_README.md) - Memory-efficient direct streaming architecture
* [**Configuration System**](./doc/CONFIGURATION_SYSTEM.md) - INI-based configuration guide
@@ -286,16 +321,15 @@ IronDrop has extensive documentation covering its architecture, API, and feature
## Testing
-IronDrop is rigorously tested with **199 comprehensive tests across 16 test files** covering all aspects of functionality.
+IronDrop is rigorously tested with **272 automated tests**:
+
+- **48 unit tests** in core source modules
+- **224 integration/system tests** across **28** test files (including WebDAV RFC suites)
-### Test Categories
-- **Integration Tests** (16 tests): End-to-end functionality and HTTP handling
-- **Monitor Tests** (2 tests): Real-time monitoring dashboard and metrics
-- **Rate Limiter Tests** (7 tests): Memory-based rate limiting and DoS protection
-- **Template Tests** (8 tests): Embedded template system and rendering
-- **Ultra-Compact Search Tests** (10 tests): Advanced search engine functionality
-- **Configuration Tests** (12 tests): INI parsing and configuration validation
-- **Core Server & Unit Tests** (40 tests): Library functions, utilities, and core logic
+### Coverage Areas
+- HTTP parser/request handling, auth, rate limiting, monitoring, uploads, search, and utilities
+- WebDAV RFC-focused behavior (`PROPFIND`, `PROPPATCH`, `COPY/MOVE`, `LOCK/UNLOCK`, error XML, edge preconditions)
+- Security and robustness paths (path traversal checks, symlink safeguards, malformed input handling)
```bash
# Run all tests
@@ -322,7 +356,7 @@ IronDrop is licensed under the [MIT License](./LICENSE).
Made with β€οΈ and π¦ in Rust
- Zero dependencies β’ Production ready β’ Battle tested with 199 comprehensive tests
+ Dependency-free core engine paths β’ Production ready β’ Battle tested with 272 automated tests
β Star us on GitHub
diff --git a/config/irondrop.ini b/config/irondrop.ini
index c2c49be..f35734f 100644
--- a/config/irondrop.ini
+++ b/config/irondrop.ini
@@ -10,10 +10,10 @@
# 2. Edit the settings below to match your needs
# 3. Run: irondrop --config-file my-config.ini
#
-# π§ IronDrop v2.6+ Features:
+# π§ IronDrop v2.7+ Features:
# β’ Direct streaming uploads with unlimited file size support
# β’ Ultra-compact search engine (10M+ files, <100MB RAM)
-# β’ Zero-dependency single binary
+# β’ Dependency-free core engine paths (networking/search/filesystem)
# β’ Enterprise-grade security
#
# βοΈ Configuration Priority (highest to lowest):
@@ -111,6 +111,21 @@ enable_upload = true
# π IronDrop's advantage: Even with "unlimited", memory usage stays constant!
max_upload_size = 5GB
+# ===============================================================================
+# π WEBDAV CONFIGURATION
+# ===============================================================================
+
+[webdav]
+# π Enable WebDAV (RFC 4918 Class 1 + Class 2 core methods)
+# β’ false = WebDAV routes return 405 Method Not Allowed (default)
+# β’ true = Enable WebDAV clients (Finder, mount tools, DAV sync clients)
+#
+# β
Methods enabled when true:
+# OPTIONS, PROPFIND, PROPPATCH, MKCOL, PUT, DELETE, COPY, MOVE, LOCK, UNLOCK
+#
+# β οΈ Recommendation: Use authentication and HTTPS when exposing WebDAV externally.
+enable_webdav = false
+
# ===============================================================================
# π SECURITY CONFIGURATION
@@ -263,6 +278,9 @@ detailed = true
# [upload]
# enable_upload = true
# max_upload_size = 100MB
+#
+# [webdav]
+# enable_webdav = true
#
# [security]
# allowed_extensions = *.pdf,*.doc,*.docx,*.xls,*.xlsx,*.ppt,*.pptx,*.zip
@@ -300,12 +318,13 @@ detailed = true
# β
3. Choose your listen address (127.0.0.1 or 0.0.0.0)
# β
4. Set a port (8080 is fine for most cases)
# β
5. Enable uploads if needed (set enable_upload = true)
-# β
6. Add authentication for network access (set username/password)
-# β
7. Configure allowed file extensions for security
-# β
7b. (Optional) Add SSL cert and key for HTTPS
-# β
8. Run: irondrop --config-file my-config.ini
-# β
9. Open browser: http://localhost:8080 (or your chosen port)
-# β
10. Enjoy blazing-fast file sharing! π
+# β
6. (Optional) Enable WebDAV if needed (set [webdav] enable_webdav = true)
+# β
7. Add authentication for network access (set username/password)
+# β
8. Configure allowed file extensions for security
+# β
8b. (Optional) Add SSL cert and key for HTTPS
+# β
9. Run: irondrop --config-file my-config.ini
+# β
10. Open browser: http://localhost:8080 (or your chosen port)
+# β
11. Enjoy blazing-fast file sharing! π
#
# π Need more help? Check out:
# β’ Complete documentation: ./doc/README.md
diff --git a/doc/API_REFERENCE.md b/doc/API_REFERENCE.md
index 96ac2d5..25d851d 100644
--- a/doc/API_REFERENCE.md
+++ b/doc/API_REFERENCE.md
@@ -1,4 +1,4 @@
-# IronDrop API Reference v2.6.5
+# IronDrop API Reference v2.7.0
## Overview
@@ -31,7 +31,7 @@ User-Agent:
#### Response Headers
```http
# Standard headers
-Server: IronDrop/2.6.5
+Server: IronDrop/2.7.0
Content-Type:
Content-Length:
Connection: keep-alive
@@ -193,7 +193,7 @@ Content-Type: text/html; charset=utf-8
#### `POST /_irondrop/upload`
Uploads files using direct binary streaming for optimal performance and unlimited file size support.
-**Direct Upload Features (v2.6.5):**
+**Direct Upload Features (v2.7.0):**
- **Direct Binary Streaming**: No multipart parsing overhead
- **Automatic Mode Selection**: Small uploads (β€64MB) processed in memory, large uploads (>64MB) streamed to disk
- **Constant Memory Usage**: ~7MB RAM usage regardless of file size
@@ -420,7 +420,7 @@ Basic health check endpoint.
```json
{
"status": "healthy",
- "version": "2.6.5",
+ "version": "2.7.0",
"uptime_seconds": 3600,
"timestamp": "2024-01-01T12:00:00Z"
}
@@ -827,4 +827,4 @@ All inputs are validated:
- File names for path traversal attempts
- HTTP headers for malformed content
-This API reference covers all functionality available in IronDrop v2.6.5 and provides comprehensive examples for client integration.
\ No newline at end of file
+This API reference covers all functionality available in IronDrop v2.7.0 and provides comprehensive examples for client integration.
\ No newline at end of file
diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md
index 683a336..f30dd5f 100644
--- a/doc/ARCHITECTURE.md
+++ b/doc/ARCHITECTURE.md
@@ -1,4 +1,4 @@
-# IronDrop Architecture Documentation v2.6.5
+# IronDrop Architecture Documentation v2.7.0
## Overview
@@ -257,7 +257,7 @@ Request β Cache Check β Hit: Return Cached Results
## HTTP Layer Streaming Architecture
### Overview
-IronDrop v2.6.5 provides advanced HTTP layer streaming for efficient handling of large file uploads. The system automatically switches between memory-based and disk-based processing based on content size, providing optimal performance and resource utilization.
+IronDrop v2.7.0 provides advanced HTTP layer streaming for efficient handling of large file uploads. The system automatically switches between memory-based and disk-based processing based on content size, providing optimal performance and resource utilization.
### RequestBody Architecture
@@ -577,4 +577,4 @@ pub enum AppError {
4. **CDN Integration**: Edge caching and global distribution
5. **Database Caching**: Redis integration for session management
-This architecture documentation reflects the current state of IronDrop v2.6.5 and serves as a foundation for understanding the system's design principles, implementation details, and operational characteristics.
\ No newline at end of file
+This architecture documentation reflects the current state of IronDrop v2.7.0 and serves as a foundation for understanding the system's design principles, implementation details, and operational characteristics.
\ No newline at end of file
diff --git a/doc/CONFIGURATION_SYSTEM.md b/doc/CONFIGURATION_SYSTEM.md
index 0306426..fb88441 100644
--- a/doc/CONFIGURATION_SYSTEM.md
+++ b/doc/CONFIGURATION_SYSTEM.md
@@ -1,4 +1,4 @@
-## IronDrop Configuration System (v2.6.5)
+## IronDrop Configuration System (v2.7.0)
### Overview
IronDrop 2.5 introduces a firstβclass configuration system with hierarchical precedence and zero external dependencies. It complements (not replaces) the existing CLI flags, enabling reproducible deployments, easier automation, and environment portability. The system is intentionally simple: an internal INI parser (`src/config/ini_parser.rs`) plus a composition layer (`src/config/mod.rs`) that merges values from multiple sources.
diff --git a/doc/DEPLOYMENT.md b/doc/DEPLOYMENT.md
index 2fd0fd6..d34bdb7 100644
--- a/doc/DEPLOYMENT.md
+++ b/doc/DEPLOYMENT.md
@@ -1,4 +1,4 @@
-# IronDrop Deployment Guide v2.6.5
+# IronDrop Deployment Guide v2.7.0
## Overview
@@ -789,4 +789,4 @@ perf record -g irondrop -d /srv/files
strace -p $(pgrep irondrop)
```
-This deployment guide provides comprehensive coverage of production deployment scenarios and operational best practices for IronDrop v2.6.5.
\ No newline at end of file
+This deployment guide provides comprehensive coverage of production deployment scenarios and operational best practices for IronDrop v2.7.0.
\ No newline at end of file
diff --git a/doc/HTTP_STREAMING.md b/doc/HTTP_STREAMING.md
index d7c7dc1..16c2bfc 100644
--- a/doc/HTTP_STREAMING.md
+++ b/doc/HTTP_STREAMING.md
@@ -1,10 +1,10 @@
-# IronDrop Direct Upload Streaming (v2.6.5)
+# IronDrop Direct Upload Streaming (v2.7.0)
## Overview
IronDrop implements direct streaming uploads. Large request bodies are streamed to disk, avoiding unbounded memory growth. Small bodies are processed in memory.
-**Status**: Production-ready (v2.6.5)
+**Status**: Production-ready (v2.7.0)
- Direct streaming implementation with bounded memory usage
- Handling from small to very large files
- Tests cover stability and cleanup
@@ -310,7 +310,7 @@ The streaming system integrates with IronDrop's monitoring:
## Version History
-- **v2.6.5**: Direct streaming implementation with unlimited file size support
+- **v2.7.0**: Direct streaming implementation with unlimited file size support
- Automatic memory/disk switching based on content size
- `RequestBody` enum with `Memory` and `File` variants
- Comprehensive test coverage with dedicated streaming tests
diff --git a/doc/MONITORING.md b/doc/MONITORING.md
index 06b8072..1322eab 100644
--- a/doc/MONITORING.md
+++ b/doc/MONITORING.md
@@ -1,4 +1,4 @@
-# IronDrop Monitoring Guide (v2.6.5)
+# IronDrop Monitoring Guide (v2.7.0)
This guide documents the built-in monitoring capabilities introduced with the `/monitor` endpoint and supporting health APIs.
@@ -125,4 +125,4 @@ done
Monitoring schema may evolve with additive fields. Consumers should ignore unknown keys. Breaking changes (renames/removals) will bump minor version >= 2.x.
---
-*Monitoring Guide for IronDrop v2.6.5*
+*Monitoring Guide for IronDrop v2.7.0*
diff --git a/doc/MULTIPART_README.md b/doc/MULTIPART_README.md
index a06f92e..f4ec223 100644
--- a/doc/MULTIPART_README.md
+++ b/doc/MULTIPART_README.md
@@ -1,10 +1,10 @@
-# IronDrop Direct Upload System v2.6.5
+# IronDrop Direct Upload System v2.7.0
This document describes the simplified direct upload system that replaced the multipart parser in IronDrop.
## Overview
-IronDrop replaces legacy multipart parsing with a direct binary upload system focused on predictable memory use and simpler processing. The system handles raw binary uploads with bounded memory. (v2.6.5)
+IronDrop replaces legacy multipart parsing with a direct binary upload system focused on predictable memory use and simpler processing. The system handles raw binary uploads with bounded memory. (v2.7.0)
**Current Status**: Production-ready with direct streaming implementation and comprehensive test coverage (verified memory stability across all file sizes).
diff --git a/doc/README.md b/doc/README.md
index 81a987d..2c5cb8f 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -12,6 +12,22 @@ This documentation suite provides complete coverage of IronDrop's architecture,
Recent updates include direct streaming uploads and the ultra-compact search mode.
+## WebDAV support snapshot
+
+Current WebDAV support targets RFC 4918 Class 1 + Class 2 core behavior without new dependencies:
+
+- Implemented methods: `OPTIONS`, `PROPFIND`, `PROPPATCH`, `MKCOL`, `PUT`, `DELETE`, `COPY`, `MOVE`, `LOCK`, `UNLOCK`
+- Protocol details: `DAV: 1,2` capability advertisement, `Allow` and `MS-Author-Via` headers, `207 Multi-Status` XML where required
+- `PROPFIND` semantics: `allprop`, `propname`, named `prop`, and `200`/`404` `propstat` grouping
+- `PROPPATCH` semantics: dead-property `set`/`remove` with `207` response model
+- Lock semantics: token-gated writes with `If`-header parsing, lock refresh, and lock-aware copy/move/delete preconditions
+- `PROPFIND` `Depth: infinity` on collections is refused with RFC-shaped `403` DAV precondition (`propfind-finite-depth`)
+
+Known scope limits:
+
+- No ACL/versioning/bindings extensions (`RFC 3744`, `RFC 3253`, `RFC 5842`)
+- Lock/dead-property state is in-memory (non-persistent across restarts)
+
## π Core Documentation
### ποΈ [Architecture Documentation](./ARCHITECTURE.md)
@@ -99,7 +115,7 @@ Native zero-dependency template engine: variables, conditionals, embedded assets
- **Streaming Tests**: HTTP layer streaming validation and large file bash integration tests
- **Test Infrastructure**: Helper functions, data management, execution procedures
-**Implementation Status**: β
**Production Ready** (v2.6.5)
+**Implementation Status**: β
**Production Ready** (v2.7.0)
- **English-Only Testing**: All test messages and output standardized to English
- **Comprehensive Coverage**: Edge cases, security scenarios, performance validation, and streaming functionality
- **Memory Optimization Tests**: Ultra-compact search engine validation for 10M+ files
@@ -119,7 +135,7 @@ Native zero-dependency template engine: variables, conditionals, embedded assets
- Multi-file concurrent upload handling
- Client-side validation and error handling
-**Implementation Status**: β
**Production Ready** (v2.6.5)
+**Implementation Status**: β
**Production Ready** (v2.7.0)
- Complete upload system with 29 comprehensive tests
- Professional UI matching IronDrop's design language
- Integrated with template engine and security systems
@@ -136,7 +152,7 @@ Native zero-dependency template engine: variables, conditionals, embedded assets
- CLI configuration security enhancements
- Defense-in-depth implementation details
-**Security Status**: β
**Fully Implemented** (v2.6.5)
+**Security Status**: β
**Fully Implemented** (v2.7.0)
- Comprehensive input validation at multiple layers
- System directory blacklisting and write permission checks
- Direct streaming with unlimited file size support
@@ -154,7 +170,7 @@ Native zero-dependency template engine: variables, conditionals, embedded assets
- Configuration options and customization
- Comprehensive API usage examples
-**Implementation Status**: β
**Production Ready** (v2.6.5)
+**Implementation Status**: β
**Production Ready** (v2.7.0)
- RFC 7578 compliance with robust boundary detection and streaming support
- Advanced streaming implementation for memory-efficient large file processing
- 7+ dedicated test cases covering edge cases and streaming scenarios
@@ -175,7 +191,7 @@ Native zero-dependency template engine: variables, conditionals, embedded assets
- **Integration Guide**: Seamless integration with existing upload handlers
- **Testing Framework**: Comprehensive test coverage with dedicated HTTP streaming tests
-**Implementation Status**: β
**Production Ready** (v2.6.5)
+**Implementation Status**: β
**Production Ready** (v2.7.0)
- **Automatic Mode Selection**: β€1MB in memory, >1MB streamed to disk
- **Zero Configuration**: Works transparently with existing upload handlers
- **Resource Protection**: Prevents memory exhaustion from large uploads
@@ -211,7 +227,7 @@ Native zero-dependency template engine: variables, conditionals, embedded assets
- Security implementation and access control
- Configuration options and troubleshooting guide
-**Implementation Status**: β
**Production Ready** (v2.6.5)
+**Implementation Status**: β
**Production Ready** (v2.7.0)
- **Standard Search Engine**: Thread-safe search with LRU caching (5-minute TTL)
- **Ultra-Compact Search Engine**: Memory-optimized for massive directories (10M+ files)
- **Automatic Mode Selection**: Transparent switching based on directory size
@@ -222,7 +238,18 @@ Native zero-dependency template engine: variables, conditionals, embedded assets
- Accessibility-compliant UI with keyboard navigation support
- Performance testing and benchmarking infrastructure
-**π NEW in v2.6**: Revolutionary direct streaming upload system with **unlimited file size support**, constant memory usage (~7MB), and simplified binary upload architecture. (v2.6.5)
+**π NEW in v2.6**: Revolutionary direct streaming upload system with **unlimited file size support**, constant memory usage (~7MB), and simplified binary upload architecture. (v2.7.0)
+
+### π [WebDAV Implementation Guide](./WEBDAV_IMPLEMENTATION.md) β
+**Audience**: Backend Developers, Integrators, Client Compatibility Engineers
+**Purpose**: Easy-to-follow RFC 4918 flow guide for the implemented WebDAV engine
+
+**Contents:**
+- Request routing and method dispatch flow
+- PROPFIND/PROPPATCH logic and XML response shape
+- COPY/MOVE/DELETE precondition and lock checks
+- LOCK/UNLOCK token flow and `If` header evaluation
+- Practical troubleshooting checklist with expected status codes
---
@@ -330,7 +357,7 @@ Open a browser at [http://127.0.0.1:8080](http://127.0.0.1:8080) and you will se
---
-## π What's New in v2.6.5
+## π What's New in v2.7.0
### π€ **Complete File Upload System**
IronDrop v2.5 introduces a **production-ready file upload system** with enterprise-grade features:
diff --git a/doc/TEMPLATE_SYSTEM.md b/doc/TEMPLATE_SYSTEM.md
index 118acee..1e0caf2 100644
--- a/doc/TEMPLATE_SYSTEM.md
+++ b/doc/TEMPLATE_SYSTEM.md
@@ -1,6 +1,6 @@
-# IronDrop Template & UI System Documentation (v2.6.5)
+# IronDrop Template & UI System Documentation (v2.7.0)
-**Status**: Production ready (v2.6.5)
+**Status**: Production ready (v2.7.0)
**Audience**: Backend & Frontend Developers, UI/UX Engineers, Integrators
@@ -340,6 +340,6 @@ let err_html = engine.render_error_page(404, "Not Found", get_error_description(
---
-*This document is part of the IronDrop v2.6.5 documentation suite and will evolve with future template system enhancements.*
+*This document is part of the IronDrop v2.7.0 documentation suite and will evolve with future template system enhancements.*
Return to documentation index: [./README.md](./README.md)
diff --git a/doc/TESTING_DOCUMENTATION.md b/doc/TESTING_DOCUMENTATION.md
index 9054374..84d4769 100644
--- a/doc/TESTING_DOCUMENTATION.md
+++ b/doc/TESTING_DOCUMENTATION.md
@@ -1,10 +1,10 @@
# IronDrop Testing Documentation
-Version 2.6.5 - Test Suite Overview
+Version 2.7.0 - Test Suite Overview
## Overview
-IronDrop includes a comprehensive test suite with **199 tests** covering functionality, security scenarios, performance validation, and concurrent operations. Recent improvements include enhanced path parsing, Unicode support, and race condition fixes.
+IronDrop includes a comprehensive test suite with **272 automated tests** covering functionality, security scenarios, performance validation, and concurrent operations. Recent improvements include expanded WebDAV RFC suites, hardened path handling, and stronger lock/precondition validation.
## Test Architecture
@@ -21,24 +21,11 @@ IronDrop includes a comprehensive test suite with **199 tests** covering functio
| Category | Test Files | Test Count | Coverage |
|----------|------------|------------|----------|
-| **Core Server & Unit Tests** | `server.rs`, `upload.rs`, `router.rs`, `config/mod.rs`, `config/ini_parser.rs`, `error.rs`, `cli.rs` (unit tests) | 40 | Core functionality, HTTP handling, utilities |
-| **Configuration System** | `config_test.rs` | 16 | INI parsing, precedence, validation, edge cases |
-| **Direct Upload System** | `direct_upload_test.rs` | 15 | File uploads, streaming, concurrent operations, race conditions |
-| **Integration Testing** | `integration_test.rs` | 16 | Authentication, security, HTTP compliance, edge cases |
-| **Memory & Performance** | `memory_leak_fix_test.rs`, `src/ultra_memory_test.rs` | 6 | Memory management, leak prevention, cleanup |
-| **HTTP Parser** | `http_parser_test.rs` | 13 | Version parsing, malformed requests, edge cases |
-| **Middleware** | `middleware_test.rs` | 13 | Authentication, security, request processing |
-| **Template System** | `template_embedding_test.rs`, `templates_escape_test.rs` | 7 | Embedded templates, escaping, assets |
-| **Template Utilities** | `response_utils_test.rs` | 2 | Response generation, template processing |
-| **Ultra-Compact Search** | `ultra_compact_test.rs`, `src/ultra_compact_search.rs` | 10 | Memory efficiency, search performance |
-| **Rate Limiting** | `rate_limiter_memory_test.rs`, `rate_limiter_smart_eviction_test.rs` | 7 | Memory management, cleanup, limits |
-| **Monitoring & Stats** | `monitor_test.rs` | 2 | Health endpoints, metrics tracking |
-| **Utilities** | `utils_test.rs`, `utils_parse_path_test.rs` | 23 | Path parsing, Unicode encoding, special characters |
-| **Request Body** | `http_requestbody_test.rs` | 1 | Size and emptiness |
-| **Hidden Files** | `hidden_files_test.rs` | 8 | Dotfile handling, listing behavior, ignore rules |
-| **Logging** | `log_dir_test.rs` | 20 | Directory creation, permissions |
-
-**Total Tests: 199**
+| **Core Unit Tests** | `src/*.rs` unit modules | 48 | Core functionality, parser behavior, routing, upload/search internals |
+| **Integration/System Tests** | `tests/*.rs` (non-WebDAV) | 167 | Auth, config, uploads, monitoring, middleware, parser, utilities, resilience |
+| **WebDAV RFC/Edge Tests** | `tests/webdav*_test.rs` | 57 | RFC 4918 behavior, lock semantics, multistatus/error XML, tree operations |
+
+**Total Tests: 272**
## Detailed Test Coverage
@@ -200,7 +187,7 @@ fn test_demonstrate_memory_savings() // Compares memory usage vs alternatives
## Running Tests
-### Basic Test Execution (current totals: 199 tests across 16 files)
+### Basic Test Execution (current totals: 272 tests: 48 unit + 224 integration/system)
```bash
# Run all tests
@@ -390,7 +377,7 @@ fn test_new_feature() {
- Code formatting validation
- Documentation completeness
-## Recent Improvements (v2.6.5)
+## Recent Improvements (v2.7.0)
### Critical Fixes and Enhancements
@@ -414,7 +401,7 @@ fn test_new_feature() {
- All 15 direct upload tests now pass, including concurrent upload scenarios
**Test Suite Stability**
-- Achieved 100% test pass rate across all 189 tests
+- Achieved 100% test pass rate across all 272 tests
- Enhanced test reliability under concurrent execution
- Improved error handling and edge case coverage
- Added comprehensive validation for boundary conditions
@@ -448,6 +435,6 @@ fn test_new_feature() {
---
-*This document is part of the IronDrop v2.6.5 documentation suite. The test suite is continuously evolving to ensure comprehensive coverage and reliability.*
+*This document is part of the IronDrop v2.7.0 documentation suite. The test suite is continuously evolving to ensure comprehensive coverage and reliability.*
Return to documentation index: [./README.md](./README.md)
\ No newline at end of file
diff --git a/doc/WEBDAV_IMPLEMENTATION.md b/doc/WEBDAV_IMPLEMENTATION.md
new file mode 100644
index 0000000..7d14c1f
--- /dev/null
+++ b/doc/WEBDAV_IMPLEMENTATION.md
@@ -0,0 +1,153 @@
+# WebDAV Implementation Guide
+
+Version: 2.7.0
+
+This guide explains how the WebDAV implementation works in plain language. It focuses on request flow, lock behavior, and the core RFC 4918 semantics implemented in `src/webdav.rs`.
+
+## 1) High-level architecture
+
+IronDrop routes WebDAV methods through the normal HTTP server path, then into a dedicated WebDAV engine.
+
+```mermaid
+flowchart TD
+ A[Client Request] --> B[HTTP parser in src/http.rs]
+ B --> C[Router + middleware in src/server.rs]
+ C --> D[File handler in src/handlers.rs]
+ D --> E{WebDAV method?}
+ E -- yes --> F[WebDAV engine in src/webdav.rs]
+ E -- no --> G[Regular file/directory flow]
+```
+
+### Methods currently handled
+
+- `OPTIONS`
+- `PROPFIND`
+- `PROPPATCH`
+- `MKCOL`
+- `PUT`
+- `DELETE`
+- `COPY`
+- `MOVE`
+- `LOCK`
+- `UNLOCK`
+
+## 2) Request routing and feature gate
+
+WebDAV methods are only processed when WebDAV is enabled in config/CLI. If disabled, WebDAV methods return `405 Method Not Allowed`.
+
+Configuration controls:
+
+- CLI: `--enable-webdav true|false`
+- INI: `[webdav] enable_webdav = true|false` (also accepted under `[server]`)
+
+## 3) Core request flow
+
+Every mutating WebDAV request follows the same protection sequence:
+
+```mermaid
+flowchart TD
+ A[Incoming WebDAV request] --> B[Resolve and normalize path]
+ B --> C[Check traversal/symlink constraints]
+ C --> D[Lock precondition checks]
+ D --> E{Precondition satisfied?}
+ E -- no --> F[Return 423 or 207/424]
+ E -- yes --> G[Execute filesystem mutation]
+ G --> H[Update lock/dead-property state]
+ H --> I[Return status + headers + XML if needed]
+```
+
+## 4) PROPFIND logic (easy mental model)
+
+IronDrop supports the common PROPFIND modes:
+
+- `allprop`
+- `propname`
+- named `prop`
+
+and depth handling:
+
+- `Depth: 0` and `Depth: 1` for collections
+- `Depth: infinity` on collections refused with RFC-shaped finite-depth precondition response
+
+```mermaid
+flowchart TD
+ A[PROPFIND request] --> B[Parse Depth]
+ B --> C[Parse body mode: allprop/propname/named]
+ C --> D[Collect target + optional children]
+ D --> E[Build live properties]
+ E --> F[Merge dead properties where applicable]
+ F --> G[Group propstat by status]
+ G --> H[Return 207 Multi-Status XML]
+```
+
+## 5) PROPPATCH logic
+
+PROPPATCH operations are applied in document order and return per-property statuses in `207 Multi-Status`.
+
+- dead property `set`/`remove` supported
+- protected/live properties rejected with `403` in property-level `propstat`
+- malformed or empty updates rejected with `400`
+
+## 6) COPY/MOVE/DELETE logic
+
+These operations enforce destination and lock preconditions before mutation.
+
+### COPY/MOVE checks
+
+- valid `Destination` required
+- `Overwrite` semantics honored
+- source-under-destination loops rejected
+- lock checks run on source and destination where required
+
+### DELETE behavior
+
+- locked descendants produce multi-status (`207`) with dependency semantics (`423`/`424`) when applicable
+
+## 7) LOCK/UNLOCK and If-header flow
+
+Locking is class-2 style exclusive write locking with token-based authorization.
+
+```mermaid
+sequenceDiagram
+ participant C as Client
+ participant S as Server
+ C->>S: LOCK /file (lockinfo + timeout)
+ S-->>C: 201 Created + Lock-Token
+ C->>S: PUT /file with If: ()
+ S-->>C: 204/201 (allowed)
+ C->>S: UNLOCK /file with Lock-Token
+ S-->>C: 204 No Content
+```
+
+Key points:
+
+- new lock returns `201`
+- refresh lock returns `200`
+- wrong/missing token on locked mutation returns `423`
+- wrong token on unlock returns `409`
+
+## 8) Dead properties and lock lifecycle
+
+The engine keeps dead properties and lock metadata in-memory for active process lifetime.
+
+- delete/move/copy paths update related state
+- state is non-persistent across server restarts
+
+## 9) Operational troubleshooting
+
+If a client reports odd behavior, check in this order:
+
+1. WebDAV flag enabled (`--enable-webdav` / INI)
+2. auth result (401s in log)
+3. rate-limiter behavior (burst-heavy clients can be throttled)
+4. lock token flow (`LOCK`/`If`/`UNLOCK`)
+5. method/status pair in logs (`PROPFIND -> 207`, `LOCK -> 201`, etc.)
+
+## 10) Current scope limits
+
+Implemented scope is RFC 4918 Class 1 + Class 2 core behavior used by common clients.
+
+Not in scope:
+
+- ACL/versioning/bindings extensions (`RFC 3744`, `RFC 3253`, `RFC 5842`)
+- persistent lock/dead-property storage across restarts
diff --git a/irondrop.log b/irondrop.log
deleted file mode 100644
index e69de29..0000000
diff --git a/src/cli.rs b/src/cli.rs
index 30efcdd..3be5af7 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -64,6 +64,10 @@ pub struct Cli {
#[arg(long, value_parser = validate_upload_size)]
pub max_upload_size: Option,
+ /// Enable WebDAV methods (`OPTIONS`,`PROPFIND`,`PROPPATCH`,`MKCOL`,`PUT`,`DELETE`,`COPY`,`MOVE`,`LOCK`,`UNLOCK`).
+ #[arg(long)]
+ pub enable_webdav: Option,
+
/// Configuration file path - Specify a custom configuration file (INI format). If not provided, looks for irondrop.ini in current directory or ~/.config/irondrop/config.ini π οΈ
#[arg(long, value_parser = validate_config_file)]
pub config_file: Option,
@@ -217,6 +221,7 @@ mod tests {
password: None,
enable_upload: Some(false),
max_upload_size: Some(100),
+ enable_webdav: Some(false),
config_file: None,
log_dir: None,
ssl_cert: None,
@@ -254,6 +259,7 @@ mod tests {
password: None,
enable_upload: Some(true),
max_upload_size: Some(100),
+ enable_webdav: Some(false),
config_file: None,
log_dir: None,
ssl_cert: None,
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 2141523..87b53de 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -21,6 +21,7 @@ pub struct Config {
// Upload settings
pub enable_upload: bool,
pub max_upload_size: u64,
+ pub enable_webdav: bool,
// Security settings
pub username: Option,
@@ -71,6 +72,7 @@ impl Config {
enable_upload: Self::get_enable_upload(&ini, cli),
max_upload_size: Self::get_max_upload_size(&ini, cli),
+ enable_webdav: Self::get_enable_webdav(&ini, cli),
username: Self::get_username(&ini, cli),
password: Self::get_password(&ini, cli),
@@ -244,6 +246,19 @@ impl Config {
u64::MAX
}
+ fn get_enable_webdav(ini: &IniConfig, cli: &Cli) -> bool {
+ if let Some(enable_webdav) = cli.enable_webdav {
+ return enable_webdav;
+ }
+ if let Some(enabled) = ini.get_bool("webdav", "enable_webdav") {
+ return enabled;
+ }
+ if let Some(enabled) = ini.get_bool("server", "enable_webdav") {
+ return enabled;
+ }
+ false
+ }
+
fn get_username(ini: &IniConfig, cli: &Cli) -> Option {
// CLI argument
if let Some(ref username) = cli.username {
@@ -342,6 +357,7 @@ impl Config {
self.max_upload_size / (1024 * 1024)
);
}
+ log::info!(" WebDAV Enabled: {}", self.enable_webdav);
log::info!(
" Authentication: {}",
if self.username.is_some() {
@@ -383,6 +399,7 @@ mod tests {
password: None,
enable_upload: None,
max_upload_size: None,
+ enable_webdav: None,
config_file: None,
log_dir: None,
ssl_cert: None,
@@ -405,6 +422,7 @@ mod tests {
assert_eq!(config.directory, temp_dir.path());
assert!(!config.enable_upload);
assert_eq!(config.max_upload_size, u64::MAX); // No limit with direct streaming
+ assert!(!config.enable_webdav);
assert_eq!(config.username, None);
assert_eq!(config.password, None);
assert_eq!(config.allowed_extensions, vec!["*.zip", "*.txt"]);
@@ -428,6 +446,9 @@ chunk_size = 2048
enable_upload = true
max_upload_size = 5GB
+[webdav]
+enable_webdav = true
+
[auth]
username = testuser
password = testpass
@@ -454,6 +475,7 @@ detailed = false
assert_eq!(config.chunk_size, 2048);
assert!(config.enable_upload);
assert_eq!(config.max_upload_size, 5 * 1024 * 1024 * 1024);
+ assert!(config.enable_webdav);
assert_eq!(config.username, Some("testuser".to_string()));
assert_eq!(config.password, Some("testpass".to_string()));
assert_eq!(config.allowed_extensions, vec!["*.pdf", "*.doc"]);
@@ -517,6 +539,9 @@ threads = 16
[upload]
enable_upload = true
max_upload_size = 2GB
+
+[webdav]
+enable_webdav = true
";
fs::write(&config_file, ini_content).unwrap();
@@ -528,6 +553,7 @@ max_upload_size = 2GB
assert!(config.enable_upload);
assert_eq!(config.max_upload_size, 2 * 1024 * 1024 * 1024);
+ assert!(config.enable_webdav);
}
#[test]
diff --git a/src/handlers.rs b/src/handlers.rs
index df46c4d..10e296c 100644
--- a/src/handlers.rs
+++ b/src/handlers.rs
@@ -125,6 +125,13 @@ pub fn register_internal_routes(
"/_irondrop/cleanup-memory",
Box::new(|_| handle_memory_cleanup_request()),
);
+
+ // Logout endpoint
+ router.register_exact(
+ "GET",
+ "/_irondrop/logout",
+ Box::new(|_| handle_logout_request()),
+ );
}
pub fn create_health_check_response() -> Response {
@@ -492,6 +499,14 @@ pub fn handle_file_request(
// For the current implementation, we'll allow POST but treat it like GET for basic functionality
debug!("POST request received, treating as GET for basic functionality");
}
+ "OPTIONS" | "PROPFIND" | "PROPPATCH" | "MKCOL" | "PUT" | "DELETE" | "COPY" | "MOVE"
+ | "LOCK" | "UNLOCK" => {
+ if !cli_config.and_then(|c| c.enable_webdav).unwrap_or(false) {
+ debug!("WebDAV method rejected because WebDAV is disabled");
+ return Err(AppError::MethodNotAllowed);
+ }
+ return crate::webdav::handle_webdav_request(request, base_dir, allowed_extensions);
+ }
_ => {
debug!("Method not allowed: {}", request.method);
return Err(AppError::MethodNotAllowed);
@@ -556,6 +571,7 @@ pub fn handle_file_request(
directory: cli.directory.clone(),
enable_upload: cli.enable_upload.unwrap_or(false),
max_upload_size: cli.max_upload_size_bytes(),
+ enable_webdav: cli.enable_webdav.unwrap_or(false),
username: cli.username.clone(),
password: cli.password.clone(),
allowed_extensions: cli
@@ -853,3 +869,30 @@ pub fn handle_memory_cleanup_request() -> Result {
}
}
}
+
+/// Handle explicit logout requests for Basic Auth
+pub fn handle_logout_request() -> Result {
+ debug!("Handling logout request");
+
+ let engine = crate::templates::TemplateEngine::global();
+ let html = engine
+ .render_logout_page()
+ .unwrap_or_else(|_| "Logged out".to_string());
+
+ let mut headers = HashMap::new();
+ headers.insert(
+ "Content-Type".to_string(),
+ "text/html; charset=utf-8".to_string(),
+ );
+ headers.insert(
+ "WWW-Authenticate".to_string(),
+ r#"Basic realm="IronDrop""#.to_string(),
+ );
+
+ Ok(Response {
+ status_code: 401,
+ status_text: "Unauthorized".to_string(),
+ headers,
+ body: ResponseBody::Text(html),
+ })
+}
diff --git a/src/http.rs b/src/http.rs
index 9652086..2931d53 100644
--- a/src/http.rs
+++ b/src/http.rs
@@ -132,7 +132,22 @@ impl Request {
fn is_valid_http_method(method: &str) -> bool {
matches!(
method,
- "GET" | "POST" | "PUT" | "DELETE" | "HEAD" | "OPTIONS" | "PATCH" | "TRACE" | "CONNECT"
+ "GET"
+ | "POST"
+ | "PUT"
+ | "DELETE"
+ | "HEAD"
+ | "OPTIONS"
+ | "PATCH"
+ | "TRACE"
+ | "CONNECT"
+ | "PROPFIND"
+ | "MKCOL"
+ | "COPY"
+ | "MOVE"
+ | "PROPPATCH"
+ | "LOCK"
+ | "UNLOCK"
)
}
@@ -324,6 +339,20 @@ impl Request {
headers: &HashMap,
remaining_bytes: Vec,
) -> Result, AppError> {
+ let has_content_length = headers.contains_key("content-length");
+ let has_chunked_transfer = Self::has_chunked_transfer_encoding(headers);
+
+ // RFC 9112: Transfer-Encoding and Content-Length must not be sent together.
+ if has_content_length && has_chunked_transfer {
+ return Err(AppError::BadRequest);
+ }
+
+ // Chunked request bodies are decoded before any method-specific handling.
+ if has_chunked_transfer {
+ let body = Self::read_chunked_body(stream, remaining_bytes)?;
+ return Ok(Some(body));
+ }
+
// Check if we have a Content-Length header
let content_length = match headers.get("content-length") {
Some(length_str) => match length_str.parse::() {
@@ -331,13 +360,6 @@ impl Request {
Err(_) => return Err(AppError::BadRequest),
},
None => {
- // Check for Transfer-Encoding: chunked (not fully implemented but detected)
- if let Some(encoding) = headers.get("transfer-encoding")
- && encoding.to_lowercase().contains("chunked")
- {
- warn!("Chunked transfer encoding not yet supported");
- return Err(AppError::BadRequest);
- }
// No body expected
return Ok(None);
}
@@ -364,6 +386,188 @@ impl Request {
}
}
+ fn has_chunked_transfer_encoding(headers: &HashMap) -> bool {
+ headers
+ .get("transfer-encoding")
+ .map(|encoding| {
+ encoding
+ .split(',')
+ .map(|token| token.trim())
+ .any(|token| token.eq_ignore_ascii_case("chunked"))
+ })
+ .unwrap_or(false)
+ }
+
+ fn read_chunked_body(
+ stream: &mut ClientStream,
+ mut pending: Vec,
+ ) -> Result {
+ const CHUNK_LINE_LIMIT: usize = 8 * 1024;
+
+ let mut total_size: usize = 0;
+ let mut memory_body: Vec = Vec::new();
+ let mut file_sink: Option<(PathBuf, File)> = None;
+
+ loop {
+ let line = Self::read_crlf_line(stream, &mut pending, CHUNK_LINE_LIMIT)?;
+ let line_str = std::str::from_utf8(&line).map_err(|_| AppError::BadRequest)?;
+ let size_token = line_str
+ .split(';')
+ .next()
+ .ok_or(AppError::BadRequest)?
+ .trim();
+ if size_token.is_empty() {
+ return Err(AppError::BadRequest);
+ }
+
+ let chunk_size =
+ usize::from_str_radix(size_token, 16).map_err(|_| AppError::BadRequest)?;
+ if chunk_size == 0 {
+ Self::consume_chunked_trailers(stream, &mut pending)?;
+ break;
+ }
+
+ let next_total = total_size
+ .checked_add(chunk_size)
+ .ok_or(AppError::PayloadTooLarge(MAX_REQUEST_BODY_SIZE as u64))?;
+ if next_total > MAX_REQUEST_BODY_SIZE {
+ return Err(AppError::PayloadTooLarge(MAX_REQUEST_BODY_SIZE as u64));
+ }
+
+ let chunk_data = Self::read_exact_from_buffer(stream, &mut pending, chunk_size)?;
+ Self::consume_expected_crlf(stream, &mut pending)?;
+
+ if file_sink.is_none() && next_total <= STREAM_TO_DISK_THRESHOLD {
+ memory_body.extend_from_slice(&chunk_data);
+ } else {
+ if file_sink.is_none() {
+ let (temp_path, mut temp_file) = Self::create_temp_body_file()?;
+ if !memory_body.is_empty() {
+ temp_file.write_all(&memory_body).map_err(|e| {
+ let _ = std::fs::remove_file(&temp_path);
+ AppError::from(e)
+ })?;
+ memory_body.clear();
+ }
+ file_sink = Some((temp_path, temp_file));
+ }
+ if let Some((temp_path, temp_file)) = file_sink.as_mut() {
+ temp_file.write_all(&chunk_data).map_err(|e| {
+ let _ = std::fs::remove_file(temp_path);
+ AppError::from(e)
+ })?;
+ }
+ }
+
+ total_size = next_total;
+ }
+
+ if let Some((temp_path, temp_file)) = file_sink.as_mut() {
+ temp_file.sync_all().map_err(|e| {
+ let _ = std::fs::remove_file(temp_path);
+ AppError::from(e)
+ })?;
+ }
+
+ if let Some((temp_path, _)) = file_sink {
+ Ok(RequestBody::File {
+ path: temp_path,
+ size: total_size as u64,
+ })
+ } else {
+ Ok(RequestBody::Memory(memory_body))
+ }
+ }
+
+ fn create_temp_body_file() -> Result<(PathBuf, File), AppError> {
+ let temp_filename = format!(
+ "irondrop_request_{}_{:x}.tmp",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_nanos()
+ );
+ let temp_path = std::env::temp_dir().join(temp_filename);
+ let temp_file = File::create(&temp_path).map_err(|e| {
+ error!("Failed to create temporary file {temp_path:?}: {e}");
+ AppError::from(e)
+ })?;
+ Ok((temp_path, temp_file))
+ }
+
+ fn read_crlf_line(
+ stream: &mut ClientStream,
+ pending: &mut Vec,
+ max_line_len: usize,
+ ) -> Result, AppError> {
+ loop {
+ if let Some(pos) = pending.windows(2).position(|w| w == b"\r\n") {
+ let line = pending[..pos].to_vec();
+ pending.drain(0..pos + 2);
+ return Ok(line);
+ }
+
+ if pending.len() > max_line_len + 2 {
+ return Err(AppError::BadRequest);
+ }
+
+ let mut buffer = [0u8; 8192];
+ match stream.read(&mut buffer) {
+ Ok(0) => return Err(AppError::BadRequest),
+ Ok(n) => pending.extend_from_slice(&buffer[..n]),
+ Err(e) => return Err(AppError::Io(e)),
+ }
+ }
+ }
+
+ fn read_exact_from_buffer(
+ stream: &mut ClientStream,
+ pending: &mut Vec,
+ count: usize,
+ ) -> Result, AppError> {
+ while pending.len() < count {
+ let mut buffer = [0u8; 8192];
+ match stream.read(&mut buffer) {
+ Ok(0) => return Err(AppError::BadRequest),
+ Ok(n) => pending.extend_from_slice(&buffer[..n]),
+ Err(e) => return Err(AppError::Io(e)),
+ }
+ }
+ Ok(pending.drain(0..count).collect())
+ }
+
+ fn consume_expected_crlf(
+ stream: &mut ClientStream,
+ pending: &mut Vec,
+ ) -> Result<(), AppError> {
+ let crlf = Self::read_exact_from_buffer(stream, pending, 2)?;
+ if crlf != b"\r\n" {
+ return Err(AppError::BadRequest);
+ }
+ Ok(())
+ }
+
+ fn consume_chunked_trailers(
+ stream: &mut ClientStream,
+ pending: &mut Vec,
+ ) -> Result<(), AppError> {
+ let mut total_trailer_size = 0usize;
+ loop {
+ let line = Self::read_crlf_line(stream, pending, MAX_HEADERS_SIZE)?;
+ total_trailer_size += line.len() + 2;
+ if total_trailer_size > MAX_HEADERS_SIZE {
+ return Err(AppError::BadRequest);
+ }
+
+ // Empty line marks end of trailers.
+ if line.is_empty() {
+ break;
+ }
+ }
+ Ok(())
+ }
+
/// Read small request body into memory
fn read_body_to_memory(
stream: &mut ClientStream,
@@ -614,6 +818,10 @@ pub fn handle_client(
match response_result {
Ok(response) => {
+ info!(
+ "{} {} {} -> {}",
+ log_prefix, request.method, request.path, response.status_code
+ );
trace!("{} Response status: {}", log_prefix, response.status_code);
match send_response(&mut stream, response, &log_prefix) {
Ok(body_bytes) => {
diff --git a/src/lib.rs b/src/lib.rs
index a471fc6..354a617 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -39,6 +39,7 @@ pub mod ultra_compact_search;
pub mod ultra_memory_test;
pub mod upload;
pub mod utils;
+pub mod webdav;
use crate::cli::Cli;
use crate::config::Config;
diff --git a/src/server.rs b/src/server.rs
index 91910c8..05d395e 100644
--- a/src/server.rs
+++ b/src/server.rs
@@ -1368,6 +1368,7 @@ pub fn run_server_with_config(config: Config) -> Result<(), AppError> {
password: config.password,
enable_upload: Some(config.enable_upload),
max_upload_size: Some(config.max_upload_size / (1024 * 1024)), // Convert bytes back to MB
+ enable_webdav: Some(config.enable_webdav),
config_file: None, // Not needed for server execution
log_dir: config.log_dir,
ssl_cert: config.ssl_cert,
@@ -1436,8 +1437,17 @@ pub fn run_server(
let is_https = tls_config.is_some();
// Initialize security and monitoring systems
- debug!("Initializing rate limiter: 120 req/min, 10 concurrent per IP");
- let rate_limiter = Arc::new(RateLimiter::new(120, 10)); // 120 req/min, 10 concurrent per IP
+ let webdav_enabled = cli.enable_webdav.unwrap_or(false);
+ let (rate_limit_per_minute, concurrent_per_ip) = if webdav_enabled {
+ (3500, 128)
+ } else {
+ (120, 10)
+ };
+ debug!(
+ "Initializing rate limiter: {} req/min, {} concurrent per IP",
+ rate_limit_per_minute, concurrent_per_ip
+ );
+ let rate_limiter = Arc::new(RateLimiter::new(rate_limit_per_minute, concurrent_per_ip));
debug!("Initializing server statistics");
let stats = Arc::new(ServerStats::new());
@@ -1460,8 +1470,19 @@ pub fn run_server(
if is_https {
info!("π TLS/SSL: Enabled");
}
- info!("β‘ Security: Rate limiting enabled (120 req/min, 10 concurrent per IP)");
+ info!(
+ "β‘ Security: Rate limiting enabled ({} req/min, {} concurrent per IP)",
+ rate_limit_per_minute, concurrent_per_ip
+ );
info!("π Monitoring: Statistics collection enabled");
+ info!(
+ "π§© WebDAV: {}",
+ if cli.enable_webdav.unwrap_or(false) {
+ "Enabled"
+ } else {
+ "Disabled"
+ }
+ );
let thread_count = cli.threads.unwrap_or(8);
debug!("Creating thread pool with {} threads", thread_count);
@@ -1475,6 +1496,7 @@ pub fn run_server(
let mut router = Router::new();
if cli_arc.username.is_some() && cli_arc.password.is_some() {
debug!("Adding authentication middleware to router");
+ crate::templates::AUTH_ENABLED.store(true, std::sync::atomic::Ordering::SeqCst);
router.add_middleware(Box::new(AuthMiddleware::new(
cli_arc.username.clone(),
cli_arc.password.clone(),
diff --git a/src/templates.rs b/src/templates.rs
index 7e6b6e6..924f625 100644
--- a/src/templates.rs
+++ b/src/templates.rs
@@ -6,6 +6,9 @@ use crate::error::AppError;
use log::{debug, trace};
use std::collections::HashMap;
use std::sync::OnceLock;
+use std::sync::atomic::AtomicBool;
+
+pub static AUTH_ENABLED: AtomicBool = AtomicBool::new(false);
// Embed templates at compile time
// Base template
@@ -16,6 +19,7 @@ const DIRECTORY_CONTENT_HTML: &str = include_str!("../templates/directory/conten
const ERROR_CONTENT_HTML: &str = include_str!("../templates/error/content.html");
const UPLOAD_CONTENT_HTML: &str = include_str!("../templates/upload/content.html");
const UPLOAD_SUCCESS_HTML: &str = include_str!("../templates/upload/success.html");
+const LOGOUT_CONTENT_HTML: &str = include_str!("../templates/common/logout.html");
// CSS and JS assets
const DIRECTORY_STYLES_CSS: &str = include_str!("../templates/directory/styles.css");
@@ -77,6 +81,7 @@ impl TemplateEngine {
templates.insert("upload_success", UPLOAD_SUCCESS_HTML);
templates.insert("upload_form", UPLOAD_FORM_HTML);
templates.insert("monitor_content", MONITOR_CONTENT_HTML);
+ templates.insert("logout_content", LOGOUT_CONTENT_HTML);
Self { templates }
}
@@ -159,7 +164,25 @@ impl TemplateEngine {
base_variables.insert("PAGE_TITLE".to_string(), page_title.to_string());
base_variables.insert("PAGE_STYLES".to_string(), page_styles.to_string());
base_variables.insert("PAGE_SCRIPTS".to_string(), page_scripts.to_string());
- base_variables.insert("HEADER_ACTIONS".to_string(), header_actions.to_string());
+
+ let auth_enabled = AUTH_ENABLED.load(std::sync::atomic::Ordering::SeqCst);
+ let full_header_actions = if auth_enabled && content_template != "logout_content" {
+ format!(
+ r#"{}
+
+
+
+
+
+
+ Logout
+ "#,
+ header_actions
+ )
+ } else {
+ header_actions.to_string()
+ };
+ base_variables.insert("HEADER_ACTIONS".to_string(), full_header_actions);
base_variables.insert("PAGE_CONTENT".to_string(), content);
base_variables.insert("VERSION".to_string(), crate::VERSION.to_string());
@@ -559,6 +582,13 @@ impl TemplateEngine {
// Use the new base template system
self.render_directory_page(&variables)
}
+
+ /// Generate logout page HTML using base template system
+ pub fn render_logout_page(&self) -> Result {
+ debug!("Rendering logout page");
+ let variables = HashMap::new();
+ self.render_page("logout_content", "Logged Out", "", "", "", &variables)
+ }
/// Generate error page HTML using base template system
pub fn render_error_page(
&self,
diff --git a/src/upload.rs b/src/upload.rs
index 2b9e30d..1d55623 100644
--- a/src/upload.rs
+++ b/src/upload.rs
@@ -1009,6 +1009,7 @@ mod tests {
password: None,
enable_upload: Some(true),
max_upload_size: Some(100), // 100MB for testing
+ enable_webdav: Some(false),
config_file: None,
log_dir: None,
ssl_cert: None,
diff --git a/src/webdav.rs b/src/webdav.rs
new file mode 100644
index 0000000..a82aad0
--- /dev/null
+++ b/src/webdav.rs
@@ -0,0 +1,1967 @@
+// SPDX-License-Identifier: MIT
+
+use crate::error::AppError;
+use crate::http::{Request, RequestBody, Response, ResponseBody};
+use log::{debug, trace};
+use std::collections::HashMap;
+use std::io::Write;
+use std::path::{Component, Path, PathBuf};
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{Mutex, OnceLock};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+#[derive(Clone)]
+struct DavLock {
+ token: String,
+ expires_at_epoch_secs: u64,
+ timeout_secs: u64,
+ depth_infinity: bool,
+ lockroot_href: String,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum DavDepth {
+ Zero,
+ One,
+ Infinity,
+}
+
+#[derive(Debug, Clone)]
+enum PropfindMode {
+ AllProp,
+ PropName,
+ Named(Vec),
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum CopyDepth {
+ Zero,
+ Infinity,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum PropPatchAction {
+ Set,
+ Remove,
+}
+
+#[derive(Debug, Clone)]
+struct PropPatchOperation {
+ action: PropPatchAction,
+ props: Vec<(PropName, Option)>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+struct PropName {
+ namespace: String,
+ local_name: String,
+}
+
+static DAV_LOCKS: OnceLock>> = OnceLock::new();
+static DAV_DEAD_PROPERTIES: OnceLock>>> =
+ OnceLock::new();
+static DAV_OP_GUARD: OnceLock> = OnceLock::new();
+static LOCK_COUNTER: AtomicU64 = AtomicU64::new(1);
+const DAV_NAMESPACE: &str = "DAV:";
+
+pub fn handle_webdav_request(
+ request: &Request,
+ base_dir: &Path,
+ allowed_extensions: &[glob::Pattern],
+) -> Result {
+ match request.method.as_str() {
+ "OPTIONS" => Ok(build_options_response()),
+ "PROPFIND" => handle_propfind(request, base_dir, allowed_extensions),
+ "MKCOL" => handle_mkcol(request, base_dir),
+ "PUT" => handle_put(request, base_dir, allowed_extensions),
+ "DELETE" => handle_delete(request, base_dir),
+ "COPY" => handle_copy_or_move(request, base_dir, false),
+ "MOVE" => handle_copy_or_move(request, base_dir, true),
+ "PROPPATCH" => handle_proppatch(request, base_dir),
+ "LOCK" => handle_lock(request, base_dir),
+ "UNLOCK" => handle_unlock(request, base_dir),
+ _ => Err(AppError::MethodNotAllowed),
+ }
+}
+
+pub fn allow_header_value() -> &'static str {
+ "OPTIONS, GET, HEAD, PROPFIND, PROPPATCH, MKCOL, PUT, DELETE, COPY, MOVE, LOCK, UNLOCK"
+}
+
+fn build_options_response() -> Response {
+ let mut headers = HashMap::new();
+ headers.insert("DAV".to_string(), "1,2".to_string());
+ headers.insert("Allow".to_string(), allow_header_value().to_string());
+ headers.insert("MS-Author-Via".to_string(), "DAV".to_string());
+ Response {
+ status_code: 200,
+ status_text: "OK".to_string(),
+ headers,
+ body: ResponseBody::Text(String::new()),
+ }
+}
+
+fn handle_propfind(
+ request: &Request,
+ base_dir: &Path,
+ allowed_extensions: &[glob::Pattern],
+) -> Result {
+ let depth = parse_depth_header(&request.headers)?;
+ let mode = parse_propfind_mode(request)?;
+ let target_path = resolve_request_path(base_dir, &request.path)?;
+
+ if !target_path.exists() {
+ return Err(AppError::NotFound);
+ }
+
+ if target_path.is_file()
+ && !allowed_extensions
+ .iter()
+ .any(|pattern| pattern.matches_path(&target_path))
+ {
+ return Err(AppError::Forbidden);
+ }
+
+ if depth == DavDepth::Infinity && target_path.is_dir() {
+ return Ok(propfind_finite_depth_error_response());
+ }
+
+ let mut resources = vec![target_path.clone()];
+ if depth == DavDepth::One && target_path.is_dir() {
+ for entry in std::fs::read_dir(&target_path)? {
+ let entry = entry?;
+ let entry_path = entry.path();
+ if entry_path.is_file()
+ && !allowed_extensions
+ .iter()
+ .any(|pattern| pattern.matches_path(&entry_path))
+ {
+ continue;
+ }
+ resources.push(entry_path);
+ }
+ }
+
+ let mut body = String::from(
+ r#"
+
+"#,
+ );
+
+ for resource in resources {
+ append_multistatus_response(&mut body, base_dir, &resource, &mode)?;
+ }
+ body.push_str(" \n");
+
+ let mut headers = HashMap::new();
+ headers.insert(
+ "Content-Type".to_string(),
+ "application/xml; charset=utf-8".to_string(),
+ );
+ headers.insert("DAV".to_string(), "1,2".to_string());
+ headers.insert("Allow".to_string(), allow_header_value().to_string());
+
+ Ok(Response {
+ status_code: 207,
+ status_text: "Multi-Status".to_string(),
+ headers,
+ body: ResponseBody::Text(body),
+ })
+}
+
+fn parse_propfind_mode(request: &Request) -> Result {
+ let body = request_body_bytes(request)?;
+ if body.is_empty() {
+ return Ok(PropfindMode::AllProp);
+ }
+
+ let body_str = std::str::from_utf8(&body).map_err(|_| AppError::BadRequest)?;
+ if contains_named_element(body_str, "propname") {
+ return Ok(PropfindMode::PropName);
+ }
+ if contains_named_element(body_str, "allprop") {
+ return Ok(PropfindMode::AllProp);
+ }
+ let requested = parse_requested_prop_names(body_str);
+ if !requested.is_empty() {
+ return Ok(PropfindMode::Named(requested));
+ }
+ // RFC default for empty propfind body shape or unknown child shape is allprop.
+ if contains_named_element(body_str, "propfind") {
+ return Ok(PropfindMode::AllProp);
+ }
+
+ Err(AppError::BadRequest)
+}
+
+fn contains_named_element(xml: &str, local_name: &str) -> bool {
+ let local_name = local_name.to_ascii_lowercase();
+ let mut idx = 0usize;
+ while let Some(lt_rel) = xml[idx..].find('<') {
+ let lt = idx + lt_rel;
+ let Some(gt_rel) = xml[lt..].find('>') else {
+ break;
+ };
+ let gt = lt + gt_rel;
+ let token = xml[lt + 1..gt].trim();
+ idx = gt + 1;
+ if token.is_empty()
+ || token.starts_with('/')
+ || token.starts_with('?')
+ || token.starts_with('!')
+ {
+ continue;
+ }
+ let raw = token
+ .split_whitespace()
+ .next()
+ .unwrap_or_default()
+ .trim_end_matches('/');
+ let local = raw
+ .split(':')
+ .next_back()
+ .unwrap_or_default()
+ .to_ascii_lowercase();
+ if local == local_name {
+ return true;
+ }
+ }
+ false
+}
+
+fn parse_requested_prop_names(xml: &str) -> Vec {
+ let lowered = xml.to_ascii_lowercase();
+ let prop_open = lowered.find("') else {
+ return Vec::new();
+ };
+ let open_end = start + open_end_rel + 1;
+ let close_idx = lowered[open_end..]
+ .find(" ")
+ .or_else(|| lowered[open_end..].find(""))
+ .map(|idx| open_end + idx)
+ .unwrap_or(xml.len());
+
+ let mut namespace_map = parse_xmlns_mappings(xml);
+ let prop_open_tag = &xml[start + 1..open_end - 1];
+ for (prefix, uri) in parse_xmlns_mappings_from_tag(prop_open_tag) {
+ namespace_map.insert(prefix, uri);
+ }
+
+ let inner = &xml[open_end..close_idx];
+ let mut names = Vec::new();
+ let mut idx = 0usize;
+ while let Some(lt_rel) = inner[idx..].find('<') {
+ let lt = idx + lt_rel;
+ let Some(gt_rel) = inner[lt..].find('>') else {
+ break;
+ };
+ let gt = lt + gt_rel;
+ let token = inner[lt + 1..gt].trim();
+ idx = gt + 1;
+ if token.is_empty()
+ || token.starts_with('/')
+ || token.starts_with('?')
+ || token.starts_with('!')
+ {
+ continue;
+ }
+
+ for (prefix, uri) in parse_xmlns_mappings_from_tag(token) {
+ namespace_map.insert(prefix, uri);
+ }
+ let Some(name) = parse_prop_name_from_token(token, &namespace_map) else {
+ continue;
+ };
+ if name.local_name == "prop" {
+ continue;
+ }
+ names.push(name);
+ }
+ names
+}
+
+fn parse_xmlns_mappings(xml: &str) -> HashMap {
+ let mut map = HashMap::new();
+ map.insert("d".to_string(), DAV_NAMESPACE.to_string());
+ map.insert(String::new(), DAV_NAMESPACE.to_string());
+ let mut idx = 0usize;
+ while let Some(start_rel) = xml[idx..].find("xmlns") {
+ let start = idx + start_rel;
+ let rest = &xml[start..];
+ let Some(eq_rel) = rest.find('=') else {
+ break;
+ };
+ let key = rest[..eq_rel].trim();
+ let quote_start = start + eq_rel + 1;
+ let quote_char = xml[quote_start..].chars().next().unwrap_or('"');
+ if quote_char != '"' && quote_char != '\'' {
+ idx = quote_start + 1;
+ continue;
+ }
+ let after_quote = quote_start + 1;
+ let Some(end_rel) = xml[after_quote..].find(quote_char) else {
+ break;
+ };
+ let value = &xml[after_quote..after_quote + end_rel];
+ let prefix = if let Some(p) = key.strip_prefix("xmlns:") {
+ p.trim().to_ascii_lowercase()
+ } else if key == "xmlns" {
+ String::new()
+ } else {
+ idx = after_quote + end_rel + 1;
+ continue;
+ };
+ map.insert(prefix, value.to_string());
+ idx = after_quote + end_rel + 1;
+ }
+ map
+}
+
+fn parse_xmlns_mappings_from_tag(tag: &str) -> HashMap {
+ parse_xmlns_mappings(tag)
+}
+
+fn parse_prop_name_from_token(
+ token: &str,
+ namespace_map: &HashMap,
+) -> Option {
+ let raw = token
+ .split_whitespace()
+ .next()
+ .unwrap_or_default()
+ .trim_end_matches('/')
+ .trim();
+ if raw.is_empty() {
+ return None;
+ }
+ let (prefix, local_name) = if let Some((p, local)) = raw.split_once(':') {
+ (p.to_ascii_lowercase(), local.to_ascii_lowercase())
+ } else {
+ (String::new(), raw.to_ascii_lowercase())
+ };
+ if local_name.is_empty() {
+ return None;
+ }
+ let namespace = namespace_map
+ .get(&prefix)
+ .cloned()
+ .unwrap_or_else(|| DAV_NAMESPACE.to_string());
+ Some(PropName {
+ namespace,
+ local_name,
+ })
+}
+
+fn propfind_finite_depth_error_response() -> Response {
+ let mut headers = HashMap::new();
+ headers.insert(
+ "Content-Type".to_string(),
+ "application/xml; charset=utf-8".to_string(),
+ );
+ Response {
+ status_code: 403,
+ status_text: "Forbidden".to_string(),
+ headers,
+ body: ResponseBody::Text(
+ r#"
+
+
+ "#
+ .to_string(),
+ ),
+ }
+}
+
+fn handle_mkcol(request: &Request, base_dir: &Path) -> Result {
+ let _op_guard = op_guard()
+ .lock()
+ .map_err(|_| AppError::InternalServerError("dav operation guard poisoned".to_string()))?;
+ let target_path = resolve_request_path(base_dir, &request.path)?;
+ if let Some(response) = lock_precondition_response(request, &target_path) {
+ debug!(
+ "WebDAV MKCOL blocked by lock target={} status={}",
+ target_path.display(),
+ response.status_code
+ );
+ return Ok(response);
+ }
+
+ if target_path.exists() {
+ return Ok(status_response(405, "Method Not Allowed"));
+ }
+
+ let Some(parent) = target_path.parent() else {
+ return Ok(status_response(409, "Conflict"));
+ };
+ if !parent.exists() || !parent.is_dir() {
+ return Ok(status_response(409, "Conflict"));
+ }
+
+ std::fs::create_dir(&target_path)?;
+ Ok(status_response(201, "Created"))
+}
+
+fn handle_put(
+ request: &Request,
+ base_dir: &Path,
+ allowed_extensions: &[glob::Pattern],
+) -> Result {
+ let _op_guard = op_guard()
+ .lock()
+ .map_err(|_| AppError::InternalServerError("dav operation guard poisoned".to_string()))?;
+ let target_path = resolve_request_path(base_dir, &request.path)?;
+ if let Some(response) = lock_precondition_response(request, &target_path) {
+ debug!(
+ "WebDAV PUT blocked by lock target={} status={}",
+ target_path.display(),
+ response.status_code
+ );
+ return Ok(response);
+ }
+
+ if target_path == base_dir {
+ return Err(AppError::Forbidden);
+ }
+
+ let Some(parent) = target_path.parent() else {
+ return Ok(status_response(409, "Conflict"));
+ };
+ if !parent.exists() || !parent.is_dir() {
+ return Ok(status_response(409, "Conflict"));
+ }
+ if target_path.exists() && target_path.is_dir() {
+ return Ok(status_response(405, "Method Not Allowed"));
+ }
+
+ if !allowed_extensions
+ .iter()
+ .any(|pattern| pattern.matches_path(&target_path))
+ {
+ return Err(AppError::Forbidden);
+ }
+
+ let existed = target_path.exists();
+ let body_bytes = request_body_bytes(request)?;
+
+ let mut file = std::fs::File::create(&target_path)?;
+ file.write_all(&body_bytes)?;
+ file.sync_all()?;
+
+ if existed {
+ Ok(status_response(204, "No Content"))
+ } else {
+ Ok(status_response(201, "Created"))
+ }
+}
+
+fn handle_delete(request: &Request, base_dir: &Path) -> Result {
+ let _op_guard = op_guard()
+ .lock()
+ .map_err(|_| AppError::InternalServerError("dav operation guard poisoned".to_string()))?;
+ let target_path = resolve_request_path(base_dir, &request.path)?;
+ debug!("WebDAV DELETE target={}", target_path.display());
+ if let Some(response) = lock_precondition_response(request, &target_path) {
+ debug!(
+ "WebDAV DELETE blocked by lock target={} status={}",
+ target_path.display(),
+ response.status_code
+ );
+ return Ok(response);
+ }
+
+ if target_path == base_dir {
+ return Err(AppError::Forbidden);
+ }
+ if !target_path.exists() {
+ return Err(AppError::NotFound);
+ }
+
+ if target_path.is_dir() {
+ let locked_descendants = locked_descendants_without_token(request, &target_path);
+ if !locked_descendants.is_empty() {
+ debug!(
+ "WebDAV DELETE returns multistatus target={} locked_descendants={}",
+ target_path.display(),
+ locked_descendants.len()
+ );
+ return Ok(delete_locked_multistatus_response(
+ base_dir,
+ &target_path,
+ &locked_descendants,
+ ));
+ }
+ }
+
+ if target_path.is_dir() {
+ std::fs::remove_dir_all(&target_path)?;
+ remove_locks_for_subtree(&target_path);
+ remove_dead_props_for_subtree(&target_path);
+ } else {
+ std::fs::remove_file(&target_path)?;
+ remove_lock_for_exact_path(&target_path);
+ remove_dead_prop_for_exact_path(&target_path);
+ }
+
+ debug!("WebDAV DELETE success target={}", target_path.display());
+ Ok(status_response(204, "No Content"))
+}
+
+fn handle_copy_or_move(
+ request: &Request,
+ base_dir: &Path,
+ is_move: bool,
+) -> Result {
+ let _op_guard = op_guard()
+ .lock()
+ .map_err(|_| AppError::InternalServerError("dav operation guard poisoned".to_string()))?;
+ let source = resolve_request_path(base_dir, &request.path)?;
+ debug!(
+ "WebDAV {} source={}",
+ if is_move { "MOVE" } else { "COPY" },
+ source.display()
+ );
+ if let Some(response) = lock_precondition_response(request, &source) {
+ debug!(
+ "WebDAV {} blocked by source lock source={} status={}",
+ if is_move { "MOVE" } else { "COPY" },
+ source.display(),
+ response.status_code
+ );
+ return Ok(response);
+ }
+ if source == base_dir {
+ return Err(AppError::Forbidden);
+ }
+ if !source.exists() {
+ return Err(AppError::NotFound);
+ }
+
+ let destination_header = request
+ .headers
+ .get("destination")
+ .ok_or(AppError::BadRequest)?;
+ let destination_request_path = extract_destination_path(
+ destination_header,
+ request.headers.get("host").map(String::as_str),
+ )?;
+ let destination = resolve_request_path(base_dir, &destination_request_path)?;
+ if let Some(response) = lock_precondition_response(request, &destination) {
+ debug!(
+ "WebDAV {} blocked by destination lock destination={} status={}",
+ if is_move { "MOVE" } else { "COPY" },
+ destination.display(),
+ response.status_code
+ );
+ return Ok(response);
+ }
+ if destination == base_dir {
+ return Err(AppError::Forbidden);
+ }
+ if source == destination || is_descendant_or_same(&destination, &source) {
+ return Err(AppError::BadRequest);
+ }
+
+ let Some(parent) = destination.parent() else {
+ return Ok(status_response(409, "Conflict"));
+ };
+ if !parent.exists() || !parent.is_dir() {
+ return Ok(status_response(409, "Conflict"));
+ }
+
+ let overwrite = request
+ .headers
+ .get("overwrite")
+ .map(|value| !value.trim().eq_ignore_ascii_case("F"))
+ .unwrap_or(true);
+ let destination_exists = destination.exists();
+ if destination_exists && !overwrite {
+ debug!(
+ "WebDAV {} overwrite denied source={} destination={}",
+ if is_move { "MOVE" } else { "COPY" },
+ source.display(),
+ destination.display()
+ );
+ return Ok(status_response(412, "Precondition Failed"));
+ }
+
+ if destination_exists {
+ if destination.is_dir() {
+ std::fs::remove_dir_all(&destination)?;
+ remove_locks_for_subtree(&destination);
+ remove_dead_props_for_subtree(&destination);
+ } else {
+ std::fs::remove_file(&destination)?;
+ remove_lock_for_exact_path(&destination);
+ remove_dead_prop_for_exact_path(&destination);
+ }
+ }
+
+ if is_move {
+ if std::fs::rename(&source, &destination).is_err() {
+ copy_path_recursive(&source, &destination)?;
+ if source.is_dir() {
+ std::fs::remove_dir_all(&source)?;
+ } else {
+ std::fs::remove_file(&source)?;
+ }
+ }
+ move_locks_for_subtree(&source, &destination);
+ move_dead_props(&source, &destination);
+ } else {
+ let depth = parse_copy_depth_header(&request.headers)?;
+ trace!(
+ "WebDAV COPY depth={:?} source={} destination={}",
+ depth,
+ source.display(),
+ destination.display()
+ );
+ if source.is_dir() && depth == CopyDepth::Zero {
+ std::fs::create_dir_all(&destination)?;
+ } else {
+ copy_path_recursive(&source, &destination)?;
+ }
+ copy_dead_props(&source, &destination);
+ }
+
+ if destination_exists {
+ Ok(status_response(204, "No Content"))
+ } else {
+ Ok(status_response(201, "Created"))
+ }
+}
+
+fn parse_copy_depth_header(headers: &HashMap) -> Result {
+ let depth = headers
+ .get("depth")
+ .map(|value| value.trim())
+ .unwrap_or("infinity");
+ match depth {
+ "0" => Ok(CopyDepth::Zero),
+ "infinity" | "Infinity" | "INFINITY" => Ok(CopyDepth::Infinity),
+ _ => Err(AppError::BadRequest),
+ }
+}
+
+fn handle_lock(request: &Request, base_dir: &Path) -> Result {
+ let _op_guard = op_guard()
+ .lock()
+ .map_err(|_| AppError::InternalServerError("dav operation guard poisoned".to_string()))?;
+ let target_path = resolve_request_path(base_dir, &request.path)?;
+ let key = lock_key(&target_path);
+ cleanup_expired_locks();
+ let lock_body = request_body_bytes(request)?;
+ debug!("WebDAV LOCK target={}", target_path.display());
+
+ let target_is_collection = target_path.is_dir() || request.path.ends_with('/');
+ let depth_infinity = parse_lock_depth(&request.headers, target_is_collection)?;
+ let timeout_secs = parse_lock_timeout_secs(&request.headers).unwrap_or(600);
+ let now = now_epoch_secs();
+ let expires_at = now.saturating_add(timeout_secs);
+ let lockroot_href = build_href(base_dir, &target_path, target_is_collection);
+
+ let token = format!(
+ "opaquelocktoken:{:x}-{:x}",
+ now,
+ LOCK_COUNTER.fetch_add(1, Ordering::Relaxed)
+ );
+
+ let map = locks_map();
+ let mut guard = map
+ .lock()
+ .map_err(|_| AppError::InternalServerError("lock map poisoned".to_string()))?;
+ if let Some(existing) = guard.get(&key)
+ && existing.expires_at_epoch_secs > now
+ {
+ if token_present_for_request(request, &existing.token, &request.path) {
+ debug!(
+ "WebDAV LOCK refresh accepted target={}",
+ target_path.display()
+ );
+ let refresh_lock = existing.clone();
+ if let Some(existing_mut) = guard.get_mut(&key) {
+ existing_mut.expires_at_epoch_secs = expires_at;
+ existing_mut.timeout_secs = timeout_secs;
+ }
+ drop(guard);
+ return Ok(lock_success_response(
+ &refresh_lock.token,
+ timeout_secs,
+ refresh_lock.depth_infinity,
+ &refresh_lock.lockroot_href,
+ true,
+ ));
+ }
+ debug!(
+ "WebDAV LOCK denied existing lock target={}",
+ target_path.display()
+ );
+ return Ok(status_response(423, "Locked"));
+ }
+ if let Some(invalid_response) = validate_new_lock_request(&lock_body) {
+ debug!(
+ "WebDAV LOCK invalid request target={} status={}",
+ target_path.display(),
+ invalid_response.status_code
+ );
+ return Ok(invalid_response);
+ }
+ guard.insert(
+ key.clone(),
+ DavLock {
+ token: token.clone(),
+ expires_at_epoch_secs: expires_at,
+ timeout_secs,
+ depth_infinity,
+ lockroot_href: lockroot_href.clone(),
+ },
+ );
+ drop(guard);
+ trace!(
+ "WebDAV LOCK created target={} depth_infinity={} timeout_secs={}",
+ target_path.display(),
+ depth_infinity,
+ timeout_secs
+ );
+
+ if !target_path.exists() {
+ if request.path.ends_with('/') {
+ std::fs::create_dir_all(&target_path)?;
+ } else {
+ if let Some(parent) = target_path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ let _ = std::fs::File::create(&target_path)?;
+ }
+ }
+
+ Ok(lock_success_response(
+ &token,
+ timeout_secs,
+ depth_infinity,
+ &lockroot_href,
+ false,
+ ))
+}
+
+fn validate_new_lock_request(body: &[u8]) -> Option {
+ if body.is_empty() {
+ debug!("WebDAV LOCK missing lockinfo body");
+ return Some(status_response(400, "Bad Request"));
+ }
+ let Ok(body_str) = std::str::from_utf8(body) else {
+ debug!("WebDAV LOCK body is not utf-8");
+ return Some(status_response(400, "Bad Request"));
+ };
+ let xml = body_str.to_ascii_lowercase();
+ let has_lockinfo = xml.contains("") || xml.contains(" ");
+ let has_exclusive = xml.contains(" ") || xml.contains(" ");
+ let has_shared = xml.contains(" ") || xml.contains(" ");
+ if !has_write || has_shared || !has_exclusive {
+ debug!(
+ "WebDAV LOCK unsupported scope/type has_write={} has_exclusive={} has_shared={}",
+ has_write, has_exclusive, has_shared
+ );
+ return Some(status_response(409, "Conflict"));
+ }
+ None
+}
+
+fn handle_unlock(request: &Request, base_dir: &Path) -> Result {
+ let _op_guard = op_guard()
+ .lock()
+ .map_err(|_| AppError::InternalServerError("dav operation guard poisoned".to_string()))?;
+ let target_path = resolve_request_path(base_dir, &request.path)?;
+ let key = lock_key(&target_path);
+ cleanup_expired_locks();
+ debug!("WebDAV UNLOCK target={}", target_path.display());
+
+ let lock_token_raw = request
+ .headers
+ .get("lock-token")
+ .ok_or(AppError::BadRequest)?;
+ let normalized_token = normalize_lock_token(lock_token_raw).ok_or(AppError::BadRequest)?;
+
+ let map = locks_map();
+ let mut guard = map
+ .lock()
+ .map_err(|_| AppError::InternalServerError("lock map poisoned".to_string()))?;
+ match guard.get(&key) {
+ Some(lock) if lock.token == normalized_token => {
+ guard.remove(&key);
+ debug!("WebDAV UNLOCK success target={}", target_path.display());
+ Ok(status_response(204, "No Content"))
+ }
+ Some(_) => {
+ debug!(
+ "WebDAV UNLOCK token mismatch target={}",
+ target_path.display()
+ );
+ Ok(status_response(409, "Conflict"))
+ }
+ None => {
+ debug!(
+ "WebDAV UNLOCK no active lock target={}",
+ target_path.display()
+ );
+ Ok(status_response(409, "Conflict"))
+ }
+ }
+}
+
+fn extract_destination_path(
+ destination_header: &str,
+ request_host: Option<&str>,
+) -> Result {
+ let trimmed = destination_header.trim();
+
+ if let Some(scheme_sep) = trimmed.find("://") {
+ let rest = &trimmed[scheme_sep + 3..];
+ let path_start = rest.find('/').ok_or(AppError::BadRequest)?;
+ let authority = &rest[..path_start];
+ if let Some(host) = request_host
+ && !authority.eq_ignore_ascii_case(host.trim())
+ {
+ return Err(AppError::BadRequest);
+ }
+ return decode_percent_path(&rest[path_start..]);
+ }
+
+ Err(AppError::BadRequest)
+}
+
+fn decode_percent_path(path: &str) -> Result {
+ let bytes = path.as_bytes();
+ let mut out = Vec::with_capacity(bytes.len());
+ let mut i = 0usize;
+ while i < bytes.len() {
+ if bytes[i] == b'%' {
+ if i + 2 >= bytes.len() {
+ return Err(AppError::BadRequest);
+ }
+ let hex =
+ std::str::from_utf8(&bytes[i + 1..i + 3]).map_err(|_| AppError::BadRequest)?;
+ let byte = u8::from_str_radix(hex, 16).map_err(|_| AppError::BadRequest)?;
+ out.push(byte);
+ i += 3;
+ } else {
+ out.push(bytes[i]);
+ i += 1;
+ }
+ }
+ String::from_utf8(out).map_err(|_| AppError::BadRequest)
+}
+
+fn copy_path_recursive(source: &Path, destination: &Path) -> Result<(), AppError> {
+ if source.is_dir() {
+ std::fs::create_dir_all(destination)?;
+ for entry in std::fs::read_dir(source)? {
+ let entry = entry?;
+ let src_path = entry.path();
+ let dst_path = destination.join(entry.file_name());
+ copy_path_recursive(&src_path, &dst_path)?;
+ }
+ Ok(())
+ } else {
+ if let Some(parent) = destination.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ std::fs::copy(source, destination)?;
+ Ok(())
+ }
+}
+
+fn request_body_bytes(request: &Request) -> Result, AppError> {
+ match &request.body {
+ Some(RequestBody::Memory(data)) => Ok(data.clone()),
+ Some(RequestBody::File { path, .. }) => std::fs::read(path).map_err(AppError::from),
+ None => Ok(Vec::new()),
+ }
+}
+
+fn dead_props_map() -> &'static Mutex>> {
+ DAV_DEAD_PROPERTIES.get_or_init(|| Mutex::new(HashMap::new()))
+}
+
+fn dead_props_for_path(path: &Path) -> HashMap {
+ let key = lock_key(path);
+ if let Ok(guard) = dead_props_map().lock() {
+ return guard.get(&key).cloned().unwrap_or_default();
+ }
+ HashMap::new()
+}
+
+fn copy_dead_props(source: &Path, destination: &Path) {
+ if let Ok(mut guard) = dead_props_map().lock() {
+ if let Some(props) = guard.get(&lock_key(source)).cloned() {
+ guard.insert(lock_key(destination), props);
+ }
+ if !source.is_dir() {
+ return;
+ }
+ let entries: Vec<(String, HashMap)> = guard
+ .iter()
+ .filter_map(|(key, props)| {
+ let current = Path::new(key);
+ current
+ .strip_prefix(source)
+ .ok()
+ .map(|rel| (lock_key(&destination.join(rel)), props.clone()))
+ })
+ .collect();
+ for (new_key, props) in entries {
+ guard.insert(new_key, props);
+ }
+ }
+}
+
+fn move_dead_props(source: &Path, destination: &Path) {
+ if let Ok(mut guard) = dead_props_map().lock() {
+ if let Some(props) = guard.remove(&lock_key(source)) {
+ guard.insert(lock_key(destination), props);
+ }
+ if !source.is_dir() {
+ return;
+ }
+ let keys: Vec = guard.keys().cloned().collect();
+ let mut moved = Vec::new();
+ for key in keys {
+ let current = Path::new(&key);
+ if let Ok(rel) = current.strip_prefix(source)
+ && let Some(props) = guard.remove(&key)
+ {
+ moved.push((lock_key(&destination.join(rel)), props));
+ }
+ }
+ for (new_key, props) in moved {
+ guard.insert(new_key, props);
+ }
+ }
+}
+
+fn remove_lock_for_exact_path(path: &Path) {
+ if let Ok(mut guard) = locks_map().lock() {
+ guard.remove(&lock_key(path));
+ }
+}
+
+fn remove_locks_for_subtree(root: &Path) {
+ if let Ok(mut guard) = locks_map().lock() {
+ let keys: Vec = guard
+ .keys()
+ .filter_map(|key| {
+ let current = Path::new(key);
+ if current == root || current.strip_prefix(root).is_ok() {
+ Some(key.clone())
+ } else {
+ None
+ }
+ })
+ .collect();
+ for key in keys {
+ guard.remove(&key);
+ }
+ }
+}
+
+fn move_locks_for_subtree(source: &Path, destination: &Path) {
+ if let Ok(mut guard) = locks_map().lock() {
+ let keys: Vec = guard.keys().cloned().collect();
+ let mut moved = Vec::new();
+ for key in keys {
+ let current = Path::new(&key);
+ if let Ok(rel) = current.strip_prefix(source)
+ && let Some(lock) = guard.remove(&key)
+ {
+ moved.push((lock_key(&destination.join(rel)), lock));
+ }
+ }
+ for (new_key, lock) in moved {
+ guard.insert(new_key, lock);
+ }
+ }
+}
+
+fn remove_dead_prop_for_exact_path(path: &Path) {
+ if let Ok(mut guard) = dead_props_map().lock() {
+ guard.remove(&lock_key(path));
+ }
+}
+
+fn remove_dead_props_for_subtree(root: &Path) {
+ if let Ok(mut guard) = dead_props_map().lock() {
+ let keys: Vec = guard
+ .keys()
+ .filter_map(|key| {
+ let current = Path::new(key);
+ if current == root || current.strip_prefix(root).is_ok() {
+ Some(key.clone())
+ } else {
+ None
+ }
+ })
+ .collect();
+ for key in keys {
+ guard.remove(&key);
+ }
+ }
+}
+
+fn handle_proppatch(request: &Request, base_dir: &Path) -> Result {
+ let _op_guard = op_guard()
+ .lock()
+ .map_err(|_| AppError::InternalServerError("dav operation guard poisoned".to_string()))?;
+ let target_path = resolve_request_path(base_dir, &request.path)?;
+ if !target_path.exists() {
+ return Err(AppError::NotFound);
+ }
+ if let Some(response) = lock_precondition_response(request, &target_path) {
+ debug!(
+ "WebDAV PROPPATCH blocked by lock target={} status={}",
+ target_path.display(),
+ response.status_code
+ );
+ return Ok(response);
+ }
+
+ let body = request_body_bytes(request)?;
+ let body_str = std::str::from_utf8(&body).map_err(|_| AppError::BadRequest)?;
+
+ let operations = parse_propertyupdate_operations(body_str);
+ if operations.is_empty() {
+ return Err(AppError::BadRequest);
+ }
+
+ let key = lock_key(&target_path);
+ let mut ok_props: Vec<(PropName, Option)> = Vec::new();
+ let mut missing_props: Vec<(PropName, Option)> = Vec::new();
+ let mut forbidden_props: Vec<(PropName, Option)> = Vec::new();
+
+ let mut guard = dead_props_map()
+ .lock()
+ .map_err(|_| AppError::InternalServerError("dead properties map poisoned".to_string()))?;
+ let props = guard.entry(key).or_default();
+
+ for operation in operations {
+ for (name, value) in operation.props {
+ if is_protected_property(&name) {
+ forbidden_props.push((name, None));
+ continue;
+ }
+ match operation.action {
+ PropPatchAction::Set => {
+ props.insert(name.clone(), value.unwrap_or_default());
+ ok_props.push((name, None));
+ }
+ PropPatchAction::Remove => {
+ if props.remove(&name).is_some() {
+ ok_props.push((name, None));
+ } else {
+ missing_props.push((name, None));
+ }
+ }
+ }
+ }
+ }
+ drop(guard);
+
+ let mut xml = String::from(
+ r#"
+
+
+"#,
+ );
+ let href = build_href(base_dir, &target_path, target_path.is_dir());
+ xml.push_str(" ");
+ xml.push_str(&xml_escape(&href));
+ xml.push_str(" \n");
+ if !ok_props.is_empty() {
+ append_propstat(&mut xml, &ok_props, "HTTP/1.1 200 OK");
+ }
+ if !missing_props.is_empty() {
+ append_propstat(&mut xml, &missing_props, "HTTP/1.1 404 Not Found");
+ }
+ if !forbidden_props.is_empty() {
+ append_propstat(&mut xml, &forbidden_props, "HTTP/1.1 403 Forbidden");
+ }
+ xml.push_str(" \n \n");
+
+ let mut headers = HashMap::new();
+ headers.insert(
+ "Content-Type".to_string(),
+ "application/xml; charset=utf-8".to_string(),
+ );
+ Ok(Response {
+ status_code: 207,
+ status_text: "Multi-Status".to_string(),
+ headers,
+ body: ResponseBody::Text(xml),
+ })
+}
+
+fn parse_propertyupdate_operations(xml: &str) -> Vec {
+ let lowered = xml.to_ascii_lowercase();
+ let global_namespace_map = parse_xmlns_mappings(xml);
+ let mut operations = Vec::new();
+ let mut idx = 0usize;
+
+ while idx < lowered.len() {
+ let next_set = lowered[idx..]
+ .find(" (PropPatchAction::Set, s),
+ (Some(_), Some(r)) => (PropPatchAction::Remove, r),
+ (Some(s), None) => (PropPatchAction::Set, s),
+ (None, Some(r)) => (PropPatchAction::Remove, r),
+ (None, None) => break,
+ };
+
+ let (close_tag, close_tag_plain) = match action {
+ PropPatchAction::Set => (" ", ""),
+ PropPatchAction::Remove => ("", ""),
+ };
+
+ let Some(open_end_rel) = lowered[op_start..].find('>') else {
+ break;
+ };
+ let open_end = op_start + open_end_rel + 1;
+ let Some(op_end) = lowered[open_end..]
+ .find(close_tag)
+ .or_else(|| lowered[open_end..].find(close_tag_plain))
+ .map(|v| open_end + v)
+ else {
+ break;
+ };
+
+ let props = parse_prop_elements(&xml[open_end..op_end], &global_namespace_map);
+ if !props.is_empty() {
+ operations.push(PropPatchOperation { action, props });
+ }
+ idx = if lowered[op_end..].starts_with(close_tag) {
+ op_end + close_tag.len()
+ } else {
+ op_end + close_tag_plain.len()
+ };
+ }
+
+ operations
+}
+
+fn is_protected_property(name: &PropName) -> bool {
+ if name.namespace != DAV_NAMESPACE {
+ return false;
+ }
+ matches!(
+ name.local_name.as_str(),
+ "displayname"
+ | "resourcetype"
+ | "getcontentlength"
+ | "getlastmodified"
+ | "getcontenttype"
+ | "creationdate"
+ | "getetag"
+ | "supportedlock"
+ | "lockdiscovery"
+ )
+}
+
+fn parse_prop_elements(
+ fragment: &str,
+ base_namespace_map: &HashMap,
+) -> Vec<(PropName, Option)> {
+ let mut items = Vec::new();
+ let mut namespace_map = base_namespace_map.clone();
+ for (prefix, uri) in parse_xmlns_mappings(fragment) {
+ namespace_map.insert(prefix, uri);
+ }
+ let mut idx = 0usize;
+ while let Some(lt_rel) = fragment[idx..].find('<') {
+ let lt = idx + lt_rel;
+ let Some(gt_rel) = fragment[lt..].find('>') else {
+ break;
+ };
+ let gt = lt + gt_rel;
+ let token = fragment[lt + 1..gt].trim();
+ idx = gt + 1;
+
+ if token.is_empty()
+ || token.starts_with('/')
+ || token.starts_with('?')
+ || token.starts_with('!')
+ || token.to_ascii_lowercase().contains(":prop")
+ {
+ continue;
+ }
+
+ for (prefix, uri) in parse_xmlns_mappings_from_tag(token) {
+ namespace_map.insert(prefix, uri);
+ }
+ let Some(name) = parse_prop_name_from_token(token, &namespace_map) else {
+ continue;
+ };
+
+ if token.ends_with('/') {
+ items.push((name, None));
+ continue;
+ }
+ let raw = token
+ .split_whitespace()
+ .next()
+ .unwrap_or_default()
+ .trim_end_matches('/');
+
+ let close_tag = format!("{raw}>");
+ if let Some(close_rel) = fragment[idx..]
+ .to_ascii_lowercase()
+ .find(&close_tag.to_ascii_lowercase())
+ {
+ let value = fragment[idx..idx + close_rel].trim().to_string();
+ idx += close_rel + close_tag.len();
+ items.push((name, Some(value)));
+ } else {
+ items.push((name, None));
+ }
+ }
+ items
+}
+
+fn parse_depth_header(headers: &HashMap) -> Result {
+ let depth = headers
+ .get("depth")
+ .map(|value| value.trim())
+ .unwrap_or("infinity");
+ match depth {
+ "0" => Ok(DavDepth::Zero),
+ "1" => Ok(DavDepth::One),
+ "infinity" | "Infinity" | "INFINITY" => Ok(DavDepth::Infinity),
+ _ => Err(AppError::BadRequest),
+ }
+}
+
+fn locked_descendants_without_token(request: &Request, target_path: &Path) -> Vec {
+ cleanup_expired_locks();
+ let mut locked = Vec::new();
+ let Ok(guard) = locks_map().lock() else {
+ return locked;
+ };
+ for (path_key, lock) in guard.iter() {
+ let locked_path = Path::new(path_key);
+ if locked_path != target_path
+ && is_descendant_or_same(locked_path, target_path)
+ && !token_present_for_request(request, &lock.token, &request.path)
+ {
+ locked.push(PathBuf::from(path_key));
+ }
+ }
+ locked.sort();
+ locked.dedup();
+ locked
+}
+
+fn delete_locked_multistatus_response(
+ base_dir: &Path,
+ target_path: &Path,
+ locked_descendants: &[PathBuf],
+) -> Response {
+ let mut body = String::from(
+ r#"
+
+"#,
+ );
+
+ for locked in locked_descendants {
+ body.push_str(" \n");
+ body.push_str(" ");
+ body.push_str(&xml_escape(&build_href(base_dir, locked, locked.is_dir())));
+ body.push_str(" \n");
+ body.push_str(" HTTP/1.1 423 Locked \n");
+ body.push_str(" \n");
+ }
+
+ body.push_str(" \n");
+ body.push_str(" ");
+ body.push_str(&xml_escape(&build_href(
+ base_dir,
+ target_path,
+ target_path.is_dir(),
+ )));
+ body.push_str(" \n");
+ body.push_str(" HTTP/1.1 424 Failed Dependency \n");
+ body.push_str(" \n");
+ body.push_str(" \n");
+
+ let mut headers = HashMap::new();
+ headers.insert(
+ "Content-Type".to_string(),
+ "application/xml; charset=utf-8".to_string(),
+ );
+ Response {
+ status_code: 207,
+ status_text: "Multi-Status".to_string(),
+ headers,
+ body: ResponseBody::Text(body),
+ }
+}
+
+fn lock_precondition_response(request: &Request, target_path: &Path) -> Option {
+ cleanup_expired_locks();
+ let map = locks_map();
+ let Ok(guard) = map.lock() else {
+ return Some(status_response(500, "Internal Server Error"));
+ };
+ for (key, lock) in guard.iter() {
+ let lock_path = Path::new(key);
+ let applies = lock_path == target_path
+ || (lock.depth_infinity && is_same_or_ancestor(lock_path, target_path));
+ if applies && !token_present_for_request(request, &lock.token, &request.path) {
+ trace!(
+ "WebDAV precondition failed target={} lock_path={} depth_infinity={}",
+ target_path.display(),
+ lock_path.display(),
+ lock.depth_infinity
+ );
+ return Some(status_response(423, "Locked"));
+ }
+ }
+ None
+}
+
+fn token_present_for_request(request: &Request, expected_token: &str, request_path: &str) -> bool {
+ if request
+ .headers
+ .get("lock-token")
+ .and_then(|v| normalize_lock_token(v))
+ .map(|t| t == expected_token)
+ .unwrap_or(false)
+ {
+ return true;
+ }
+
+ let if_header = request.headers.get("if").map(String::as_str).unwrap_or("");
+ if_header_matches_lock_token(if_header, expected_token, request_path)
+}
+
+fn if_header_matches_lock_token(if_header: &str, expected_token: &str, request_path: &str) -> bool {
+ if if_header.trim().is_empty() {
+ return false;
+ }
+
+ let request_path = request_path_only(request_path);
+ let mut idx = 0usize;
+ while let Some(open_rel) = if_header[idx..].find('(') {
+ let open = idx + open_rel;
+ let Some(close_rel) = if_header[open + 1..].find(')') else {
+ break;
+ };
+ let close = open + 1 + close_rel;
+ let list = &if_header[open + 1..close];
+ let tag_matches = if_header_resource_tag_matches(&if_header[idx..open], &request_path);
+ idx = close + 1;
+ if !tag_matches {
+ continue;
+ }
+
+ let tokens: Vec<&str> = list.split_whitespace().collect();
+ if tokens.is_empty() {
+ continue;
+ }
+
+ let mut list_ok = true;
+ let mut has_positive_expected = false;
+ let mut i = 0usize;
+ while i < tokens.len() {
+ let mut not = false;
+ if tokens[i].eq_ignore_ascii_case("Not") {
+ not = true;
+ i += 1;
+ if i >= tokens.len() {
+ list_ok = false;
+ break;
+ }
+ }
+
+ let term = tokens[i];
+ i += 1;
+ if term.starts_with('<') && term.ends_with('>') {
+ let token = term.trim_matches(|c| c == '<' || c == '>');
+ if token == expected_token {
+ if not {
+ list_ok = false;
+ break;
+ }
+ has_positive_expected = true;
+ } else if !not {
+ list_ok = false;
+ break;
+ }
+ } else {
+ // Ignore ETag/state token terms for lock-token authorization.
+ continue;
+ }
+ }
+
+ if list_ok && has_positive_expected {
+ return true;
+ }
+ }
+ false
+}
+
+fn request_path_only(path: &str) -> String {
+ let only_path = path.split('?').next().unwrap_or(path);
+ format!("/{}", only_path.trim_start_matches('/'))
+}
+
+fn if_header_resource_tag_matches(prefix: &str, request_path: &str) -> bool {
+ let trimmed = prefix.trim();
+ if trimmed.is_empty() {
+ return true;
+ }
+ let Some(start) = trimmed.rfind('<') else {
+ return true;
+ };
+ let Some(end) = trimmed.rfind('>') else {
+ return true;
+ };
+ if end <= start + 1 {
+ return true;
+ }
+ let tag = &trimmed[start + 1..end];
+ if let Some(path_start) = tag
+ .find("://")
+ .and_then(|sep| tag[sep + 3..].find('/').map(|v| sep + 3 + v))
+ {
+ let tagged_path = &tag[path_start..];
+ return tagged_path == request_path;
+ }
+ if tag.starts_with('/') {
+ return tag == request_path;
+ }
+ true
+}
+
+fn is_descendant_or_same(path: &Path, ancestor: &Path) -> bool {
+ path == ancestor || path.strip_prefix(ancestor).is_ok()
+}
+
+fn is_same_or_ancestor(ancestor: &Path, path: &Path) -> bool {
+ path == ancestor || path.strip_prefix(ancestor).is_ok()
+}
+
+fn lock_success_response(
+ token: &str,
+ timeout_secs: u64,
+ depth_infinity: bool,
+ lockroot_href: &str,
+ is_refresh: bool,
+) -> Response {
+ let timeout_header = format!("Second-{timeout_secs}");
+ let lock_body = lockdiscovery_xml(token, &timeout_header, depth_infinity, lockroot_href);
+ let mut headers = HashMap::new();
+ headers.insert(
+ "Content-Type".to_string(),
+ "application/xml; charset=utf-8".to_string(),
+ );
+ headers.insert("Lock-Token".to_string(), format!("<{token}>"));
+ headers.insert("Timeout".to_string(), timeout_header);
+ Response {
+ status_code: if is_refresh { 200 } else { 201 },
+ status_text: if is_refresh { "OK" } else { "Created" }.to_string(),
+ headers,
+ body: ResponseBody::Text(lock_body),
+ }
+}
+
+fn locks_map() -> &'static Mutex> {
+ DAV_LOCKS.get_or_init(|| Mutex::new(HashMap::new()))
+}
+
+fn op_guard() -> &'static Mutex<()> {
+ DAV_OP_GUARD.get_or_init(|| Mutex::new(()))
+}
+
+fn current_lock_for_path(path: &Path) -> Option {
+ cleanup_expired_locks();
+ let guard = locks_map().lock().ok()?;
+ guard.get(&lock_key(path)).cloned()
+}
+
+fn lock_key(path: &Path) -> String {
+ path.to_string_lossy().to_string()
+}
+
+fn now_epoch_secs() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs()
+}
+
+fn cleanup_expired_locks() {
+ let now = now_epoch_secs();
+ if let Ok(mut guard) = locks_map().lock() {
+ guard.retain(|_, lock| lock.expires_at_epoch_secs > now);
+ }
+}
+
+fn parse_lock_timeout_secs(headers: &HashMap) -> Option {
+ let timeout = headers.get("timeout")?;
+ for token in timeout.split(',') {
+ let token = token.trim();
+ if token.eq_ignore_ascii_case("infinite") {
+ return Some(3600);
+ }
+ if let Some(seconds) = token.strip_prefix("Second-")
+ && let Ok(v) = seconds.parse::()
+ {
+ return Some(v.clamp(1, 3600));
+ }
+ }
+ None
+}
+
+fn parse_lock_depth(
+ headers: &HashMap,
+ is_collection: bool,
+) -> Result {
+ let depth = headers
+ .get("depth")
+ .map(|value| value.trim())
+ .unwrap_or(if is_collection { "infinity" } else { "0" });
+ match depth {
+ "0" => Ok(false),
+ "infinity" | "Infinity" | "INFINITY" if is_collection => Ok(true),
+ _ => Err(AppError::BadRequest),
+ }
+}
+
+fn normalize_lock_token(value: &str) -> Option {
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ return None;
+ }
+ let without_brackets = trimmed
+ .strip_prefix('<')
+ .and_then(|v| v.strip_suffix('>'))
+ .unwrap_or(trimmed);
+ Some(without_brackets.to_string())
+}
+
+fn lockdiscovery_xml(
+ token: &str,
+ timeout: &str,
+ depth_infinity: bool,
+ lockroot_href: &str,
+) -> String {
+ let depth_value = if depth_infinity { "Infinity" } else { "0" };
+ format!(
+ r#"
+
+
+
+
+
+ {depth_value}
+ {timeout}
+ {lockroot_href}
+ {token}
+
+
+ "#
+ )
+}
+
+fn status_response(status_code: u16, status_text: &str) -> Response {
+ if status_code == 423 {
+ let mut headers = HashMap::new();
+ headers.insert(
+ "Content-Type".to_string(),
+ "application/xml; charset=utf-8".to_string(),
+ );
+ let body = r#"
+
+
+ "#
+ .to_string();
+ return Response {
+ status_code,
+ status_text: status_text.to_string(),
+ headers,
+ body: ResponseBody::Text(body),
+ };
+ }
+
+ let mut headers = HashMap::new();
+ headers.insert("Content-Length".to_string(), "0".to_string());
+ Response {
+ status_code,
+ status_text: status_text.to_string(),
+ headers,
+ body: ResponseBody::Text(String::new()),
+ }
+}
+
+fn resolve_request_path(base_dir: &Path, request_path: &str) -> Result {
+ let path_only = request_path.split('?').next().unwrap_or(request_path);
+ let requested_path = PathBuf::from(path_only.strip_prefix('/').unwrap_or(path_only));
+ let safe_path = normalize_relative_path(&requested_path)?;
+ let full_path = base_dir.join(safe_path);
+ if !full_path.starts_with(base_dir) {
+ return Err(AppError::Forbidden);
+ }
+ let base_canonical = std::fs::canonicalize(base_dir)?;
+ if full_path.exists() {
+ let resolved = std::fs::canonicalize(&full_path)?;
+ if !resolved.starts_with(&base_canonical) {
+ return Err(AppError::Forbidden);
+ }
+ } else {
+ let mut current = full_path.parent();
+ while let Some(parent) = current {
+ if parent.exists() {
+ let resolved_parent = std::fs::canonicalize(parent)?;
+ if !resolved_parent.starts_with(&base_canonical) {
+ return Err(AppError::Forbidden);
+ }
+ break;
+ }
+ current = parent.parent();
+ }
+ }
+ Ok(full_path)
+}
+
+fn normalize_relative_path(path: &Path) -> Result {
+ let mut components = Vec::new();
+ for component in path.components() {
+ match component {
+ Component::Normal(name) => components.push(name),
+ Component::ParentDir => {
+ if components.pop().is_none() {
+ return Err(AppError::Forbidden);
+ }
+ }
+ _ => {}
+ }
+ }
+ Ok(components.iter().collect())
+}
+
+fn append_multistatus_response(
+ xml: &mut String,
+ base_dir: &Path,
+ resource: &Path,
+ mode: &PropfindMode,
+) -> Result<(), AppError> {
+ let metadata = std::fs::metadata(resource)?;
+ let is_dir = metadata.is_dir();
+ let href = build_href(base_dir, resource, is_dir);
+ let displayname = if resource == base_dir {
+ "/".to_string()
+ } else {
+ resource
+ .file_name()
+ .map(|name| name.to_string_lossy().to_string())
+ .unwrap_or_else(|| "/".to_string())
+ };
+
+ xml.push_str(" \n");
+ xml.push_str(" ");
+ xml.push_str(&xml_escape(&href));
+ xml.push_str(" \n");
+ let live_props = build_live_props(resource, is_dir, &displayname, &metadata);
+ match mode {
+ PropfindMode::AllProp => {
+ let mut merged = live_props.clone();
+ let dead = dead_props_for_path(resource);
+ for (name, value) in dead {
+ if !merged.iter().any(|(live_name, _)| live_name == &name) {
+ merged.push((name, Some(xml_escape(&value))));
+ }
+ }
+ append_propstat(xml, &merged, "HTTP/1.1 200 OK");
+ }
+ PropfindMode::PropName => {
+ let mut names: Vec<(PropName, Option)> = live_props
+ .iter()
+ .map(|(name, _)| (name.clone(), None))
+ .collect();
+ let dead = dead_props_for_path(resource);
+ for dead_name in dead.keys() {
+ if !names.iter().any(|(name, _)| name == dead_name) {
+ names.push((dead_name.clone(), None));
+ }
+ }
+ append_propstat(xml, &names, "HTTP/1.1 200 OK");
+ }
+ PropfindMode::Named(requested) => {
+ let mut ok_props: Vec<(PropName, Option)> = Vec::new();
+ let mut not_found_props: Vec<(PropName, Option)> = Vec::new();
+ let dead = dead_props_for_path(resource);
+
+ for prop in requested {
+ if let Some((_, value)) = live_props.iter().find(|(name, _)| name == prop) {
+ ok_props.push((prop.clone(), value.clone()));
+ } else if let Some(value) = dead.get(prop) {
+ ok_props.push((prop.clone(), Some(xml_escape(value))));
+ } else {
+ not_found_props.push((prop.clone(), None));
+ }
+ }
+
+ if !ok_props.is_empty() {
+ append_propstat(xml, &ok_props, "HTTP/1.1 200 OK");
+ }
+ if !not_found_props.is_empty() {
+ append_propstat(xml, ¬_found_props, "HTTP/1.1 404 Not Found");
+ }
+ }
+ }
+ xml.push_str(" \n");
+
+ Ok(())
+}
+
+fn append_propstat(xml: &mut String, props: &[(PropName, Option)], status: &str) {
+ xml.push_str(" \n");
+ xml.push_str(" \n");
+ for (name, value) in props {
+ let (prefix, xmlns_attr) = prop_render_prefix_and_xmlns(name);
+ xml.push_str(" <");
+ xml.push_str(&prefix);
+ xml.push(':');
+ xml.push_str(&name.local_name);
+ if !xmlns_attr.is_empty() {
+ xml.push(' ');
+ xml.push_str(&xmlns_attr);
+ }
+ if let Some(v) = value {
+ xml.push('>');
+ xml.push_str(v);
+ xml.push_str("");
+ xml.push_str(&prefix);
+ xml.push(':');
+ xml.push_str(&name.local_name);
+ xml.push_str(">\n");
+ } else {
+ xml.push_str("/>\n");
+ }
+ }
+ xml.push_str(" \n");
+ xml.push_str(" ");
+ xml.push_str(status);
+ xml.push_str(" \n");
+ xml.push_str(" \n");
+}
+
+fn dav_prop_name(local_name: &str) -> PropName {
+ PropName {
+ namespace: DAV_NAMESPACE.to_string(),
+ local_name: local_name.to_ascii_lowercase(),
+ }
+}
+
+fn prop_render_prefix_and_xmlns(name: &PropName) -> (String, String) {
+ if name.namespace == DAV_NAMESPACE {
+ return ("D".to_string(), String::new());
+ }
+ (
+ "X".to_string(),
+ format!(r#"xmlns:X="{}""#, xml_escape(&name.namespace)),
+ )
+}
+
+fn build_live_props(
+ resource: &Path,
+ is_dir: bool,
+ displayname: &str,
+ metadata: &std::fs::Metadata,
+) -> Vec<(PropName, Option)> {
+ let mut props = Vec::new();
+ props.push((dav_prop_name("displayname"), Some(xml_escape(displayname))));
+
+ if is_dir {
+ props.push((
+ dav_prop_name("resourcetype"),
+ Some(" ".to_string()),
+ ));
+ } else {
+ props.push((dav_prop_name("resourcetype"), Some(String::new())));
+ props.push((
+ dav_prop_name("getcontentlength"),
+ Some(metadata.len().to_string()),
+ ));
+ props.push((
+ dav_prop_name("getcontenttype"),
+ Some("application/octet-stream".to_string()),
+ ));
+ }
+
+ if let Ok(modified) = metadata.modified()
+ && let Some(http_date) = format_http_date(modified)
+ {
+ props.push((
+ dav_prop_name("getlastmodified"),
+ Some(xml_escape(&http_date)),
+ ));
+ }
+ if let Ok(modified) = metadata.modified()
+ && let Some(creation_date) = format_iso8601_utc(modified)
+ {
+ props.push((
+ dav_prop_name("creationdate"),
+ Some(xml_escape(&creation_date)),
+ ));
+ }
+ props.push((
+ dav_prop_name("getetag"),
+ Some(etag_for_resource(resource, metadata)),
+ ));
+ props.push((
+ dav_prop_name("supportedlock"),
+ Some(" ".to_string()),
+ ));
+ if let Some(lock) = current_lock_for_path(resource) {
+ props.push((
+ dav_prop_name("lockdiscovery"),
+ Some(format!(
+ "{} Second-{} {} {} ",
+ if lock.depth_infinity { "Infinity" } else { "0" },
+ lock.timeout_secs,
+ xml_escape(&lock.lockroot_href),
+ xml_escape(&lock.token)
+ )),
+ ));
+ } else {
+ props.push((dav_prop_name("lockdiscovery"), Some(String::new())));
+ }
+
+ props
+}
+
+fn etag_for_resource(path: &Path, metadata: &std::fs::Metadata) -> String {
+ let modified = metadata
+ .modified()
+ .ok()
+ .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
+ .map(|d| d.as_secs())
+ .unwrap_or_default();
+ let seed = path.to_string_lossy();
+ format!("\"{:x}-{:x}-{:x}\"", metadata.len(), modified, seed.len())
+}
+
+fn build_href(base_dir: &Path, resource: &Path, is_dir: bool) -> String {
+ if resource == base_dir {
+ return "/".to_string();
+ }
+
+ let relative = resource.strip_prefix(base_dir).unwrap_or(resource);
+ let mut href = String::from("/");
+ let mut first = true;
+ for component in relative.components() {
+ if let Component::Normal(segment) = component {
+ if !first {
+ href.push('/');
+ }
+ href.push_str(&percent_encode(segment.to_string_lossy().as_ref()));
+ first = false;
+ }
+ }
+ if is_dir && !href.ends_with('/') {
+ href.push('/');
+ }
+ href
+}
+
+fn percent_encode(value: &str) -> String {
+ let mut out = String::new();
+ for b in value.bytes() {
+ let is_unreserved = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~');
+ if is_unreserved {
+ out.push(b as char);
+ } else {
+ out.push_str(&format!("%{b:02X}"));
+ }
+ }
+ out
+}
+
+fn xml_escape(value: &str) -> String {
+ value
+ .replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+ .replace('\'', "'")
+}
+
+fn format_http_date(time: SystemTime) -> Option {
+ const WEEKDAYS: [&str; 7] = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
+ const MONTHS: [&str; 12] = [
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
+ ];
+
+ let total_seconds = time.duration_since(UNIX_EPOCH).ok()?.as_secs() as i64;
+ let days = total_seconds.div_euclid(86_400);
+ let secs_of_day = total_seconds.rem_euclid(86_400);
+
+ let hour = (secs_of_day / 3600) as u32;
+ let minute = ((secs_of_day % 3600) / 60) as u32;
+ let second = (secs_of_day % 60) as u32;
+
+ let weekday_idx = (days.rem_euclid(7)) as usize;
+ let weekday = WEEKDAYS[weekday_idx];
+
+ let (year, month, day) = civil_from_days(days);
+ let month_name = MONTHS[(month - 1) as usize];
+
+ Some(format!(
+ "{weekday}, {day:02} {month_name} {year:04} {hour:02}:{minute:02}:{second:02} GMT"
+ ))
+}
+
+fn format_iso8601_utc(time: SystemTime) -> Option {
+ let total_seconds = time.duration_since(UNIX_EPOCH).ok()?.as_secs() as i64;
+ let days = total_seconds.div_euclid(86_400);
+ let secs_of_day = total_seconds.rem_euclid(86_400);
+
+ let hour = (secs_of_day / 3600) as u32;
+ let minute = ((secs_of_day % 3600) / 60) as u32;
+ let second = (secs_of_day % 60) as u32;
+ let (year, month, day) = civil_from_days(days);
+ Some(format!(
+ "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z"
+ ))
+}
+
+fn civil_from_days(days_since_epoch: i64) -> (i32, u32, u32) {
+ let z = days_since_epoch + 719_468;
+ let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
+ let doe = z - era * 146_097;
+ let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
+ let mut year = (yoe as i32) + era as i32 * 400;
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ let mp = (5 * doy + 2) / 153;
+ let day = doy - (153 * mp + 2) / 5 + 1;
+ let month = mp + if mp < 10 { 3 } else { -9 };
+ if month <= 2 {
+ year += 1;
+ }
+ (year, month as u32, day as u32)
+}
diff --git a/templates/common/logout.html b/templates/common/logout.html
new file mode 100644
index 0000000..05bab1c
--- /dev/null
+++ b/templates/common/logout.html
@@ -0,0 +1,13 @@
+
+
diff --git a/tests/config_test.rs b/tests/config_test.rs
index d814e9e..7c61978 100644
--- a/tests/config_test.rs
+++ b/tests/config_test.rs
@@ -178,6 +178,7 @@ verbose = false
password: None,
enable_upload: Some(false),
max_upload_size: Some(10240),
+ enable_webdav: Some(false),
config_file: Some(config_file.to_string_lossy().to_string()),
log_dir: None,
ssl_cert: None,
@@ -225,6 +226,7 @@ max_upload_size = 1GB
password: None,
enable_upload: None,
max_upload_size: None,
+ enable_webdav: None,
config_file: Some(explicit_config.to_string_lossy().to_string()),
log_dir: None,
ssl_cert: None,
@@ -256,6 +258,7 @@ fn test_config_defaults() {
password: None,
enable_upload: Some(false),
max_upload_size: Some(10240),
+ enable_webdav: Some(false),
config_file: None,
log_dir: None,
ssl_cert: None,
@@ -296,6 +299,7 @@ fn test_config_file_load_error() {
password: None,
enable_upload: Some(false),
max_upload_size: Some(10240),
+ enable_webdav: Some(false),
config_file: Some(nonexistent_config.to_string_lossy().to_string()),
log_dir: None,
ssl_cert: None,
@@ -364,6 +368,7 @@ directory = {}
password: None,
enable_upload: None,
max_upload_size: None,
+ enable_webdav: None,
config_file: Some(config_file.to_string_lossy().to_string()),
log_dir: None,
ssl_cert: None,
@@ -407,6 +412,7 @@ port = 9999
password: None,
enable_upload: None,
max_upload_size: None,
+ enable_webdav: None,
config_file: Some(config_file.to_string_lossy().to_string()),
log_dir: None,
ssl_cert: None,
@@ -455,6 +461,7 @@ fn test_config_invalid_port_values() {
password: None,
enable_upload: None,
max_upload_size: None,
+ enable_webdav: None,
config_file: Some(config_file.to_string_lossy().to_string()),
log_dir: None,
ssl_cert: None,
@@ -500,6 +507,7 @@ fn test_config_invalid_port_values() {
password: None,
enable_upload: None,
max_upload_size: None,
+ enable_webdav: None,
config_file: Some(config_file.to_string_lossy().to_string()),
log_dir: None,
ssl_cert: None,
@@ -556,6 +564,7 @@ fn test_config_invalid_file_size_formats() {
password: None,
enable_upload: None,
max_upload_size: None,
+ enable_webdav: None,
config_file: Some(config_file.to_string_lossy().to_string()),
log_dir: None,
ssl_cert: None,
@@ -632,6 +641,7 @@ fn test_config_boolean_edge_cases() {
password: None,
enable_upload: None,
max_upload_size: None,
+ enable_webdav: None,
config_file: Some(config_file.to_string_lossy().to_string()),
log_dir: None,
ssl_cert: None,
@@ -680,6 +690,7 @@ fn test_config_malformed_ini_syntax() {
password: None,
enable_upload: None,
max_upload_size: None,
+ enable_webdav: None,
config_file: Some(config_file.to_string_lossy().to_string()),
log_dir: None,
ssl_cert: None,
diff --git a/tests/direct_upload_test.rs b/tests/direct_upload_test.rs
index 44992ff..8c90dc8 100644
--- a/tests/direct_upload_test.rs
+++ b/tests/direct_upload_test.rs
@@ -22,6 +22,7 @@ fn create_test_cli(upload_dir: PathBuf) -> Cli {
password: None,
enable_upload: Some(true),
max_upload_size: Some(100), // 100MB
+ enable_webdav: Some(false),
config_file: None,
log_dir: None,
ssl_cert: None,
diff --git a/tests/http_parser_test.rs b/tests/http_parser_test.rs
index 28a7792..ca7a388 100644
--- a/tests/http_parser_test.rs
+++ b/tests/http_parser_test.rs
@@ -1,14 +1,18 @@
// SPDX-License-Identifier: MIT
use irondrop::http::{ClientStream, Request};
-use std::io::{Read, Write};
+use std::io::Write;
use std::net::{TcpListener, TcpStream};
use std::thread;
fn serve_and_parse(request: &str) -> Result {
+ serve_and_parse_bytes(request.as_bytes())
+}
+
+fn serve_and_parse_bytes(request: &[u8]) -> Result {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
- let req_owned = request.as_bytes().to_vec();
+ let req_owned = request.to_vec();
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
@@ -41,8 +45,92 @@ fn test_lf_only_headers_separator() {
}
#[test]
-fn test_chunked_encoding_rejected() {
- let req = "POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n";
+fn test_chunked_encoding_is_accepted() {
+ let req = concat!(
+ "POST / HTTP/1.1\r\n",
+ "Host: x\r\n",
+ "Transfer-Encoding: chunked\r\n",
+ "\r\n",
+ "4\r\n",
+ "Wiki\r\n",
+ "5\r\n",
+ "pedia\r\n",
+ "0\r\n",
+ "\r\n"
+ );
+ let result = serve_and_parse(req).expect("chunked request should parse");
+ assert_eq!(result.method, "POST");
+ match result.body {
+ Some(irondrop::http::RequestBody::Memory(body)) => assert_eq!(body, b"Wikipedia"),
+ _ => panic!("expected memory body for chunked payload"),
+ }
+}
+
+#[test]
+fn test_chunked_encoding_malformed_chunk_size_rejected() {
+ let req = concat!(
+ "POST / HTTP/1.1\r\n",
+ "Host: x\r\n",
+ "Transfer-Encoding: chunked\r\n",
+ "\r\n",
+ "ZZ\r\n",
+ "abc\r\n",
+ "0\r\n",
+ "\r\n"
+ );
+ let result = serve_and_parse(req);
+ assert!(result.is_err());
+}
+
+#[test]
+fn test_chunked_encoding_invalid_terminator_rejected() {
+ let req = concat!(
+ "POST / HTTP/1.1\r\n",
+ "Host: x\r\n",
+ "Transfer-Encoding: chunked\r\n",
+ "\r\n",
+ "3\r\n",
+ "abcX",
+ "0\r\n",
+ "\r\n"
+ );
+ let result = serve_and_parse(req);
+ assert!(result.is_err());
+}
+
+#[test]
+fn test_chunked_encoding_with_trailers_is_accepted() {
+ let req = concat!(
+ "POST / HTTP/1.1\r\n",
+ "Host: x\r\n",
+ "Transfer-Encoding: chunked\r\n",
+ "\r\n",
+ "3\r\n",
+ "abc\r\n",
+ "0\r\n",
+ "X-Test: ok\r\n",
+ "\r\n"
+ );
+ let result = serve_and_parse(req).expect("chunked request with trailer should parse");
+ match result.body {
+ Some(irondrop::http::RequestBody::Memory(body)) => assert_eq!(body, b"abc"),
+ _ => panic!("expected memory body for chunked payload"),
+ }
+}
+
+#[test]
+fn test_chunked_and_content_length_conflict_rejected() {
+ let req = concat!(
+ "POST / HTTP/1.1\r\n",
+ "Host: x\r\n",
+ "Content-Length: 4\r\n",
+ "Transfer-Encoding: chunked\r\n",
+ "\r\n",
+ "4\r\n",
+ "Wiki\r\n",
+ "0\r\n",
+ "\r\n"
+ );
let result = serve_and_parse(req);
assert!(result.is_err());
}
@@ -50,7 +138,7 @@ fn test_chunked_encoding_rejected() {
#[test]
fn test_missing_host_header() {
let req = "GET / HTTP/1.1\r\n\r\n";
- let result = serve_and_parse(req);
+ let _result = serve_and_parse(req);
// Should handle gracefully - either accept or reject with appropriate error
// This test ensures no panic occurs
}
@@ -94,7 +182,7 @@ fn test_malformed_headers() {
];
for req in test_cases {
- let result = serve_and_parse(req);
+ let _result = serve_and_parse(req);
// Should either parse successfully (ignoring bad headers) or return error
// This test ensures no panic occurs
}
@@ -104,7 +192,7 @@ fn test_malformed_headers() {
fn test_extremely_long_request_line() {
let long_path = "/".to_string() + &"x".repeat(65536); // 64KB path
let req = format!("GET {} HTTP/1.1\r\nHost: x\r\n\r\n", long_path);
- let result = serve_and_parse(&req);
+ let _result = serve_and_parse(&req);
// Should either accept or reject with appropriate error (414 URI Too Long)
// This test ensures no panic or infinite loop occurs
}
@@ -131,14 +219,14 @@ fn test_multiple_content_length_headers() {
fn test_header_continuation_lines() {
// HTTP/1.1 allows header continuation with leading whitespace
let req = "GET / HTTP/1.1\r\nHost: x\r\nX-Custom: value1\r\n continuation\r\n\r\n";
- let result = serve_and_parse(req);
+ let _result = serve_and_parse(req);
// Should handle header continuation or reject gracefully
}
#[test]
fn test_request_with_body_but_no_content_length() {
let req = "POST / HTTP/1.1\r\nHost: x\r\n\r\nsome body data";
- let result = serve_and_parse(req);
+ let _result = serve_and_parse(req);
// Should handle gracefully - either require Content-Length or read until connection close
}
@@ -153,7 +241,53 @@ fn test_http_version_variations() {
];
for req in test_cases {
- let result = serve_and_parse(req);
+ let _result = serve_and_parse(req);
// Should accept HTTP/1.0 and HTTP/1.1, reject others
}
}
+
+#[test]
+fn test_webdav_methods_are_accepted() {
+ let methods = [
+ "PROPFIND",
+ "MKCOL",
+ "COPY",
+ "MOVE",
+ "PROPPATCH",
+ "LOCK",
+ "UNLOCK",
+ ];
+
+ for method in methods {
+ let req = format!("{method} /dav/path HTTP/1.1\r\nHost: x\r\n\r\n");
+ let result = serve_and_parse(&req);
+ assert!(result.is_ok(), "method {method} should be accepted");
+ }
+}
+
+#[test]
+fn test_chunked_body_split_across_tcp_frames() {
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+ let addr = listener.local_addr().unwrap();
+
+ let handle = thread::spawn(move || {
+ let (mut stream, _) = listener.accept().unwrap();
+ stream
+ .write_all(b"POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nWi")
+ .unwrap();
+ stream.flush().unwrap();
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ stream.write_all(b"ki\r\n0\r\n\r\n").unwrap();
+ stream.flush().unwrap();
+ });
+
+ let client = TcpStream::connect(addr).unwrap();
+ let mut client_stream = ClientStream::Plain(client);
+ let parsed = Request::from_stream(&mut client_stream).expect("request should parse");
+ handle.join().unwrap();
+
+ match parsed.body {
+ Some(irondrop::http::RequestBody::Memory(body)) => assert_eq!(body, b"Wiki"),
+ _ => panic!("expected in-memory body"),
+ }
+}
diff --git a/tests/integration_test.rs b/tests/integration_test.rs
index 2fd2315..87c47a6 100644
--- a/tests/integration_test.rs
+++ b/tests/integration_test.rs
@@ -45,6 +45,7 @@ fn setup_test_server(username: Option, password: Option) -> Test
password,
enable_upload: Some(false),
max_upload_size: Some(10240),
+ enable_webdav: Some(false),
config_file: None,
log_dir: None,
ssl_cert: None,
@@ -446,6 +447,7 @@ where
password,
enable_upload: Some(false),
max_upload_size: Some(10240),
+ enable_webdav: Some(false),
config_file: None,
log_dir: None,
ssl_cert: None,
diff --git a/tests/log_dir_test.rs b/tests/log_dir_test.rs
index 38aad5f..ca63eca 100644
--- a/tests/log_dir_test.rs
+++ b/tests/log_dir_test.rs
@@ -30,6 +30,7 @@ fn create_test_cli_with_log_dir(log_dir: Option) -> Cli {
password: None,
enable_upload: Some(false),
max_upload_size: None,
+ enable_webdav: Some(false),
config_file: None,
log_dir,
ssl_cert: None,
diff --git a/tests/monitor_test.rs b/tests/monitor_test.rs
index 6b7ccc0..033bf88 100644
--- a/tests/monitor_test.rs
+++ b/tests/monitor_test.rs
@@ -42,6 +42,7 @@ fn setup_test_server() -> TestServer {
password: None,
enable_upload: Some(false),
max_upload_size: Some(10240),
+ enable_webdav: Some(false),
config_file: None,
log_dir: None,
ssl_cert: None,
diff --git a/tests/ssl_test.rs b/tests/ssl_test.rs
index 5f84abb..86d7603 100644
--- a/tests/ssl_test.rs
+++ b/tests/ssl_test.rs
@@ -90,6 +90,7 @@ fn setup_ssl_server(username: Option, password: Option) -> TestS
password,
enable_upload: Some(false),
max_upload_size: Some(10240),
+ enable_webdav: Some(false),
config_file: None,
log_dir: None,
ssl_cert: Some(cert_path),
@@ -271,6 +272,7 @@ fn test_ssl_missing_cert_file() {
password: None,
enable_upload: Some(false),
max_upload_size: Some(10240),
+ enable_webdav: Some(false),
config_file: None,
log_dir: None,
ssl_cert: Some(bogus_cert),
@@ -311,6 +313,7 @@ fn test_ssl_missing_key_file() {
password: None,
enable_upload: Some(false),
max_upload_size: Some(10240),
+ enable_webdav: Some(false),
config_file: None,
log_dir: None,
ssl_cert: Some(cert_path),
@@ -347,6 +350,7 @@ fn test_ssl_cert_without_key() {
password: None,
enable_upload: Some(false),
max_upload_size: Some(10240),
+ enable_webdav: Some(false),
config_file: None,
log_dir: None,
ssl_cert: Some(cert_path),
@@ -386,6 +390,7 @@ fn test_ssl_key_without_cert() {
password: None,
enable_upload: Some(false),
max_upload_size: Some(10240),
+ enable_webdav: Some(false),
config_file: None,
log_dir: None,
ssl_cert: None,
@@ -428,6 +433,7 @@ fn test_http_still_works_without_ssl() {
password: None,
enable_upload: Some(false),
max_upload_size: Some(10240),
+ enable_webdav: Some(false),
config_file: None,
log_dir: None,
ssl_cert: None,
diff --git a/tests/webdav_copy_move_rfc_test.rs b/tests/webdav_copy_move_rfc_test.rs
new file mode 100644
index 0000000..caaadfd
--- /dev/null
+++ b/tests/webdav_copy_move_rfc_test.rs
@@ -0,0 +1,305 @@
+// SPDX-License-Identifier: MIT
+
+use irondrop::cli::Cli;
+use irondrop::server::run_server;
+use reqwest::Method;
+use reqwest::StatusCode;
+use reqwest::blocking::Client;
+use std::fs::{File, create_dir_all};
+use std::io::Write;
+use std::net::SocketAddr;
+use std::sync::mpsc;
+use std::thread::{self, JoinHandle};
+use tempfile::{TempDir, tempdir};
+
+struct TestServer {
+ addr: SocketAddr,
+ shutdown_tx: mpsc::Sender<()>,
+ handle: Option>,
+ _temp_dir: TempDir,
+}
+
+fn setup_test_server_with_tree(populate: F) -> TestServer
+where
+ F: FnOnce(&std::path::Path),
+{
+ let dir = tempdir().unwrap();
+ populate(dir.path());
+
+ let cli = Cli {
+ directory: dir.path().to_path_buf(),
+ listen: Some("127.0.0.1".to_string()),
+ port: Some(0),
+ allowed_extensions: Some("*".to_string()),
+ threads: Some(4),
+ chunk_size: Some(1024),
+ verbose: Some(false),
+ detailed_logging: Some(false),
+ username: None,
+ password: None,
+ enable_upload: Some(false),
+ max_upload_size: Some(10240),
+ enable_webdav: Some(true),
+ config_file: None,
+ log_dir: None,
+ ssl_cert: None,
+ ssl_key: None,
+ };
+
+ let (shutdown_tx, shutdown_rx) = mpsc::channel();
+ let (addr_tx, addr_rx) = mpsc::channel();
+ let handle = thread::spawn(move || {
+ if let Err(e) = run_server(cli, Some(shutdown_rx), Some(addr_tx)) {
+ eprintln!("Server thread failed: {e}");
+ }
+ });
+
+ let addr = addr_rx.recv().unwrap();
+ TestServer {
+ addr,
+ shutdown_tx,
+ handle: Some(handle),
+ _temp_dir: dir,
+ }
+}
+
+impl Drop for TestServer {
+ fn drop(&mut self) {
+ if let Some(handle) = self.handle.take() {
+ self.shutdown_tx.send(()).ok();
+ handle.join().unwrap();
+ }
+ }
+}
+
+fn lock_token(client: &Client, addr: SocketAddr, path: &str) -> String {
+ let response = client
+ .request(
+ Method::from_bytes(b"LOCK").unwrap(),
+ format!("http://{addr}{path}"),
+ )
+ .body(
+ r#"
+
+
+
+ "#,
+ )
+ .send()
+ .unwrap();
+ assert!(response.status() == StatusCode::CREATED || response.status() == StatusCode::OK);
+ response
+ .headers()
+ .get("lock-token")
+ .unwrap()
+ .to_str()
+ .unwrap()
+ .trim_matches(|c| c == '<' || c == '>')
+ .to_string()
+}
+
+#[test]
+fn test_move_locked_destination_without_if_token_is_locked() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut src = File::create(root.join("src.txt")).unwrap();
+ writeln!(src, "src").unwrap();
+ let mut dst = File::create(root.join("dst.txt")).unwrap();
+ writeln!(dst, "dst").unwrap();
+ });
+ let client = Client::new();
+ let _dst_lock = lock_token(&client, server.addr, "/dst.txt");
+
+ let move_resp = client
+ .request(
+ Method::from_bytes(b"MOVE").unwrap(),
+ format!("http://{}/src.txt", server.addr),
+ )
+ .header("Destination", format!("http://{}/dst.txt", server.addr))
+ .send()
+ .unwrap();
+
+ assert_eq!(move_resp.status().as_u16(), 423);
+}
+
+#[test]
+fn test_delete_collection_with_locked_child_returns_multistatus() {
+ let server = setup_test_server_with_tree(|root| {
+ create_dir_all(root.join("dir")).unwrap();
+ let mut child = File::create(root.join("dir").join("child.txt")).unwrap();
+ writeln!(child, "child").unwrap();
+ });
+ let client = Client::new();
+ let _child_lock = lock_token(&client, server.addr, "/dir/child.txt");
+
+ let delete_resp = client
+ .request(Method::DELETE, format!("http://{}/dir/", server.addr))
+ .send()
+ .unwrap();
+
+ assert_eq!(delete_resp.status().as_u16(), 207);
+ let xml = delete_resp.text().unwrap();
+ assert!(xml.contains("/dir/child.txt"));
+ assert!(xml.contains("HTTP/1.1 423 Locked"));
+ assert!(xml.contains("/dir/"));
+ assert!(xml.contains("HTTP/1.1 424 Failed Dependency"));
+}
+
+#[test]
+fn test_copy_depth_zero_on_collection_does_not_copy_members() {
+ let server = setup_test_server_with_tree(|root| {
+ create_dir_all(root.join("src").join("nested")).unwrap();
+ let mut child = File::create(root.join("src").join("nested").join("child.txt")).unwrap();
+ writeln!(child, "child").unwrap();
+ });
+ let client = Client::new();
+
+ let copy_resp = client
+ .request(
+ Method::from_bytes(b"COPY").unwrap(),
+ format!("http://{}/src/", server.addr),
+ )
+ .header("Depth", "0")
+ .header("Destination", format!("http://{}/dst/", server.addr))
+ .send()
+ .unwrap();
+ assert_eq!(copy_resp.status(), StatusCode::CREATED);
+
+ let nested_resp = client
+ .get(format!("http://{}/dst/nested/child.txt", server.addr))
+ .send()
+ .unwrap();
+ assert_eq!(nested_resp.status(), StatusCode::NOT_FOUND);
+}
+
+#[test]
+fn test_copy_destination_descendant_of_source_is_bad_request() {
+ let server = setup_test_server_with_tree(|root| {
+ create_dir_all(root.join("src").join("nested")).unwrap();
+ let mut child = File::create(root.join("src").join("nested").join("child.txt")).unwrap();
+ writeln!(child, "child").unwrap();
+ });
+ let client = Client::new();
+
+ let copy_resp = client
+ .request(
+ Method::from_bytes(b"COPY").unwrap(),
+ format!("http://{}/src/", server.addr),
+ )
+ .header(
+ "Destination",
+ format!("http://{}/src/nested/newcopy/", server.addr),
+ )
+ .send()
+ .unwrap();
+ assert_eq!(copy_resp.status(), StatusCode::BAD_REQUEST);
+}
+
+#[test]
+fn test_move_preserves_dead_property_at_destination() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut src = File::create(root.join("src.txt")).unwrap();
+ writeln!(src, "src").unwrap();
+ });
+ let client = Client::new();
+
+ let patch_body = r#"
+
+
+
+ blue
+
+
+ "#;
+ let patch_resp = client
+ .request(
+ Method::from_bytes(b"PROPPATCH").unwrap(),
+ format!("http://{}/src.txt", server.addr),
+ )
+ .header("Content-Type", "application/xml")
+ .body(patch_body.to_string())
+ .send()
+ .unwrap();
+ assert_eq!(patch_resp.status().as_u16(), 207);
+
+ let move_resp = client
+ .request(
+ Method::from_bytes(b"MOVE").unwrap(),
+ format!("http://{}/src.txt", server.addr),
+ )
+ .header("Destination", format!("http://{}/dst.txt", server.addr))
+ .send()
+ .unwrap();
+ assert!(
+ move_resp.status() == StatusCode::CREATED || move_resp.status() == StatusCode::NO_CONTENT
+ );
+
+ let propfind_body = r#"
+
+
+
+
+ "#;
+ let read_resp = client
+ .request(
+ Method::from_bytes(b"PROPFIND").unwrap(),
+ format!("http://{}/dst.txt", server.addr),
+ )
+ .header("Depth", "0")
+ .header("Content-Type", "application/xml")
+ .body(propfind_body.to_string())
+ .send()
+ .unwrap();
+ assert_eq!(read_resp.status().as_u16(), 207);
+ let xml = read_resp.text().unwrap();
+ assert!(
+ xml.contains("favorite") && xml.contains(">blue"),
+ "unexpected xml: {xml}"
+ );
+}
+
+#[test]
+fn test_copy_invalid_depth_is_bad_request() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut src = File::create(root.join("src.txt")).unwrap();
+ writeln!(src, "src").unwrap();
+ });
+ let client = Client::new();
+
+ let copy_resp = client
+ .request(
+ Method::from_bytes(b"COPY").unwrap(),
+ format!("http://{}/src.txt", server.addr),
+ )
+ .header("Depth", "1")
+ .header("Destination", format!("http://{}/dst.txt", server.addr))
+ .send()
+ .unwrap();
+ assert_eq!(copy_resp.status(), StatusCode::BAD_REQUEST);
+}
+
+#[test]
+fn test_delete_collection_with_multiple_tokens_succeeds() {
+ let server = setup_test_server_with_tree(|root| {
+ create_dir_all(root.join("dir")).unwrap();
+ let mut a = File::create(root.join("dir").join("a.txt")).unwrap();
+ writeln!(a, "a").unwrap();
+ let mut b = File::create(root.join("dir").join("b.txt")).unwrap();
+ writeln!(b, "b").unwrap();
+ });
+ let client = Client::new();
+ let token_a = lock_token(&client, server.addr, "/dir/a.txt");
+ let token_b = lock_token(&client, server.addr, "/dir/b.txt");
+
+ let delete_resp = client
+ .request(Method::DELETE, format!("http://{}/dir/", server.addr))
+ .header("If", format!("(<{}>) (<{}>)", token_a, token_b))
+ .send()
+ .unwrap();
+ assert_eq!(delete_resp.status(), StatusCode::NO_CONTENT);
+
+ let check = client
+ .request(Method::GET, format!("http://{}/dir/", server.addr))
+ .send()
+ .unwrap();
+ assert_eq!(check.status(), StatusCode::NOT_FOUND);
+}
diff --git a/tests/webdav_copy_move_test.rs b/tests/webdav_copy_move_test.rs
new file mode 100644
index 0000000..e19a48e
--- /dev/null
+++ b/tests/webdav_copy_move_test.rs
@@ -0,0 +1,201 @@
+// SPDX-License-Identifier: MIT
+
+use irondrop::cli::Cli;
+use irondrop::server::run_server;
+use reqwest::Method;
+use reqwest::StatusCode;
+use reqwest::blocking::Client;
+use std::fs::{self, File, create_dir_all};
+use std::io::Write;
+use std::net::SocketAddr;
+use std::sync::mpsc;
+use std::thread::{self, JoinHandle};
+use tempfile::{TempDir, tempdir};
+
+struct TestServer {
+ addr: SocketAddr,
+ shutdown_tx: mpsc::Sender<()>,
+ handle: Option>,
+ root: std::path::PathBuf,
+ _temp_dir: TempDir,
+}
+
+fn setup_test_server_with_tree(populate: F) -> TestServer
+where
+ F: FnOnce(&std::path::Path),
+{
+ let dir = tempdir().unwrap();
+ populate(dir.path());
+
+ let cli = Cli {
+ directory: dir.path().to_path_buf(),
+ listen: Some("127.0.0.1".to_string()),
+ port: Some(0),
+ allowed_extensions: Some("*".to_string()),
+ threads: Some(4),
+ chunk_size: Some(1024),
+ verbose: Some(false),
+ detailed_logging: Some(false),
+ username: None,
+ password: None,
+ enable_upload: Some(false),
+ max_upload_size: Some(10240),
+ enable_webdav: Some(true),
+ config_file: None,
+ log_dir: None,
+ ssl_cert: None,
+ ssl_key: None,
+ };
+
+ let (shutdown_tx, shutdown_rx) = mpsc::channel();
+ let (addr_tx, addr_rx) = mpsc::channel();
+ let root = dir.path().to_path_buf();
+
+ let server_handle = thread::spawn(move || {
+ if let Err(e) = run_server(cli, Some(shutdown_rx), Some(addr_tx)) {
+ eprintln!("Server thread failed: {e}");
+ }
+ });
+
+ let server_addr = addr_rx.recv().unwrap();
+
+ TestServer {
+ addr: server_addr,
+ shutdown_tx,
+ handle: Some(server_handle),
+ root,
+ _temp_dir: dir,
+ }
+}
+
+impl Drop for TestServer {
+ fn drop(&mut self) {
+ if let Some(handle) = self.handle.take() {
+ self.shutdown_tx.send(()).ok();
+ handle.join().unwrap();
+ }
+ }
+}
+
+#[test]
+fn test_copy_creates_destination_and_preserves_source() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("source.txt")).unwrap();
+ write!(file, "copy-me").unwrap();
+ });
+ let client = Client::new();
+
+ let response = client
+ .request(
+ Method::from_bytes(b"COPY").unwrap(),
+ format!("http://{}/source.txt", server.addr),
+ )
+ .header("Destination", format!("http://{}/dest.txt", server.addr))
+ .send()
+ .unwrap();
+
+ assert_eq!(response.status(), StatusCode::CREATED);
+ assert_eq!(
+ fs::read_to_string(server.root.join("source.txt")).unwrap(),
+ "copy-me"
+ );
+ assert_eq!(
+ fs::read_to_string(server.root.join("dest.txt")).unwrap(),
+ "copy-me"
+ );
+}
+
+#[test]
+fn test_copy_overwrite_false_returns_precondition_failed() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut src = File::create(root.join("src.txt")).unwrap();
+ write!(src, "src").unwrap();
+ let mut dst = File::create(root.join("dst.txt")).unwrap();
+ write!(dst, "dst").unwrap();
+ });
+ let client = Client::new();
+
+ let response = client
+ .request(
+ Method::from_bytes(b"COPY").unwrap(),
+ format!("http://{}/src.txt", server.addr),
+ )
+ .header("Destination", format!("http://{}/dst.txt", server.addr))
+ .header("Overwrite", "F")
+ .send()
+ .unwrap();
+
+ assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
+ assert_eq!(
+ fs::read_to_string(server.root.join("dst.txt")).unwrap(),
+ "dst"
+ );
+}
+
+#[test]
+fn test_move_renames_resource() {
+ let server = setup_test_server_with_tree(|root| {
+ create_dir_all(root.join("from")).unwrap();
+ let mut src = File::create(root.join("from").join("file.txt")).unwrap();
+ write!(src, "move-me").unwrap();
+ });
+ let client = Client::new();
+
+ let response = client
+ .request(
+ Method::from_bytes(b"MOVE").unwrap(),
+ format!("http://{}/from/file.txt", server.addr),
+ )
+ .header(
+ "Destination",
+ format!("http://{}/file-moved.txt", server.addr),
+ )
+ .send()
+ .unwrap();
+
+ assert_eq!(response.status(), StatusCode::CREATED);
+ assert!(!server.root.join("from").join("file.txt").exists());
+ assert_eq!(
+ fs::read_to_string(server.root.join("file-moved.txt")).unwrap(),
+ "move-me"
+ );
+}
+
+#[test]
+fn test_copy_missing_destination_is_bad_request() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut src = File::create(root.join("src.txt")).unwrap();
+ write!(src, "src").unwrap();
+ });
+ let client = Client::new();
+
+ let response = client
+ .request(
+ Method::from_bytes(b"COPY").unwrap(),
+ format!("http://{}/src.txt", server.addr),
+ )
+ .send()
+ .unwrap();
+
+ assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+}
+
+#[test]
+fn test_copy_path_only_destination_is_bad_request() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut src = File::create(root.join("src.txt")).unwrap();
+ write!(src, "src").unwrap();
+ });
+ let client = Client::new();
+
+ let response = client
+ .request(
+ Method::from_bytes(b"COPY").unwrap(),
+ format!("http://{}/src.txt", server.addr),
+ )
+ .header("Destination", "/dst.txt")
+ .send()
+ .unwrap();
+
+ assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+}
diff --git a/tests/webdav_error_semantics_test.rs b/tests/webdav_error_semantics_test.rs
new file mode 100644
index 0000000..6efcf69
--- /dev/null
+++ b/tests/webdav_error_semantics_test.rs
@@ -0,0 +1,265 @@
+// SPDX-License-Identifier: MIT
+
+use irondrop::cli::Cli;
+use irondrop::server::run_server;
+use reqwest::Method;
+use reqwest::StatusCode;
+use reqwest::blocking::Client;
+use std::fs::File;
+use std::io::{BufRead, Write};
+use std::net::{SocketAddr, TcpStream};
+use std::sync::mpsc;
+use std::thread::{self, JoinHandle};
+use tempfile::{TempDir, tempdir};
+
+struct TestServer {
+ addr: SocketAddr,
+ shutdown_tx: mpsc::Sender<()>,
+ handle: Option>,
+ _temp_dir: TempDir,
+}
+
+fn setup_test_server_with_tree(populate: F) -> TestServer
+where
+ F: FnOnce(&std::path::Path),
+{
+ let dir = tempdir().unwrap();
+ populate(dir.path());
+
+ let cli = Cli {
+ directory: dir.path().to_path_buf(),
+ listen: Some("127.0.0.1".to_string()),
+ port: Some(0),
+ allowed_extensions: Some("*".to_string()),
+ threads: Some(4),
+ chunk_size: Some(1024),
+ verbose: Some(false),
+ detailed_logging: Some(false),
+ username: None,
+ password: None,
+ enable_upload: Some(false),
+ max_upload_size: Some(10240),
+ enable_webdav: Some(true),
+ config_file: None,
+ log_dir: None,
+ ssl_cert: None,
+ ssl_key: None,
+ };
+
+ let (shutdown_tx, shutdown_rx) = mpsc::channel();
+ let (addr_tx, addr_rx) = mpsc::channel();
+
+ let server_handle = thread::spawn(move || {
+ if let Err(e) = run_server(cli, Some(shutdown_rx), Some(addr_tx)) {
+ eprintln!("Server thread failed: {e}");
+ }
+ });
+
+ let server_addr = addr_rx.recv().unwrap();
+
+ TestServer {
+ addr: server_addr,
+ shutdown_tx,
+ handle: Some(server_handle),
+ _temp_dir: dir,
+ }
+}
+
+impl Drop for TestServer {
+ fn drop(&mut self) {
+ if let Some(handle) = self.handle.take() {
+ self.shutdown_tx.send(()).ok();
+ handle.join().unwrap();
+ }
+ }
+}
+
+#[test]
+fn test_copy_destination_parent_missing_returns_conflict() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut source = File::create(root.join("src.txt")).unwrap();
+ write!(source, "src").unwrap();
+ });
+ let client = Client::new();
+
+ let response = client
+ .request(
+ Method::from_bytes(b"COPY").unwrap(),
+ format!("http://{}/src.txt", server.addr),
+ )
+ .header(
+ "Destination",
+ format!("http://{}/missing/path/dst.txt", server.addr),
+ )
+ .send()
+ .unwrap();
+
+ assert_eq!(response.status(), StatusCode::CONFLICT);
+}
+
+#[test]
+fn test_move_default_overwrite_is_true() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut source = File::create(root.join("src.txt")).unwrap();
+ write!(source, "source").unwrap();
+ let mut destination = File::create(root.join("dst.txt")).unwrap();
+ write!(destination, "old").unwrap();
+ });
+ let client = Client::new();
+
+ let response = client
+ .request(
+ Method::from_bytes(b"MOVE").unwrap(),
+ format!("http://{}/src.txt", server.addr),
+ )
+ .header("Destination", format!("http://{}/dst.txt", server.addr))
+ .send()
+ .unwrap();
+
+ assert_eq!(response.status(), StatusCode::NO_CONTENT);
+}
+
+#[test]
+fn test_copy_destination_host_mismatch_rejected() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut source = File::create(root.join("src.txt")).unwrap();
+ write!(source, "src").unwrap();
+ });
+ let client = Client::new();
+
+ let response = client
+ .request(
+ Method::from_bytes(b"COPY").unwrap(),
+ format!("http://{}/src.txt", server.addr),
+ )
+ .header("Destination", "http://example.com/dst.txt")
+ .send()
+ .unwrap();
+
+ assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+}
+
+#[test]
+fn test_put_path_traversal_blocked() {
+ let server = setup_test_server_with_tree(|_| {});
+ let mut stream = TcpStream::connect(server.addr).unwrap();
+ let request = concat!(
+ "PUT /../../../../etc/passwd HTTP/1.1\r\n",
+ "Host: localhost\r\n",
+ "Content-Length: 4\r\n",
+ "\r\n",
+ "evil"
+ );
+ stream.write_all(request.as_bytes()).unwrap();
+ stream.flush().unwrap();
+
+ let mut reader = std::io::BufReader::new(stream);
+ let mut status_line = String::new();
+ reader.read_line(&mut status_line).unwrap();
+ assert!(status_line.starts_with("HTTP/1.1 403 Forbidden"));
+}
+
+#[cfg(unix)]
+#[test]
+fn test_put_through_symlink_outside_root_is_forbidden() {
+ use std::os::unix::fs::symlink;
+
+ let outside = tempdir().unwrap();
+ let server = setup_test_server_with_tree(|root| {
+ symlink(outside.path(), root.join("escape")).unwrap();
+ });
+ let client = Client::new();
+
+ let response = client
+ .request(
+ Method::PUT,
+ format!("http://{}/escape/outside.txt", server.addr),
+ )
+ .body("blocked".to_string())
+ .send()
+ .unwrap();
+ assert_eq!(response.status(), StatusCode::FORBIDDEN);
+}
+
+#[test]
+fn test_options_advertises_class2_locking() {
+ let server = setup_test_server_with_tree(|_| {});
+ let client = Client::new();
+
+ let response = client
+ .request(Method::OPTIONS, format!("http://{}/", server.addr))
+ .send()
+ .unwrap();
+
+ assert_eq!(response.status(), StatusCode::OK);
+ let dav = response.headers().get("dav").unwrap().to_str().unwrap();
+ assert!(dav.contains('2'));
+ let allow = response.headers().get("allow").unwrap().to_str().unwrap();
+ assert!(allow.contains("LOCK"));
+ assert!(allow.contains("UNLOCK"));
+}
+
+#[test]
+fn test_lock_unlock_roundtrip_with_put_if_header() {
+ let server = setup_test_server_with_tree(|_| {});
+ let client = Client::new();
+
+ let lock_response = client
+ .request(
+ Method::from_bytes(b"LOCK").unwrap(),
+ format!("http://{}/locked.txt", server.addr),
+ )
+ .header("Timeout", "Second-300")
+ .body(
+ r#"
+
+
+
+ "#,
+ )
+ .send()
+ .unwrap();
+ assert!(
+ lock_response.status() == StatusCode::CREATED || lock_response.status() == StatusCode::OK
+ );
+ let lock_token_header = lock_response
+ .headers()
+ .get("lock-token")
+ .unwrap()
+ .to_str()
+ .unwrap()
+ .to_string();
+ let lock_token = lock_token_header
+ .trim()
+ .trim_start_matches('<')
+ .trim_end_matches('>')
+ .to_string();
+
+ let blocked_put = client
+ .request(Method::PUT, format!("http://{}/locked.txt", server.addr))
+ .body("no-token".to_string())
+ .send()
+ .unwrap();
+ assert_eq!(blocked_put.status().as_u16(), 423);
+
+ let allowed_put = client
+ .request(Method::PUT, format!("http://{}/locked.txt", server.addr))
+ .header("If", format!("(<{lock_token}>)"))
+ .body("with-token".to_string())
+ .send()
+ .unwrap();
+ assert!(
+ allowed_put.status() == StatusCode::CREATED
+ || allowed_put.status() == StatusCode::NO_CONTENT
+ );
+
+ let unlock_response = client
+ .request(
+ Method::from_bytes(b"UNLOCK").unwrap(),
+ format!("http://{}/locked.txt", server.addr),
+ )
+ .header("Lock-Token", lock_token_header)
+ .send()
+ .unwrap();
+ assert_eq!(unlock_response.status(), StatusCode::NO_CONTENT);
+}
diff --git a/tests/webdav_error_xml_rfc_test.rs b/tests/webdav_error_xml_rfc_test.rs
new file mode 100644
index 0000000..fdce359
--- /dev/null
+++ b/tests/webdav_error_xml_rfc_test.rs
@@ -0,0 +1,111 @@
+// SPDX-License-Identifier: MIT
+
+use irondrop::cli::Cli;
+use irondrop::server::run_server;
+use reqwest::Method;
+use reqwest::blocking::Client;
+use std::fs::File;
+use std::io::Write;
+use std::net::SocketAddr;
+use std::sync::mpsc;
+use std::thread::{self, JoinHandle};
+use tempfile::{TempDir, tempdir};
+
+struct TestServer {
+ addr: SocketAddr,
+ shutdown_tx: mpsc::Sender<()>,
+ handle: Option>,
+ _temp_dir: TempDir,
+}
+
+fn setup_test_server_with_tree(populate: F) -> TestServer
+where
+ F: FnOnce(&std::path::Path),
+{
+ let dir = tempdir().unwrap();
+ populate(dir.path());
+
+ let cli = Cli {
+ directory: dir.path().to_path_buf(),
+ listen: Some("127.0.0.1".to_string()),
+ port: Some(0),
+ allowed_extensions: Some("*".to_string()),
+ threads: Some(4),
+ chunk_size: Some(1024),
+ verbose: Some(false),
+ detailed_logging: Some(false),
+ username: None,
+ password: None,
+ enable_upload: Some(false),
+ max_upload_size: Some(10240),
+ enable_webdav: Some(true),
+ config_file: None,
+ log_dir: None,
+ ssl_cert: None,
+ ssl_key: None,
+ };
+
+ let (shutdown_tx, shutdown_rx) = mpsc::channel();
+ let (addr_tx, addr_rx) = mpsc::channel();
+ let handle = thread::spawn(move || {
+ if let Err(e) = run_server(cli, Some(shutdown_rx), Some(addr_tx)) {
+ eprintln!("Server thread failed: {e}");
+ }
+ });
+
+ let addr = addr_rx.recv().unwrap();
+ TestServer {
+ addr,
+ shutdown_tx,
+ handle: Some(handle),
+ _temp_dir: dir,
+ }
+}
+
+impl Drop for TestServer {
+ fn drop(&mut self) {
+ if let Some(handle) = self.handle.take() {
+ self.shutdown_tx.send(()).ok();
+ handle.join().unwrap();
+ }
+ }
+}
+
+#[test]
+fn test_locked_write_returns_dav_error_body() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("sample.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+
+ let lock_response = client
+ .request(
+ Method::from_bytes(b"LOCK").unwrap(),
+ format!("http://{}/sample.txt", server.addr),
+ )
+ .body(
+ r#"
+
+
+
+ "#,
+ )
+ .send()
+ .unwrap();
+ assert!(
+ lock_response.status() == reqwest::StatusCode::CREATED
+ || lock_response.status().as_u16() == 200
+ );
+
+ let put_response = client
+ .request(Method::PUT, format!("http://{}/sample.txt", server.addr))
+ .body("blocked".to_string())
+ .send()
+ .unwrap();
+ assert_eq!(put_response.status().as_u16(), 423);
+
+ let body = put_response.text().unwrap();
+ assert!(body.contains(",
+ handle: Option>,
+ _temp_dir: TempDir,
+}
+
+fn setup_test_server_with_tree(populate: F) -> TestServer
+where
+ F: FnOnce(&std::path::Path),
+{
+ let dir = tempdir().unwrap();
+ populate(dir.path());
+
+ let cli = Cli {
+ directory: dir.path().to_path_buf(),
+ listen: Some("127.0.0.1".to_string()),
+ port: Some(0),
+ allowed_extensions: Some("*".to_string()),
+ threads: Some(4),
+ chunk_size: Some(1024),
+ verbose: Some(false),
+ detailed_logging: Some(false),
+ username: None,
+ password: None,
+ enable_upload: Some(false),
+ max_upload_size: Some(10240),
+ enable_webdav: Some(true),
+ config_file: None,
+ log_dir: None,
+ ssl_cert: None,
+ ssl_key: None,
+ };
+
+ let (shutdown_tx, shutdown_rx) = mpsc::channel();
+ let (addr_tx, addr_rx) = mpsc::channel();
+ let handle = thread::spawn(move || {
+ if let Err(e) = run_server(cli, Some(shutdown_rx), Some(addr_tx)) {
+ eprintln!("Server thread failed: {e}");
+ }
+ });
+
+ let addr = addr_rx.recv().unwrap();
+ TestServer {
+ addr,
+ shutdown_tx,
+ handle: Some(handle),
+ _temp_dir: dir,
+ }
+}
+
+impl Drop for TestServer {
+ fn drop(&mut self) {
+ if let Some(handle) = self.handle.take() {
+ self.shutdown_tx.send(()).ok();
+ handle.join().unwrap();
+ }
+ }
+}
+
+fn acquire_lock_token(client: &Client, addr: SocketAddr, path: &str) -> String {
+ let response = client
+ .request(
+ Method::from_bytes(b"LOCK").unwrap(),
+ format!("http://{addr}{path}"),
+ )
+ .header("Timeout", "Second-600")
+ .body(
+ r#"
+
+
+
+ "#,
+ )
+ .send()
+ .unwrap();
+ assert!(response.status() == StatusCode::CREATED || response.status() == StatusCode::OK);
+ response
+ .headers()
+ .get("lock-token")
+ .unwrap()
+ .to_str()
+ .unwrap()
+ .to_string()
+}
+
+#[test]
+fn test_lock_refresh_with_if_header() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("doc.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+
+ let lock_token_header = acquire_lock_token(&client, server.addr, "/doc.txt");
+ let token = lock_token_header
+ .trim_matches(|c| c == '<' || c == '>')
+ .to_string();
+
+ let refresh = client
+ .request(
+ Method::from_bytes(b"LOCK").unwrap(),
+ format!("http://{}/doc.txt", server.addr),
+ )
+ .header("If", format!("(<{token}>)"))
+ .send()
+ .unwrap();
+ assert_eq!(refresh.status(), StatusCode::OK);
+}
+
+#[test]
+fn test_lock_response_includes_lockroot_and_depth_zero_for_file() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("doc.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+
+ let response = client
+ .request(
+ Method::from_bytes(b"LOCK").unwrap(),
+ format!("http://{}/doc.txt", server.addr),
+ )
+ .body(
+ r#"
+
+
+
+ "#,
+ )
+ .send()
+ .unwrap();
+ assert!(response.status() == StatusCode::CREATED || response.status() == StatusCode::OK);
+ let body = response.text().unwrap();
+ assert!(body.contains("0 "));
+ assert!(body.contains("/doc.txt "));
+}
+
+#[test]
+fn test_new_lock_without_lockinfo_body_is_bad_request() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("doc.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+
+ let response = client
+ .request(
+ Method::from_bytes(b"LOCK").unwrap(),
+ format!("http://{}/doc.txt", server.addr),
+ )
+ .send()
+ .unwrap();
+ assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+}
+
+#[test]
+fn test_new_lock_with_shared_scope_is_conflict() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("doc.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+
+ let response = client
+ .request(
+ Method::from_bytes(b"LOCK").unwrap(),
+ format!("http://{}/doc.txt", server.addr),
+ )
+ .body(
+ r#"
+
+
+
+ "#,
+ )
+ .send()
+ .unwrap();
+ assert_eq!(response.status(), StatusCode::CONFLICT);
+}
+
+#[test]
+fn test_file_lock_rejects_depth_infinity() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("doc.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+
+ let response = client
+ .request(
+ Method::from_bytes(b"LOCK").unwrap(),
+ format!("http://{}/doc.txt", server.addr),
+ )
+ .header("Depth", "infinity")
+ .body(
+ r#"
+
+
+
+ "#,
+ )
+ .send()
+ .unwrap();
+ assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+}
+
+#[test]
+fn test_if_not_condition_does_not_satisfy_lock_requirement() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("doc.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+
+ let lock_token_header = acquire_lock_token(&client, server.addr, "/doc.txt");
+ let token = lock_token_header
+ .trim_matches(|c| c == '<' || c == '>')
+ .to_string();
+
+ let put = client
+ .request(Method::PUT, format!("http://{}/doc.txt", server.addr))
+ .header("If", format!("(Not <{token}>)"))
+ .body("blocked".to_string())
+ .send()
+ .unwrap();
+ assert_eq!(put.status().as_u16(), 423);
+}
+
+#[test]
+fn test_if_not_wrong_token_does_not_bypass_lock() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("doc.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+ let _token = acquire_lock_token(&client, server.addr, "/doc.txt");
+
+ let put = client
+ .request(Method::PUT, format!("http://{}/doc.txt", server.addr))
+ .header("If", "(Not )")
+ .body("blocked".to_string())
+ .send()
+ .unwrap();
+ assert_eq!(put.status(), StatusCode::LOCKED);
+}
+
+#[test]
+fn test_if_tagged_list_with_correct_token_allows_write() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("doc.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+
+ let lock_token_header = acquire_lock_token(&client, server.addr, "/doc.txt");
+ let token = lock_token_header
+ .trim_matches(|c| c == '<' || c == '>')
+ .to_string();
+
+ let put = client
+ .request(Method::PUT, format!("http://{}/doc.txt", server.addr))
+ .header(
+ "If",
+ format!(" (<{}>)", server.addr, token),
+ )
+ .body("updated".to_string())
+ .send()
+ .unwrap();
+ assert!(put.status() == StatusCode::NO_CONTENT || put.status() == StatusCode::CREATED);
+}
+
+#[test]
+fn test_if_tag_for_other_resource_does_not_authorize() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("doc.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ let mut other = File::create(root.join("other.txt")).unwrap();
+ writeln!(other, "other").unwrap();
+ });
+ let client = Client::new();
+ let lock_token_header = acquire_lock_token(&client, server.addr, "/doc.txt");
+ let token = lock_token_header
+ .trim_matches(|c| c == '<' || c == '>')
+ .to_string();
+
+ let put = client
+ .request(Method::PUT, format!("http://{}/doc.txt", server.addr))
+ .header(
+ "If",
+ format!(" (<{}>)", server.addr, token),
+ )
+ .body("blocked".to_string())
+ .send()
+ .unwrap();
+ assert_eq!(put.status(), StatusCode::LOCKED);
+}
+
+#[test]
+fn test_collection_lock_blocks_mutation_of_child_without_token() {
+ let server = setup_test_server_with_tree(|root| {
+ std::fs::create_dir_all(root.join("dir")).unwrap();
+ let mut file = File::create(root.join("dir").join("child.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+ let _token = acquire_lock_token(&client, server.addr, "/dir/");
+
+ let put = client
+ .request(Method::PUT, format!("http://{}/dir/child.txt", server.addr))
+ .body("blocked".to_string())
+ .send()
+ .unwrap();
+ assert_eq!(put.status(), StatusCode::LOCKED);
+}
+
+#[test]
+fn test_if_header_with_token_and_etag_state_token_is_accepted() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("doc.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+ let lock_token_header = acquire_lock_token(&client, server.addr, "/doc.txt");
+ let token = lock_token_header
+ .trim_matches(|c| c == '<' || c == '>')
+ .to_string();
+
+ let put = client
+ .request(Method::PUT, format!("http://{}/doc.txt", server.addr))
+ .header("If", format!("(<{}> [\"etag-state\"]) ", token))
+ .body("updated".to_string())
+ .send()
+ .unwrap();
+ assert!(put.status() == StatusCode::NO_CONTENT || put.status() == StatusCode::CREATED);
+}
+
+#[test]
+fn test_unlock_with_wrong_token_is_conflict() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("doc.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+ let _token = acquire_lock_token(&client, server.addr, "/doc.txt");
+
+ let unlock = client
+ .request(
+ Method::from_bytes(b"UNLOCK").unwrap(),
+ format!("http://{}/doc.txt", server.addr),
+ )
+ .header("Lock-Token", "")
+ .send()
+ .unwrap();
+ assert_eq!(unlock.status(), StatusCode::CONFLICT);
+}
+
+#[test]
+fn test_delete_with_lock_token_clears_lock_state() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("doc.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+ let lock_token_header = acquire_lock_token(&client, server.addr, "/doc.txt");
+ let token = lock_token_header
+ .trim_matches(|c| c == '<' || c == '>')
+ .to_string();
+
+ let delete = client
+ .request(Method::DELETE, format!("http://{}/doc.txt", server.addr))
+ .header("If", format!("(<{}>)", token))
+ .send()
+ .unwrap();
+ assert_eq!(delete.status(), StatusCode::NO_CONTENT);
+
+ let recreate = client
+ .request(Method::PUT, format!("http://{}/doc.txt", server.addr))
+ .body("new".to_string())
+ .send()
+ .unwrap();
+ assert!(
+ recreate.status() == StatusCode::CREATED || recreate.status() == StatusCode::NO_CONTENT
+ );
+}
+
+#[test]
+fn test_move_transfers_lock_to_destination() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("src.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+ let lock_token_header = acquire_lock_token(&client, server.addr, "/src.txt");
+ let token = lock_token_header
+ .trim_matches(|c| c == '<' || c == '>')
+ .to_string();
+
+ let mv = client
+ .request(
+ Method::from_bytes(b"MOVE").unwrap(),
+ format!("http://{}/src.txt", server.addr),
+ )
+ .header("Destination", format!("http://{}/dst.txt", server.addr))
+ .header("If", format!("(<{}>)", token))
+ .send()
+ .unwrap();
+ assert!(mv.status() == StatusCode::CREATED || mv.status() == StatusCode::NO_CONTENT);
+
+ let blocked = client
+ .request(Method::PUT, format!("http://{}/dst.txt", server.addr))
+ .body("blocked".to_string())
+ .send()
+ .unwrap();
+ assert_eq!(blocked.status(), StatusCode::LOCKED);
+
+ let allowed = client
+ .request(Method::PUT, format!("http://{}/dst.txt", server.addr))
+ .header("If", format!("(<{}>)", token))
+ .body("updated".to_string())
+ .send()
+ .unwrap();
+ assert!(allowed.status() == StatusCode::NO_CONTENT || allowed.status() == StatusCode::CREATED);
+}
+
+#[test]
+fn test_concurrent_lock_attempts_only_one_succeeds() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("doc.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let barrier = Arc::new(Barrier::new(6));
+ let mut handles = Vec::new();
+ for _ in 0..5usize {
+ let barrier_cloned = barrier.clone();
+ let addr = server.addr;
+ handles.push(thread::spawn(move || {
+ let client = Client::new();
+ barrier_cloned.wait();
+ client
+ .request(
+ Method::from_bytes(b"LOCK").unwrap(),
+ format!("http://{addr}/doc.txt"),
+ )
+ .body(
+ r#"
+
+
+
+ "#,
+ )
+ .send()
+ .unwrap()
+ .status()
+ }));
+ }
+ barrier.wait();
+ let mut ok = 0usize;
+ let mut locked = 0usize;
+ for handle in handles {
+ let status = handle.join().unwrap();
+ if status == StatusCode::OK || status == StatusCode::CREATED {
+ ok += 1;
+ } else if status == StatusCode::LOCKED {
+ locked += 1;
+ }
+ }
+ assert_eq!(ok, 1, "exactly one lock acquisition should succeed");
+ assert_eq!(locked, 4, "other concurrent lock attempts should be locked");
+}
diff --git a/tests/webdav_options_propfind_test.rs b/tests/webdav_options_propfind_test.rs
new file mode 100644
index 0000000..b810082
--- /dev/null
+++ b/tests/webdav_options_propfind_test.rs
@@ -0,0 +1,212 @@
+// SPDX-License-Identifier: MIT
+
+use irondrop::cli::Cli;
+use irondrop::server::run_server;
+use reqwest::Method;
+use reqwest::StatusCode;
+use reqwest::blocking::Client;
+use std::fs::{File, create_dir_all};
+use std::io::Write;
+use std::net::SocketAddr;
+use std::sync::mpsc;
+use std::thread::{self, JoinHandle};
+use tempfile::{TempDir, tempdir};
+
+struct TestServer {
+ addr: SocketAddr,
+ shutdown_tx: mpsc::Sender<()>,
+ handle: Option>,
+ _temp_dir: TempDir,
+}
+
+fn setup_test_server_with_tree_and_webdav(populate: F, enable_webdav: bool) -> TestServer
+where
+ F: FnOnce(&std::path::Path),
+{
+ let dir = tempdir().unwrap();
+ populate(dir.path());
+
+ let file_path = dir.path().join("test.txt");
+ let mut file = File::create(&file_path).unwrap();
+ writeln!(file, "hello from test file").unwrap();
+
+ let cli = Cli {
+ directory: dir.path().to_path_buf(),
+ listen: Some("127.0.0.1".to_string()),
+ port: Some(0),
+ allowed_extensions: Some("*".to_string()),
+ threads: Some(4),
+ chunk_size: Some(1024),
+ verbose: Some(false),
+ detailed_logging: Some(false),
+ username: None,
+ password: None,
+ enable_upload: Some(false),
+ max_upload_size: Some(10240),
+ enable_webdav: Some(enable_webdav),
+ config_file: None,
+ log_dir: None,
+ ssl_cert: None,
+ ssl_key: None,
+ };
+
+ let (shutdown_tx, shutdown_rx) = mpsc::channel();
+ let (addr_tx, addr_rx) = mpsc::channel();
+
+ let server_handle = thread::spawn(move || {
+ if let Err(e) = run_server(cli, Some(shutdown_rx), Some(addr_tx)) {
+ eprintln!("Server thread failed: {e}");
+ }
+ });
+
+ let server_addr = addr_rx.recv().unwrap();
+
+ TestServer {
+ addr: server_addr,
+ shutdown_tx,
+ handle: Some(server_handle),
+ _temp_dir: dir,
+ }
+}
+
+fn setup_test_server_with_tree(populate: F) -> TestServer
+where
+ F: FnOnce(&std::path::Path),
+{
+ setup_test_server_with_tree_and_webdav(populate, true)
+}
+
+impl Drop for TestServer {
+ fn drop(&mut self) {
+ if let Some(handle) = self.handle.take() {
+ self.shutdown_tx.send(()).ok();
+ handle.join().unwrap();
+ }
+ }
+}
+
+#[test]
+fn test_options_advertises_webdav_v1_capability() {
+ let server = setup_test_server_with_tree(|_| {});
+ let client = Client::new();
+
+ let response = client
+ .request(Method::OPTIONS, format!("http://{}/", server.addr))
+ .send()
+ .unwrap();
+
+ assert_eq!(response.status(), StatusCode::OK);
+ let dav = response.headers().get("dav").unwrap().to_str().unwrap();
+ assert!(dav.contains('1'));
+
+ let allow = response.headers().get("allow").unwrap().to_str().unwrap();
+ assert!(allow.contains("PROPFIND"));
+ assert!(allow.contains("MKCOL"));
+ assert!(allow.contains("PUT"));
+ assert!(allow.contains("DELETE"));
+ assert!(allow.contains("COPY"));
+ assert!(allow.contains("MOVE"));
+}
+
+#[test]
+fn test_webdav_methods_disabled_without_flag() {
+ let server = setup_test_server_with_tree_and_webdav(|_| {}, false);
+ let client = Client::new();
+
+ let response = client
+ .request(
+ Method::from_bytes(b"PROPFIND").unwrap(),
+ format!("http://{}/", server.addr),
+ )
+ .header("Depth", "0")
+ .header("Content-Type", "application/xml")
+ .body(r#" "#.to_string())
+ .send()
+ .unwrap();
+
+ assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
+}
+
+#[test]
+fn test_propfind_depth_zero_on_file_returns_multistatus() {
+ let server = setup_test_server_with_tree(|_| {});
+ let client = Client::new();
+ let body = r#"
+ "#;
+
+ let response = client
+ .request(
+ Method::from_bytes(b"PROPFIND").unwrap(),
+ format!("http://{}/test.txt", server.addr),
+ )
+ .header("Depth", "0")
+ .header("Content-Type", "application/xml")
+ .body(body.to_string())
+ .send()
+ .unwrap();
+
+ assert_eq!(response.status().as_u16(), 207);
+ let content_type = response
+ .headers()
+ .get("content-type")
+ .unwrap()
+ .to_str()
+ .unwrap();
+ assert!(content_type.contains("application/xml"));
+
+ let text = response.text().unwrap();
+ assert!(text.contains("/test.txt"));
+ assert!(text.contains(""));
+ assert!(text.contains(""));
+}
+
+#[test]
+fn test_propfind_depth_one_on_collection_includes_children() {
+ let server = setup_test_server_with_tree(|root| {
+ create_dir_all(root.join("dav").join("nested")).unwrap();
+ let mut f = File::create(root.join("dav").join("child.txt")).unwrap();
+ writeln!(f, "child").unwrap();
+ });
+ let client = Client::new();
+ let body = r#"
+ "#;
+
+ let response = client
+ .request(
+ Method::from_bytes(b"PROPFIND").unwrap(),
+ format!("http://{}/dav/", server.addr),
+ )
+ .header("Depth", "1")
+ .header("Content-Type", "application/xml")
+ .body(body.to_string())
+ .send()
+ .unwrap();
+
+ assert_eq!(response.status().as_u16(), 207);
+ let text = response.text().unwrap();
+ assert!(text.contains("/dav/ "));
+ assert!(text.contains("/dav/child.txt "));
+ assert!(text.contains("/dav/nested/ "));
+}
+
+#[test]
+fn test_propfind_infinite_depth_rejected_with_finite_depth_precondition() {
+ let server = setup_test_server_with_tree(|_| {});
+ let client = Client::new();
+
+ let response = client
+ .request(
+ Method::from_bytes(b"PROPFIND").unwrap(),
+ format!("http://{}/", server.addr),
+ )
+ .header("Depth", "infinity")
+ .header("Content-Type", "application/xml")
+ .body(String::new())
+ .send()
+ .unwrap();
+
+ assert_eq!(response.status(), StatusCode::FORBIDDEN);
+ let body = response.text().unwrap();
+ assert!(body.contains("propfind-finite-depth"));
+}
diff --git a/tests/webdav_propfind_rfc_test.rs b/tests/webdav_propfind_rfc_test.rs
new file mode 100644
index 0000000..fc8debe
--- /dev/null
+++ b/tests/webdav_propfind_rfc_test.rs
@@ -0,0 +1,262 @@
+// SPDX-License-Identifier: MIT
+
+use irondrop::cli::Cli;
+use irondrop::server::run_server;
+use reqwest::Method;
+use reqwest::StatusCode;
+use reqwest::blocking::Client;
+use std::fs::{File, create_dir_all};
+use std::io::Write;
+use std::net::SocketAddr;
+use std::sync::mpsc;
+use std::thread::{self, JoinHandle};
+use tempfile::{TempDir, tempdir};
+
+struct TestServer {
+ addr: SocketAddr,
+ shutdown_tx: mpsc::Sender<()>,
+ handle: Option>,
+ _temp_dir: TempDir,
+}
+
+fn setup_test_server_with_tree(populate: F) -> TestServer
+where
+ F: FnOnce(&std::path::Path),
+{
+ let dir = tempdir().unwrap();
+ populate(dir.path());
+
+ let cli = Cli {
+ directory: dir.path().to_path_buf(),
+ listen: Some("127.0.0.1".to_string()),
+ port: Some(0),
+ allowed_extensions: Some("*".to_string()),
+ threads: Some(4),
+ chunk_size: Some(1024),
+ verbose: Some(false),
+ detailed_logging: Some(false),
+ username: None,
+ password: None,
+ enable_upload: Some(false),
+ max_upload_size: Some(10240),
+ enable_webdav: Some(true),
+ config_file: None,
+ log_dir: None,
+ ssl_cert: None,
+ ssl_key: None,
+ };
+
+ let (shutdown_tx, shutdown_rx) = mpsc::channel();
+ let (addr_tx, addr_rx) = mpsc::channel();
+
+ let handle = thread::spawn(move || {
+ if let Err(e) = run_server(cli, Some(shutdown_rx), Some(addr_tx)) {
+ eprintln!("Server thread failed: {e}");
+ }
+ });
+
+ let addr = addr_rx.recv().unwrap();
+ TestServer {
+ addr,
+ shutdown_tx,
+ handle: Some(handle),
+ _temp_dir: dir,
+ }
+}
+
+impl Drop for TestServer {
+ fn drop(&mut self) {
+ if let Some(handle) = self.handle.take() {
+ self.shutdown_tx.send(()).ok();
+ handle.join().unwrap();
+ }
+ }
+}
+
+#[test]
+fn test_propfind_propname_returns_only_property_names() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("sample.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+ let body = r#"
+
+
+ "#;
+
+ let response = client
+ .request(
+ Method::from_bytes(b"PROPFIND").unwrap(),
+ format!("http://{}/sample.txt", server.addr),
+ )
+ .header("Depth", "0")
+ .header("Content-Type", "application/xml")
+ .body(body.to_string())
+ .send()
+ .unwrap();
+
+ assert_eq!(response.status().as_u16(), 207);
+ let xml = response.text().unwrap();
+ assert!(xml.contains(" "));
+ assert!(!xml.contains("6 "));
+}
+
+#[test]
+fn test_propfind_named_unknown_property_returns_404_propstat() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("sample.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+ let body = r#"
+
+
+
+
+
+ "#;
+
+ let response = client
+ .request(
+ Method::from_bytes(b"PROPFIND").unwrap(),
+ format!("http://{}/sample.txt", server.addr),
+ )
+ .header("Depth", "0")
+ .header("Content-Type", "application/xml")
+ .body(body.to_string())
+ .send()
+ .unwrap();
+
+ assert_eq!(response.status().as_u16(), 207);
+ let xml = response.text().unwrap();
+ assert!(xml.contains("HTTP/1.1 200 OK "));
+ assert!(xml.contains("HTTP/1.1 404 Not Found "));
+ assert!(xml.contains(" "));
+ assert!(xml.contains(""));
+}
+
+#[test]
+fn test_propfind_depth_infinity_on_collection_is_finite_depth_error() {
+ let server = setup_test_server_with_tree(|root| {
+ create_dir_all(root.join("dir").join("nested")).unwrap();
+ let mut file = File::create(root.join("dir").join("nested").join("x.txt")).unwrap();
+ writeln!(file, "x").unwrap();
+ });
+ let client = Client::new();
+ let body = r#"
+ "#;
+
+ let response = client
+ .request(
+ Method::from_bytes(b"PROPFIND").unwrap(),
+ format!("http://{}/dir/", server.addr),
+ )
+ .header("Depth", "infinity")
+ .header("Content-Type", "application/xml")
+ .body(body.to_string())
+ .send()
+ .unwrap();
+
+ // RFC-compliant finite-depth refusal should be 403 with DAV precondition body.
+ assert_eq!(response.status(), StatusCode::FORBIDDEN);
+ let xml = response.text().unwrap();
+ assert!(xml.contains("propfind-finite-depth"));
+}
+
+#[test]
+fn test_propfind_empty_body_defaults_to_allprop() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("sample.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+
+ let response = client
+ .request(
+ Method::from_bytes(b"PROPFIND").unwrap(),
+ format!("http://{}/sample.txt", server.addr),
+ )
+ .header("Depth", "0")
+ .header("Content-Length", "0")
+ .send()
+ .unwrap();
+
+ assert_eq!(response.status().as_u16(), 207);
+ let xml = response.text().unwrap();
+ assert!(xml.contains(""));
+ assert!(xml.contains(""));
+ assert!(xml.contains('T'));
+ assert!(xml.contains('Z'));
+}
+
+#[test]
+fn test_propfind_allprop_includes_dead_properties() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("sample.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+
+ let patch_body = r#"
+
+
+
+ blue
+
+
+ "#;
+ let patch_resp = client
+ .request(
+ Method::from_bytes(b"PROPPATCH").unwrap(),
+ format!("http://{}/sample.txt", server.addr),
+ )
+ .header("Content-Type", "application/xml")
+ .body(patch_body.to_string())
+ .send()
+ .unwrap();
+ assert_eq!(patch_resp.status().as_u16(), 207);
+
+ let body = r#"
+ "#;
+ let response = client
+ .request(
+ Method::from_bytes(b"PROPFIND").unwrap(),
+ format!("http://{}/sample.txt", server.addr),
+ )
+ .header("Depth", "0")
+ .header("Content-Type", "application/xml")
+ .body(body.to_string())
+ .send()
+ .unwrap();
+ assert_eq!(response.status().as_u16(), 207);
+ let xml = response.text().unwrap();
+ assert!(xml.contains("favorite") && xml.contains(">blue"));
+}
+
+#[test]
+fn test_propfind_wrapper_without_child_defaults_to_allprop() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("sample.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+ let body = r#"
+ "#;
+
+ let response = client
+ .request(
+ Method::from_bytes(b"PROPFIND").unwrap(),
+ format!("http://{}/sample.txt", server.addr),
+ )
+ .header("Depth", "0")
+ .header("Content-Type", "application/xml")
+ .body(body.to_string())
+ .send()
+ .unwrap();
+ assert_eq!(response.status().as_u16(), 207);
+ let xml = response.text().unwrap();
+ assert!(xml.contains(""));
+ assert!(xml.contains(""));
+}
diff --git a/tests/webdav_proppatch_rfc_test.rs b/tests/webdav_proppatch_rfc_test.rs
new file mode 100644
index 0000000..8a6c9e7
--- /dev/null
+++ b/tests/webdav_proppatch_rfc_test.rs
@@ -0,0 +1,364 @@
+// SPDX-License-Identifier: MIT
+
+use irondrop::cli::Cli;
+use irondrop::server::run_server;
+use reqwest::Method;
+use reqwest::blocking::Client;
+use std::fs::File;
+use std::io::Write;
+use std::net::SocketAddr;
+use std::sync::mpsc;
+use std::thread::{self, JoinHandle};
+use tempfile::{TempDir, tempdir};
+
+struct TestServer {
+ addr: SocketAddr,
+ shutdown_tx: mpsc::Sender<()>,
+ handle: Option>,
+ _temp_dir: TempDir,
+}
+
+fn setup_test_server_with_tree(populate: F) -> TestServer
+where
+ F: FnOnce(&std::path::Path),
+{
+ let dir = tempdir().unwrap();
+ populate(dir.path());
+
+ let cli = Cli {
+ directory: dir.path().to_path_buf(),
+ listen: Some("127.0.0.1".to_string()),
+ port: Some(0),
+ allowed_extensions: Some("*".to_string()),
+ threads: Some(4),
+ chunk_size: Some(1024),
+ verbose: Some(false),
+ detailed_logging: Some(false),
+ username: None,
+ password: None,
+ enable_upload: Some(false),
+ max_upload_size: Some(10240),
+ enable_webdav: Some(true),
+ config_file: None,
+ log_dir: None,
+ ssl_cert: None,
+ ssl_key: None,
+ };
+
+ let (shutdown_tx, shutdown_rx) = mpsc::channel();
+ let (addr_tx, addr_rx) = mpsc::channel();
+ let handle = thread::spawn(move || {
+ if let Err(e) = run_server(cli, Some(shutdown_rx), Some(addr_tx)) {
+ eprintln!("Server thread failed: {e}");
+ }
+ });
+
+ let addr = addr_rx.recv().unwrap();
+ TestServer {
+ addr,
+ shutdown_tx,
+ handle: Some(handle),
+ _temp_dir: dir,
+ }
+}
+
+impl Drop for TestServer {
+ fn drop(&mut self) {
+ if let Some(handle) = self.handle.take() {
+ self.shutdown_tx.send(()).ok();
+ handle.join().unwrap();
+ }
+ }
+}
+
+#[test]
+fn test_proppatch_set_property_then_propfind_reads_it() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("sample.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+
+ let patch_body = r#"
+
+
+
+ blue
+
+
+ "#;
+
+ let patch_resp = client
+ .request(
+ Method::from_bytes(b"PROPPATCH").unwrap(),
+ format!("http://{}/sample.txt", server.addr),
+ )
+ .header("Content-Type", "application/xml")
+ .body(patch_body.to_string())
+ .send()
+ .unwrap();
+ assert_eq!(patch_resp.status().as_u16(), 207);
+
+ let propfind_body = r#"
+
+
+
+
+ "#;
+ let read_resp = client
+ .request(
+ Method::from_bytes(b"PROPFIND").unwrap(),
+ format!("http://{}/sample.txt", server.addr),
+ )
+ .header("Depth", "0")
+ .header("Content-Type", "application/xml")
+ .body(propfind_body.to_string())
+ .send()
+ .unwrap();
+ assert_eq!(read_resp.status().as_u16(), 207);
+ let xml = read_resp.text().unwrap();
+ assert!(xml.contains("favorite") && xml.contains(">blue"));
+}
+
+#[test]
+fn test_proppatch_remove_property() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("sample.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+
+ let set_body = r#"
+
+
+
+ blue
+
+
+ "#;
+ client
+ .request(
+ Method::from_bytes(b"PROPPATCH").unwrap(),
+ format!("http://{}/sample.txt", server.addr),
+ )
+ .header("Content-Type", "application/xml")
+ .body(set_body.to_string())
+ .send()
+ .unwrap();
+
+ let remove_body = r#"
+
+
+
+
+
+
+ "#;
+ let remove_resp = client
+ .request(
+ Method::from_bytes(b"PROPPATCH").unwrap(),
+ format!("http://{}/sample.txt", server.addr),
+ )
+ .header("Content-Type", "application/xml")
+ .body(remove_body.to_string())
+ .send()
+ .unwrap();
+ assert_eq!(remove_resp.status().as_u16(), 207);
+
+ let propfind_body = r#"
+
+
+
+
+ "#;
+ let read_resp = client
+ .request(
+ Method::from_bytes(b"PROPFIND").unwrap(),
+ format!("http://{}/sample.txt", server.addr),
+ )
+ .header("Depth", "0")
+ .header("Content-Type", "application/xml")
+ .body(propfind_body.to_string())
+ .send()
+ .unwrap();
+ assert_eq!(read_resp.status().as_u16(), 207);
+ let xml = read_resp.text().unwrap();
+ assert!(xml.contains("HTTP/1.1 404 Not Found"));
+}
+
+#[test]
+fn test_proppatch_empty_propertyupdate_is_bad_request() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("sample.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+
+ let patch_body = r#"
+ "#;
+
+ let patch_resp = client
+ .request(
+ Method::from_bytes(b"PROPPATCH").unwrap(),
+ format!("http://{}/sample.txt", server.addr),
+ )
+ .header("Content-Type", "application/xml")
+ .body(patch_body.to_string())
+ .send()
+ .unwrap();
+ assert_eq!(patch_resp.status().as_u16(), 400);
+}
+
+#[test]
+fn test_proppatch_respects_document_order_remove_then_set() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("sample.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+
+ let seed_body = r#"
+
+
+
+ blue
+
+
+ "#;
+ client
+ .request(
+ Method::from_bytes(b"PROPPATCH").unwrap(),
+ format!("http://{}/sample.txt", server.addr),
+ )
+ .header("Content-Type", "application/xml")
+ .body(seed_body.to_string())
+ .send()
+ .unwrap();
+
+ let ordered_body = r#"
+
+
+
+
+
+
+
+
+ green
+
+
+ "#;
+ let patch_resp = client
+ .request(
+ Method::from_bytes(b"PROPPATCH").unwrap(),
+ format!("http://{}/sample.txt", server.addr),
+ )
+ .header("Content-Type", "application/xml")
+ .body(ordered_body.to_string())
+ .send()
+ .unwrap();
+ assert_eq!(patch_resp.status().as_u16(), 207);
+
+ let propfind_body = r#"
+
+
+
+
+ "#;
+ let read_resp = client
+ .request(
+ Method::from_bytes(b"PROPFIND").unwrap(),
+ format!("http://{}/sample.txt", server.addr),
+ )
+ .header("Depth", "0")
+ .header("Content-Type", "application/xml")
+ .body(propfind_body.to_string())
+ .send()
+ .unwrap();
+ assert_eq!(read_resp.status().as_u16(), 207);
+ let xml = read_resp.text().unwrap();
+ assert!(xml.contains("favorite") && xml.contains(">green"));
+}
+
+#[test]
+fn test_proppatch_protected_live_property_returns_403_propstat() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("sample.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+
+ let body = r#"
+
+
+
+ "override"
+
+
+ "#;
+ let resp = client
+ .request(
+ Method::from_bytes(b"PROPPATCH").unwrap(),
+ format!("http://{}/sample.txt", server.addr),
+ )
+ .header("Content-Type", "application/xml")
+ .body(body.to_string())
+ .send()
+ .unwrap();
+ assert_eq!(resp.status().as_u16(), 207);
+ let xml = resp.text().unwrap();
+ assert!(xml.contains("HTTP/1.1 403 Forbidden"));
+}
+
+#[test]
+fn test_proppatch_namespace_distinct_properties_do_not_collide() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("sample.txt")).unwrap();
+ writeln!(file, "hello").unwrap();
+ });
+ let client = Client::new();
+
+ let body = r#"
+
+
+
+ one
+ two
+
+
+ "#;
+ let resp = client
+ .request(
+ Method::from_bytes(b"PROPPATCH").unwrap(),
+ format!("http://{}/sample.txt", server.addr),
+ )
+ .header("Content-Type", "application/xml")
+ .body(body.to_string())
+ .send()
+ .unwrap();
+ assert_eq!(resp.status().as_u16(), 207);
+
+ let propfind_body = r#"
+
+
+
+
+
+ "#;
+ let read_resp = client
+ .request(
+ Method::from_bytes(b"PROPFIND").unwrap(),
+ format!("http://{}/sample.txt", server.addr),
+ )
+ .header("Depth", "0")
+ .header("Content-Type", "application/xml")
+ .body(propfind_body.to_string())
+ .send()
+ .unwrap();
+ assert_eq!(read_resp.status().as_u16(), 207);
+ let xml = read_resp.text().unwrap();
+ assert!(xml.contains("urn:a"));
+ assert!(xml.contains("urn:b"));
+ assert!(xml.contains(">one"));
+ assert!(xml.contains(">two"));
+}
diff --git a/tests/webdav_write_methods_test.rs b/tests/webdav_write_methods_test.rs
new file mode 100644
index 0000000..dc0eb93
--- /dev/null
+++ b/tests/webdav_write_methods_test.rs
@@ -0,0 +1,185 @@
+// SPDX-License-Identifier: MIT
+
+use irondrop::cli::Cli;
+use irondrop::server::run_server;
+use reqwest::Method;
+use reqwest::StatusCode;
+use reqwest::blocking::Client;
+use std::fs::{self, File, create_dir_all};
+use std::io::Write;
+use std::net::SocketAddr;
+use std::sync::mpsc;
+use std::thread::{self, JoinHandle};
+use tempfile::{TempDir, tempdir};
+
+struct TestServer {
+ addr: SocketAddr,
+ shutdown_tx: mpsc::Sender<()>,
+ handle: Option>,
+ root: std::path::PathBuf,
+ _temp_dir: TempDir,
+}
+
+fn setup_test_server_with_tree(populate: F) -> TestServer
+where
+ F: FnOnce(&std::path::Path),
+{
+ let dir = tempdir().unwrap();
+ populate(dir.path());
+
+ let cli = Cli {
+ directory: dir.path().to_path_buf(),
+ listen: Some("127.0.0.1".to_string()),
+ port: Some(0),
+ allowed_extensions: Some("*".to_string()),
+ threads: Some(4),
+ chunk_size: Some(1024),
+ verbose: Some(false),
+ detailed_logging: Some(false),
+ username: None,
+ password: None,
+ enable_upload: Some(false),
+ max_upload_size: Some(10240),
+ enable_webdav: Some(true),
+ config_file: None,
+ log_dir: None,
+ ssl_cert: None,
+ ssl_key: None,
+ };
+
+ let (shutdown_tx, shutdown_rx) = mpsc::channel();
+ let (addr_tx, addr_rx) = mpsc::channel();
+ let root = dir.path().to_path_buf();
+
+ let server_handle = thread::spawn(move || {
+ if let Err(e) = run_server(cli, Some(shutdown_rx), Some(addr_tx)) {
+ eprintln!("Server thread failed: {e}");
+ }
+ });
+
+ let server_addr = addr_rx.recv().unwrap();
+
+ TestServer {
+ addr: server_addr,
+ shutdown_tx,
+ handle: Some(server_handle),
+ root,
+ _temp_dir: dir,
+ }
+}
+
+impl Drop for TestServer {
+ fn drop(&mut self) {
+ if let Some(handle) = self.handle.take() {
+ self.shutdown_tx.send(()).ok();
+ handle.join().unwrap();
+ }
+ }
+}
+
+#[test]
+fn test_mkcol_creates_collection() {
+ let server = setup_test_server_with_tree(|_| {});
+ let client = Client::new();
+
+ let response = client
+ .request(
+ Method::from_bytes(b"MKCOL").unwrap(),
+ format!("http://{}/newdir/", server.addr),
+ )
+ .send()
+ .unwrap();
+
+ assert_eq!(response.status(), StatusCode::CREATED);
+ assert!(server.root.join("newdir").is_dir());
+}
+
+#[test]
+fn test_mkcol_parent_missing_conflict() {
+ let server = setup_test_server_with_tree(|_| {});
+ let client = Client::new();
+
+ let response = client
+ .request(
+ Method::from_bytes(b"MKCOL").unwrap(),
+ format!("http://{}/missing/child/", server.addr),
+ )
+ .send()
+ .unwrap();
+
+ assert_eq!(response.status(), StatusCode::CONFLICT);
+}
+
+#[test]
+fn test_put_creates_and_updates_file() {
+ let server = setup_test_server_with_tree(|_| {});
+ let client = Client::new();
+
+ let create = client
+ .request(Method::PUT, format!("http://{}/notes.txt", server.addr))
+ .body("hello".to_string())
+ .send()
+ .unwrap();
+ assert_eq!(create.status(), StatusCode::CREATED);
+ assert_eq!(
+ fs::read_to_string(server.root.join("notes.txt")).unwrap(),
+ "hello"
+ );
+
+ let replace = client
+ .request(Method::PUT, format!("http://{}/notes.txt", server.addr))
+ .body("updated".to_string())
+ .send()
+ .unwrap();
+ assert_eq!(replace.status(), StatusCode::NO_CONTENT);
+ assert_eq!(
+ fs::read_to_string(server.root.join("notes.txt")).unwrap(),
+ "updated"
+ );
+}
+
+#[test]
+fn test_delete_removes_file_and_collection() {
+ let server = setup_test_server_with_tree(|root| {
+ let mut file = File::create(root.join("to-delete.txt")).unwrap();
+ writeln!(file, "erase").unwrap();
+ create_dir_all(root.join("to-delete-dir").join("nested")).unwrap();
+ let mut nested =
+ File::create(root.join("to-delete-dir").join("nested").join("file.txt")).unwrap();
+ writeln!(nested, "nested").unwrap();
+ });
+ let client = Client::new();
+
+ let delete_file = client
+ .request(
+ Method::DELETE,
+ format!("http://{}/to-delete.txt", server.addr),
+ )
+ .send()
+ .unwrap();
+ assert_eq!(delete_file.status(), StatusCode::NO_CONTENT);
+ assert!(!server.root.join("to-delete.txt").exists());
+
+ let delete_dir = client
+ .request(
+ Method::DELETE,
+ format!("http://{}/to-delete-dir/", server.addr),
+ )
+ .send()
+ .unwrap();
+ assert_eq!(delete_dir.status(), StatusCode::NO_CONTENT);
+ assert!(!server.root.join("to-delete-dir").exists());
+}
+
+#[test]
+fn test_delete_missing_resource_not_found() {
+ let server = setup_test_server_with_tree(|_| {});
+ let client = Client::new();
+
+ let response = client
+ .request(Method::DELETE, format!("http://{}/nope.txt", server.addr))
+ .send()
+ .unwrap();
+
+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
+}