Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ members = [
"crates/aster_forge_test",
"crates/aster_forge_utils",
"crates/aster_forge_validation",
"crates/aster_forge_webdav",
"crates/aster_forge_xml",
]
resolver = "3"
Expand All @@ -46,6 +47,7 @@ async-trait = "0.1"
argon2 = "0.5.3"
base64 = "0.22"
bloomfilter = "3.0.1"
bytes = "1"
chrono = { version = "0.4.45", default-features = false, features = ["serde"] }
criterion = { version = "0.8", features = ["async_tokio"] }
dashmap = "6"
Expand All @@ -59,6 +61,7 @@ lettre = { version = "0.11", default-features = false, features = ["builder", "s
md-5 = "0.11"
moka = { version = "0.12.15", features = ["future"] }
parking_lot = "0.12"
percent-encoding = "2.3"
proc-macro2 = "1"
quick-xml = { version = "0.41", default-features = false }
quote = "1"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ All crate names use the `aster_forge_*` prefix. The workspace targets Rust `1.94
| Area | Crates |
| --- | --- |
| Runtime kernel | [`aster_forge_runtime`](https://forge.astercosm.com/crates/aster_forge_runtime), [`aster_forge_config`](https://forge.astercosm.com/crates/aster_forge_config), [`aster_forge_logging`](https://forge.astercosm.com/crates/aster_forge_logging), [`aster_forge_metrics`](https://forge.astercosm.com/crates/aster_forge_metrics), [`aster_forge_panic`](https://forge.astercosm.com/crates/aster_forge_panic), [`aster_forge_alloc`](https://forge.astercosm.com/crates/aster_forge_alloc) |
| Web and API | [`aster_forge_api`](https://forge.astercosm.com/crates/aster_forge_api), [`aster_forge_api_docs_macros`](https://forge.astercosm.com/crates/aster_forge_api_docs_macros), [`aster_forge_actix_middleware`](https://forge.astercosm.com/crates/aster_forge_actix_middleware), [`aster_forge_actix_observability`](https://forge.astercosm.com/crates/aster_forge_actix_observability), [`aster_forge_external_auth`](https://forge.astercosm.com/crates/aster_forge_external_auth) |
| Web and API | [`aster_forge_api`](https://forge.astercosm.com/crates/aster_forge_api), [`aster_forge_api_docs_macros`](https://forge.astercosm.com/crates/aster_forge_api_docs_macros), [`aster_forge_actix_middleware`](https://forge.astercosm.com/crates/aster_forge_actix_middleware), [`aster_forge_actix_observability`](https://forge.astercosm.com/crates/aster_forge_actix_observability), [`aster_forge_external_auth`](https://forge.astercosm.com/crates/aster_forge_external_auth), [`aster_forge_webdav`](https://forge.astercosm.com/crates/aster_forge_webdav) |
| Data, coordination, and background work | [`aster_forge_db`](https://forge.astercosm.com/crates/aster_forge_db), [`aster_forge_cache`](https://forge.astercosm.com/crates/aster_forge_cache), [`aster_forge_tasks`](https://forge.astercosm.com/crates/aster_forge_tasks), [`aster_forge_mail`](https://forge.astercosm.com/crates/aster_forge_mail), [`aster_forge_audit`](https://forge.astercosm.com/crates/aster_forge_audit) |
| Storage and domain-neutral helpers | [`aster_forge_storage_core`](https://forge.astercosm.com/crates/aster_forge_storage_core), [`aster_forge_file_classification`](https://forge.astercosm.com/crates/aster_forge_file_classification) |
| Utilities | [`aster_forge_crypto`](https://forge.astercosm.com/crates/aster_forge_crypto), [`aster_forge_utils`](https://forge.astercosm.com/crates/aster_forge_utils), [`aster_forge_validation`](https://forge.astercosm.com/crates/aster_forge_validation), [`aster_forge_xml`](https://forge.astercosm.com/crates/aster_forge_xml) |
Expand Down
2 changes: 1 addition & 1 deletion README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ aster_forge_runtime::AsterRuntime::builder()
| 领域 | Crates |
| --- | --- |
| 运行时内核 | [`aster_forge_runtime`](https://forge.astercosm.com/crates/aster_forge_runtime)、[`aster_forge_config`](https://forge.astercosm.com/crates/aster_forge_config)、[`aster_forge_logging`](https://forge.astercosm.com/crates/aster_forge_logging)、[`aster_forge_metrics`](https://forge.astercosm.com/crates/aster_forge_metrics)、[`aster_forge_panic`](https://forge.astercosm.com/crates/aster_forge_panic)、[`aster_forge_alloc`](https://forge.astercosm.com/crates/aster_forge_alloc) |
| Web 与 API | [`aster_forge_api`](https://forge.astercosm.com/crates/aster_forge_api)、[`aster_forge_api_docs_macros`](https://forge.astercosm.com/crates/aster_forge_api_docs_macros)、[`aster_forge_actix_middleware`](https://forge.astercosm.com/crates/aster_forge_actix_middleware)、[`aster_forge_actix_observability`](https://forge.astercosm.com/crates/aster_forge_actix_observability)、[`aster_forge_external_auth`](https://forge.astercosm.com/crates/aster_forge_external_auth) |
| Web 与 API | [`aster_forge_api`](https://forge.astercosm.com/crates/aster_forge_api)、[`aster_forge_api_docs_macros`](https://forge.astercosm.com/crates/aster_forge_api_docs_macros)、[`aster_forge_actix_middleware`](https://forge.astercosm.com/crates/aster_forge_actix_middleware)、[`aster_forge_actix_observability`](https://forge.astercosm.com/crates/aster_forge_actix_observability)、[`aster_forge_external_auth`](https://forge.astercosm.com/crates/aster_forge_external_auth)、[`aster_forge_webdav`](https://forge.astercosm.com/crates/aster_forge_webdav) |
| 数据、协调与后台任务 | [`aster_forge_db`](https://forge.astercosm.com/crates/aster_forge_db)、[`aster_forge_cache`](https://forge.astercosm.com/crates/aster_forge_cache)、[`aster_forge_tasks`](https://forge.astercosm.com/crates/aster_forge_tasks)、[`aster_forge_mail`](https://forge.astercosm.com/crates/aster_forge_mail)、[`aster_forge_audit`](https://forge.astercosm.com/crates/aster_forge_audit) |
| 存储与领域无关辅助 | [`aster_forge_storage_core`](https://forge.astercosm.com/crates/aster_forge_storage_core)、[`aster_forge_file_classification`](https://forge.astercosm.com/crates/aster_forge_file_classification) |
| 工具类 | [`aster_forge_crypto`](https://forge.astercosm.com/crates/aster_forge_crypto)、[`aster_forge_utils`](https://forge.astercosm.com/crates/aster_forge_utils)、[`aster_forge_validation`](https://forge.astercosm.com/crates/aster_forge_validation) |
Expand Down
2 changes: 1 addition & 1 deletion crates/aster_forge_metrics/src/prometheus.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Prometheus backend for shared Aster infrastructure metrics.
//!
//! This module is enabled by the `prometheus` feature. Product crates can use
//! [`init_or_noop`] to obtain a [`SharedMetricsRecorder`](crate::SharedMetricsRecorder)
//! [`init_or_noop`] to obtain a [`SharedMetricsRecorder`]
//! and [`export_metrics`] for their HTTP metrics endpoint without depending on
//! the `prometheus` crate directly. Product-specific metric families can be
//! registered with Forge descriptors and recorded through opaque handles, so
Expand Down
221 changes: 221 additions & 0 deletions crates/aster_forge_utils/src/http_range.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
//! Transport-neutral parsing for a single HTTP byte range.

/// A resolved inclusive byte range for one representation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HttpByteRange {
start: u64,
end: u64,
length: u64,
total_size: u64,
}

impl HttpByteRange {
/// Creates a resolved byte range and validates it against the representation length.
pub fn new(start: u64, end: u64, total_size: u64) -> Result<Self, HttpRangeError> {
if total_size == 0 {
return Err(HttpRangeError::EmptyRepresentation);
}
if start > end || end >= total_size {
return Err(HttpRangeError::Unsatisfiable);
}
Ok(Self {
start,
end,
length: end - start + 1,
total_size,
})
}

#[must_use]
pub const fn start(self) -> u64 {
self.start
}

#[must_use]
pub const fn end(self) -> u64 {
self.end
}

#[must_use]
pub const fn length(self) -> u64 {
self.length
}

#[must_use]
pub const fn total_size(self) -> u64 {
self.total_size
}

/// Renders the value required by a successful `Content-Range` response header.
#[must_use]
pub fn content_range_header(self) -> String {
format!("bytes {}-{}/{}", self.start, self.end, self.total_size)
}
}

/// Stable failure categories for a single byte-range request.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum HttpRangeError {
#[error("range header must use the bytes unit")]
UnsupportedUnit,
#[error("multiple range requests are not supported")]
MultipleRangesUnsupported,
#[error("range header is malformed")]
Malformed,
#[error("range bound must be a valid unsigned integer")]
InvalidNumber,
#[error("range cannot be requested for an empty representation")]
EmptyRepresentation,
#[error("range is not satisfiable for the current representation")]
Unsatisfiable,
}

/// Parses and resolves one RFC byte-range specifier against a representation length.
///
/// Multiple ranges are reported separately so callers can choose whether to reject them or
/// implement multipart responses. End bounds beyond the representation are clamped as required
/// by HTTP range semantics.
pub fn parse_single_byte_range(
raw: &str,
total_size: u64,
) -> Result<HttpByteRange, HttpRangeError> {
let raw = raw.trim_start();
let (unit, range) = raw.split_once('=').ok_or(HttpRangeError::UnsupportedUnit)?;
if !unit.eq_ignore_ascii_case("bytes") {
return Err(HttpRangeError::UnsupportedUnit);
}
if range.contains(',') {
return Err(HttpRangeError::MultipleRangesUnsupported);
}

let (start_raw, end_raw) = range.split_once('-').ok_or(HttpRangeError::Malformed)?;
if start_raw.is_empty() && end_raw.is_empty() {
return Err(HttpRangeError::Malformed);
}
if total_size == 0 {
return Err(HttpRangeError::EmptyRepresentation);
}

if start_raw.is_empty() {
let suffix_length = parse_bound(end_raw)?;
if suffix_length == 0 {
return Err(HttpRangeError::Unsatisfiable);
}
let length = suffix_length.min(total_size);
return HttpByteRange::new(total_size - length, total_size - 1, total_size);
}

let start = parse_bound(start_raw)?;
if start >= total_size {
return Err(HttpRangeError::Unsatisfiable);
}
let end = if end_raw.is_empty() {
total_size - 1
} else {
parse_bound(end_raw)?
};
if end < start {
return Err(HttpRangeError::Unsatisfiable);
}
HttpByteRange::new(start, end.min(total_size - 1), total_size)
}

fn parse_bound(value: &str) -> Result<u64, HttpRangeError> {
value
.parse::<u64>()
.map_err(|_| HttpRangeError::InvalidNumber)
}

#[cfg(test)]
mod tests {
use super::{HttpByteRange, HttpRangeError, parse_single_byte_range};

#[test]
fn resolves_bounded_open_and_suffix_ranges() {
assert_eq!(
parse_single_byte_range("bytes=5-9", 20),
HttpByteRange::new(5, 9, 20)
);
assert_eq!(
parse_single_byte_range("bytes=7-", 20),
HttpByteRange::new(7, 19, 20)
);
assert_eq!(
parse_single_byte_range("bytes=-6", 20),
HttpByteRange::new(14, 19, 20)
);
assert_eq!(
parse_single_byte_range("bytes=-50", 20),
HttpByteRange::new(0, 19, 20)
);
assert_eq!(
parse_single_byte_range(" BYTES=0-1", 20),
HttpByteRange::new(0, 1, 20)
);
}

#[test]
fn clamps_end_beyond_the_representation() {
assert_eq!(
parse_single_byte_range("bytes=17-99", 20),
HttpByteRange::new(17, 19, 20)
);
}

#[test]
fn preserves_u64_boundaries_without_overflow() {
let total_size = u64::MAX;
let range = parse_single_byte_range("bytes=0-18446744073709551615", total_size)
.expect("maximum end should clamp safely");
assert_eq!(range.start(), 0);
assert_eq!(range.end(), u64::MAX - 1);
assert_eq!(range.length(), u64::MAX);
assert_eq!(range.total_size(), total_size);
}

#[test]
fn renders_content_range_and_exposes_bounds() {
let range = HttpByteRange::new(2, 6, 10).expect("valid range");
assert_eq!(range.start(), 2);
assert_eq!(range.end(), 6);
assert_eq!(range.length(), 5);
assert_eq!(range.total_size(), 10);
assert_eq!(range.content_range_header(), "bytes 2-6/10");
}

#[test]
fn constructor_rejects_empty_inverted_and_out_of_bounds_ranges() {
assert_eq!(
HttpByteRange::new(0, 0, 0),
Err(HttpRangeError::EmptyRepresentation)
);
assert_eq!(
HttpByteRange::new(5, 4, 10),
Err(HttpRangeError::Unsatisfiable)
);
assert_eq!(
HttpByteRange::new(5, 10, 10),
Err(HttpRangeError::Unsatisfiable)
);
}

#[test]
fn classifies_every_rejected_range_shape() {
let cases = [
("items=0-1", HttpRangeError::UnsupportedUnit),
("bytes=0-1,3-4", HttpRangeError::MultipleRangesUnsupported),
("bytes=-", HttpRangeError::Malformed),
("bytes=abc-", HttpRangeError::InvalidNumber),
("bytes=-0", HttpRangeError::Unsatisfiable),
("bytes=9-5", HttpRangeError::Unsatisfiable),
("bytes=20-", HttpRangeError::Unsatisfiable),
];
for (raw, expected) in cases {
assert_eq!(parse_single_byte_range(raw, 20), Err(expected), "{raw}");
}
assert_eq!(
parse_single_byte_range("bytes=0-0", 0),
Err(HttpRangeError::EmptyRepresentation)
);
}
}
7 changes: 4 additions & 3 deletions crates/aster_forge_utils/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
//! Shared low-level utility helpers for Aster services.
//!
//! This crate holds small, dependency-light helpers that do not belong to a single domain module:
//! boolean-like string parsing, checked numeric conversions, path rendering helpers, loopback host
//! detection, UUID/token helpers, and RAII cleanup guards. The shared error type is intentionally
//! simple so callers can map it into richer product errors.
//! boolean-like string parsing, HTTP range/validator handling, checked numeric conversions, path
//! rendering helpers, loopback host detection, UUID/token helpers, and RAII cleanup guards. The
//! shared error type is intentionally simple so callers can map it into richer product errors.
#![deny(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
#![cfg_attr(
not(test),
Expand All @@ -22,6 +22,7 @@ pub mod backoff;
pub mod bool_like;
pub mod fs;
pub mod html;
pub mod http_range;
pub mod http_validators;
pub mod id;
pub mod net;
Expand Down
35 changes: 35 additions & 0 deletions crates/aster_forge_webdav/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# AGENTS.md

This file supplements [`../../AGENTS.md`](../../AGENTS.md) and applies only to `aster_forge_webdav`. See [`../../docs/crates/aster_forge_webdav.md`](../../docs/crates/aster_forge_webdav.md) for protocol ownership, backend ports, errors, and the test matrix. See [`../../docs/crates/aster_forge_xml.md`](../../docs/crates/aster_forge_xml.md) for the underlying XML contract.

## Before Making Changes

1. Read `Cargo.toml`, `src/lib.rs`, the target protocol module, and the corresponding `tests/*.rs` files.
2. For XML changes, first determine whether `aster_forge_xml` should provide a generic upstream primitive. Keep only DAV grammar, selection, and response composition here.
3. Use downstream projects only to validate adapters. Authentication, permissions, workspace scope, storage, quota, persistence, and audit remain product-owned.

## Ownership Boundaries

- This crate owns the WebDAV and DeltaV protocol engine: path, header, and body parsing; preconditions; LOCK; properties; COPY/MOVE/DELETE/PUT/GET/HEAD planning; response grammar; backend ports; events; and the optional Actix adapter.
- `aster_forge_xml` owns bounded parsing, streaming, writing, safety limits, and namespace primitives. This crate owns DAV XML semantics.
- Products own Basic account authentication, rate limiting, principals and workspace permissions, file/blob/quota/storage policy, dead-property and lock persistence, business transactions, audit, and notifications.

## Change Constraints

- The transport-neutral core must not depend on Actix. The Actix layer performs explicit conversions for HTTP types, streams, and bodies.
- `DavBackendErrorKind` compresses only classifications required by the protocol. Keep detailed product errors in product logs and never leak product envelopes.
- Follow protocol contracts for path percent-decoding, dot segments, mount escape, same-origin `Destination`, `If`, ETag and lock-token handling, and `Depth` and `Overwrite` precedence.
- Decide empty, bounded, streaming, or unused body policy before reading. Bound XML size and depth, and never buffer an upload stream as one complete body.
- Keep multi-resource mutation results typed across 207/201/204 and partial failures; do not infer status from strings.
- Handle unknown XML extensions according to each method's grammar. Reject DTD and ENTITY, QName or namespace conflicts, and duplicated mutually exclusive controls.
- Backend traits expose only protocol-required ports. Do not absorb product repositories, authentication context, or storage-driver details.

## Validation

```bash
cargo test -p aster_forge_webdav
cargo test -p aster_forge_webdav --features actix
cargo clippy -p aster_forge_webdav --all-targets --all-features -- -D warnings
```

Run the relevant matrix for path escape, header grammar, conditional precedence, ranges, LOCK and `If`, property XML, partial failures, and Actix body policy. Run `aster_forge_xml` tests for XML primitives, then validate complete compatibility in product Litmus, rclone, curl, or cadaver tests.
25 changes: 25 additions & 0 deletions crates/aster_forge_webdav/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[package]
name = "aster_forge_webdav"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
publish = false

[features]
default = []
actix = ["dep:actix-web"]

[dependencies]
actix-web = { workspace = true, optional = true }
aster_forge_utils = { path = "../aster_forge_utils" }
aster_forge_xml = { path = "../aster_forge_xml" }
async-trait.workspace = true
bytes.workspace = true
chrono = { workspace = true, features = ["clock"] }
futures.workspace = true
http.workspace = true
percent-encoding.workspace = true
thiserror.workspace = true
Loading