- Offline CLI `upload-migrate OLD_CONFIG NEW_CONFIG --component HOST

[--execute]`: dry run по умолчанию, local ↔ S3 и S3 ↔ S3 без удаления
  source.
- Последовательное копирование с проверкой размера и полным SHA-256
  readback
  destination; конфликтующий объект не перезаписывается. Временный диск
  —
  максимум один переносимый файл дополнительно к постоянному
  destination.
- Durable migration journal позволяет продолжить прерванную операцию с
  теми
  же конфигурациями. Незавершённый или повреждённый journal блокирует
  startup;
  backend identity меняется только после проверки всех файлов.
- Native `uploads`/`upload_stats`, slots и JWT сохраняются;
  поддерживаются
  native локальные файлы без Rust completion sidecar. Миграция требует
  остановки RustXMPP и Prosody, использующих общий `data_path`.
- Multipart intent сохраняется до создания удалённого upload. Recovery
  находит потерянный UploadId через paginated listing и очищает только
  точный
  key принадлежащего slot. При сохранных metadata cleanup не зависит от
  lifecycle; lifecycle остаётся дополнительной защитой, а не гарантией.
- Перед completion сохраняется durable marker. Неопределённый результат
  completion при отсутствии подтверждённого объекта помещает только этот
  slot в quarantine: PUT 409, retention не удаляет его, maintenance
  повторяет
  сверку. Startup продолжается; сохраняется single-use семантика.
- Добавлены network regressions миграции и расширены проверки multipart
  recovery, потерянных ответов, отмены запросов и конфликтов
  destination.

Настройка, команды миграции, recovery и ограничения отката:
[docs/HTTP-UPLOAD.md](docs/HTTP-UPLOAD.md).

Команды, результаты и границы подтверждения, включая qualification
реального
object store и 10 GiB:
[docs/VALIDATION-0.7.9.md](docs/VALIDATION-0.7.9.md).

Итог: 265 тестов, нагрузка 500 TLS клиентов и native reference —
успешно.
Реальные 10 GiB через MinIO (описанный test adapter), 1280parts и полный
SHA-256
пройдены; подробности — [VALIDATION-0.7.9](docs/VALIDATION-0.7.9.md).
This commit is contained in:
wt committed 2026-10-20 17:06:14 +07:00
1 parent c260d7bab8
commit b30fc524d8
41 files changed
+5936 -152

No files matched your search

+1 -1
View File
@@ -28,7 +28,7 @@ fn string(options: &Options, key: &str) -> Result<Option<String>> {
})
.transpose()
}
fn valid_authority(authority: &axum::http::uri::Authority) -> bool {
pub(crate) fn valid_authority(authority: &axum::http::uri::Authority) -> bool {
let raw = authority.as_str();
if raw.contains('@') {
return false;
+613 -40
View File
@@ -1,6 +1,7 @@
//! Streaming XEP-0363 upload endpoint. Slots are single-use and bounded before streaming.
use crate::{
logging::{Level, Logger},
object_store::{ObjectStore, PART_SIZE, UploadBackendConfig},
storage,
wire::escape,
};
@@ -56,13 +57,29 @@ struct Record {
token: String,
complete: bool,
#[serde(default)]
upload_id: Option<String>,
// Durable before CreateMultipartUpload: UploadId may be lost in a crash.
#[serde(default)]
multipart_intent: bool,
#[serde(default)]
completion_attempted: bool,
#[serde(default)]
xml: Option<String>,
}
#[derive(Debug)]
struct CompletionUncertain;
impl std::fmt::Display for CompletionUncertain {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("S3 completion outcome uncertain; reservation quarantined for reconciliation")
}
}
impl std::error::Error for CompletionUncertain {}
#[derive(Clone)]
pub struct UploadService {
config: Arc<UploadConfig>,
records: Arc<Mutex<BTreeMap<String, Record>>>,
active: Arc<Mutex<BTreeSet<String>>>,
backend: Option<Arc<ObjectStore>>,
}
pub struct Slot {
pub url: String,
@@ -105,6 +122,13 @@ fn url_decode(value: &str) -> Option<Vec<u8>> {
}
impl UploadService {
pub fn new(config: UploadConfig) -> Result<Self> {
Self::new_with_backend(config, UploadBackendConfig::Local)
}
pub fn new_with_backend(config: UploadConfig, backend: UploadBackendConfig) -> Result<Self> {
let backend = match backend {
UploadBackendConfig::Local => None,
UploadBackendConfig::S3(c) => Some(Arc::new(ObjectStore::new(c)?)),
};
ensure!(
config.base_url.starts_with("https://") || config.base_url.starts_with("http://"),
"invalid upload base URL"
@@ -131,7 +155,24 @@ impl UploadService {
.attribute("size")
.ok_or_else(|| anyhow::anyhow!("upload size missing"))?
.parse()?;
let token = records.remove(&e.id).map(|r| r.token).unwrap_or_default();
let cached = records.remove(&e.id);
let complete = cached.as_ref().is_some_and(|r| {
r.complete && r.owner == e.peer && r.filename == filename && r.size == size
});
let upload_id = cached
.as_ref()
.filter(|r| r.owner == e.peer && r.filename == filename && r.size == size)
.and_then(|r| r.upload_id.clone());
let multipart_intent = cached.as_ref().is_some_and(|r| {
r.owner == e.peer && r.filename == filename && r.size == size && r.multipart_intent
});
let completion_attempted = cached.as_ref().is_some_and(|r| {
r.owner == e.peer
&& r.filename == filename
&& r.size == size
&& r.completion_attempted
});
let token = cached.map(|r| r.token).unwrap_or_default();
authoritative.insert(
e.id,
Record {
@@ -144,20 +185,151 @@ impl UploadService {
.into(),
created: e.when as u64,
token,
complete: false,
complete,
upload_id,
multipart_intent,
completion_attempted,
xml: Some(e.xml),
},
);
}
records = authoritative;
let identity = backend
.as_ref()
.map(|b| b.identity())
.unwrap_or_else(|| serde_json::json!({"backend":"local"}));
let identity_path = config
.root
.join(storage::encode_name(&config.host))
.join("rust_upload_backend.json");
if identity_path.exists() {
let previous: serde_json::Value =
serde_json::from_slice(&std::fs::read(&identity_path)?)?;
ensure!(
records.is_empty() || previous == identity,
"upload backend changed while slots still exist"
);
} else {
ensure!(
backend.is_none() || records.is_empty(),
"existing upload slots require an explicit storage migration"
);
}
storage::atomic_write(&identity_path, &serde_json::to_vec(&identity)?)?;
let s = Self {
config: Arc::new(config),
records: Arc::new(Mutex::new(records)),
active: Arc::new(Mutex::new(BTreeSet::new())),
backend,
};
s.prune()?;
// Remote pending records must be reconciled before the abandoned-slot
// timeout is applied. The caller awaits recover_backend before listening.
if s.backend.is_none() {
s.prune()?;
}
Ok(s)
}
/// Recover durable multipart state before enabling listeners or slot requests.
pub async fn recover_backend(&self) -> Result<()> {
if self.backend.is_none() {
return Ok(());
}
let pending: Vec<_> = self
.records
.lock()
.unwrap()
.iter()
.filter(|(_, r)| {
!r.complete || r.upload_id.is_some() || r.multipart_intent || r.completion_attempted
})
.map(|(id, r)| (id.clone(), r.clone()))
.collect();
for (id, record) in pending {
if let Err(error) = self.reconcile_record(id, record).await
&& error.downcast_ref::<CompletionUncertain>().is_none()
{
return Err(error);
}
}
let service = self.clone();
tokio::task::spawn_blocking(move || service.prune()).await??;
Ok(())
}
async fn reconcile_record(&self, id: String, record: Record) -> Result<()> {
let Some(backend) = &self.backend else {
return Ok(());
};
if !record.complete || record.multipart_intent {
// Also cover old journals from before multipart_intent existed.
// This exact durable reservation is exclusively claimed by the
// startup barrier or the active-set lock. Never sweep a bucket.
backend.abort_pending(&id).await?;
}
if let Some(upload_id) = &record.upload_id {
backend.abort(&id, upload_id).await?;
}
// Completion may have committed while abort was in flight. Only the
// post-abort HEAD can establish the current single-use object state.
let head = backend.read(&id, true, None).await?;
let complete = match head.status() {
StatusCode::OK => {
ensure!(
head.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
== Some(record.size),
"S3 recovered object size mismatch"
);
true
}
StatusCode::NOT_FOUND => false,
_ => anyhow::bail!("S3 recovery HEAD failed"),
};
if !complete && record.completion_attempted {
return Err(CompletionUncertain.into());
}
let service = self.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let mut records = service.records.lock().unwrap();
if let Some(r) = records.get_mut(&id)
&& r.upload_id == record.upload_id
&& r.multipart_intent == record.multipart_intent
{
r.complete = complete;
r.upload_id = None;
r.multipart_intent = false;
r.completion_attempted = false;
}
service.persist(&records)
})
.await??;
Ok(())
}
/// Read-only identifiers for unresolved completion attempts. Credentials,
/// bearer tokens and object contents are never included.
pub fn quarantined_slots(&self) -> Vec<String> {
let records = self.records.lock().unwrap();
let active = self.active.lock().unwrap();
records
.iter()
.filter(|(id, r)| r.completion_attempted && !r.complete && !active.contains(*id))
.map(|(id, _)| id.clone())
.collect()
}
fn claim_upload(&self, id: &str) -> std::result::Result<Record, StatusCode> {
// Match prune's lock order, and read all mutable completion state under
// the same records lock used to establish the active claim.
let records = self.records.lock().unwrap();
let record = records.get(id).ok_or(StatusCode::GONE)?;
if record.complete || (self.backend.is_none() && self.file(id).exists()) {
return Err(StatusCode::CONFLICT);
}
if !self.active.lock().unwrap().insert(id.to_owned()) {
return Err(StatusCode::CONFLICT);
}
Ok(record.clone())
}
fn persist(&self, r: &BTreeMap<String, Record>) -> Result<()> {
let mut entries: Vec<_> = r
.iter()
@@ -214,11 +386,41 @@ impl UploadService {
}
pub fn prune(&self) -> Result<()> {
let mut r = self.records.lock().unwrap();
let active = self.active.lock().unwrap();
let mut active = self.active.lock().unwrap();
if self.backend.is_some()
&& let Ok(handle) = tokio::runtime::Handle::try_current()
{
for (id, record) in r
.iter()
.filter(|(id, r)| {
(r.upload_id.is_some() || r.multipart_intent || r.completion_attempted)
&& !active.contains(*id)
})
.map(|(id, r)| (id.clone(), r.clone()))
.collect::<Vec<_>>()
{
active.insert(id.clone());
let service = self.clone();
handle.spawn(async move {
let recovered = service.reconcile_record(id.clone(), record).await.is_ok();
tokio::task::spawn_blocking(move || {
service.active.lock().unwrap().remove(&id);
if recovered {
let _ = service.prune();
}
})
.await
.ok();
});
}
}
let obsolete: Vec<String> = r
.iter()
.filter(|(id, v)| {
!active.contains(*id)
&& v.upload_id.is_none()
&& !v.multipart_intent
&& !v.completion_attempted
&& (self
.config
.expires_after
@@ -230,6 +432,29 @@ impl UploadService {
.map(|(id, _)| id.clone())
.collect();
for id in obsolete {
if let Some(backend) = self.backend.clone() {
// Keep the reservation and metadata until remote deletion succeeds.
// The active claim prevents duplicate workers and concurrent PUTs.
if let Ok(handle) = tokio::runtime::Handle::try_current() {
active.insert(id.clone());
let service = self.clone();
handle.spawn(async move {
let deleted = backend.delete(&id).await.is_ok();
tokio::task::spawn_blocking(move || {
let mut records = service.records.lock().unwrap();
let mut active = service.active.lock().unwrap();
if deleted {
records.remove(&id);
let _ = service.persist(&records);
}
active.remove(&id);
})
.await
.ok();
});
}
continue;
}
match std::fs::remove_file(self.file(&id)) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
@@ -314,6 +539,9 @@ impl UploadService {
created: now(),
token,
complete: false,
upload_id: None,
multipart_intent: false,
completion_attempted: false,
xml: None,
},
);
@@ -358,9 +586,10 @@ impl UploadService {
let config = Arc::downgrade(&self.config);
let records = Arc::downgrade(&self.records);
let active = Arc::downgrade(&self.active);
let backend = self.backend.clone();
handle.spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
let (Some(config), Some(records), Some(active)) =
(config.upgrade(), records.upgrade(), active.upgrade())
else {
@@ -370,6 +599,7 @@ impl UploadService {
config,
records,
active,
backend: backend.clone(),
};
let _ = tokio::task::spawn_blocking(move || service.prune()).await;
}
@@ -380,6 +610,14 @@ impl UploadService {
.with_state(self.clone())
.layer(axum::Extension(UploadPath(prefix)))
}
pub async fn handle_at(&self, req: Request, internal_path: &str) -> Response {
endpoint(
State(self.clone()),
axum::Extension(UploadPath(internal_path.trim_end_matches('/').to_owned())),
req,
)
.await
}
pub async fn serve(&self, listener: TcpListener, tls: Option<TlsAcceptor>) -> Result<()> {
serve_router(listener, tls, self.router()).await
}
@@ -496,6 +734,7 @@ async fn serve_router_inner(
Some(crate::http_transport::HttpRoute::Websocket) => "websocket",
Some(crate::http_transport::HttpRoute::Bosh) => "bosh",
Some(crate::http_transport::HttpRoute::Altconnect) => "altconnect",
Some(crate::http_transport::HttpRoute::Upload) => "upload",
None => default_route,
};
let status = response.status();
@@ -693,20 +932,15 @@ async fn handle_at(s: UploadService, req: Request, prefix: &str) -> Response {
{
return response(StatusCode::PAYLOAD_TOO_LARGE);
}
if record.complete || s.file(id).exists() {
if record.complete || (s.backend.is_none() && s.file(id).exists()) {
return response(StatusCode::CONFLICT);
};
{
// Use the same lock order as prune: a reservation must still exist
// when it becomes active, so pruning cannot orphan an upload.
let records = s.records.lock().unwrap();
if !records.contains_key(id) {
return response(StatusCode::GONE);
}
if !s.active.lock().unwrap().insert(id.into()) {
return response(StatusCode::CONFLICT);
}
}
// Refresh the snapshot atomically with the exclusive claim. A request
// may have waited while an earlier PUT committed or was quarantined.
let record = match s.claim_upload(id) {
Ok(record) => record,
Err(status) => return response(status),
};
let dest = s.file(id);
let temp = dest.with_extension(format!("{}.tmp", uuid::Uuid::new_v4()));
let _guard = UploadGuard {
@@ -714,6 +948,212 @@ async fn handle_at(s: UploadService, req: Request, prefix: &str) -> Response {
id: id.into(),
temp: temp.clone(),
};
if let Some(backend) = &s.backend {
if (record.upload_id.is_some()
|| record.multipart_intent
|| record.completion_attempted)
&& let Err(error) = s.reconcile_record(id.to_owned(), record.clone()).await
{
return response(if error.downcast_ref::<CompletionUncertain>().is_some() {
StatusCode::CONFLICT
} else {
StatusCode::BAD_GATEWAY
});
}
// Reconcile a remote commit whose response or local metadata write
// was interrupted, rather than overwriting a single-use object.
match backend.read(id, true, None).await {
Ok(reply) if reply.status() == StatusCode::NOT_FOUND => {}
Ok(reply) if reply.status() == StatusCode::OK => {
if reply
.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
!= Some(record.size)
{
return response(StatusCode::BAD_GATEWAY);
}
let service = s.clone();
let id = id.to_owned();
return response(
tokio::task::spawn_blocking(move || {
let _guard = _guard;
let mut records = service.records.lock().unwrap();
if let Some(r) = records.get_mut(&id) {
r.complete = true;
r.upload_id = None;
r.multipart_intent = false;
r.completion_attempted = false;
}
if service.persist(&records).is_err() {
StatusCode::INTERNAL_SERVER_ERROR
} else {
StatusCode::CONFLICT
}
})
.await
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
);
}
_ => return response(StatusCode::BAD_GATEWAY),
}
// Hold the single-use claim across initiation and journaling even
// if the HTTP request is cancelled. A persisted intent closes the
// crash window before S3 returns its UploadId.
let service = s.clone();
let backend = backend.clone();
let slot_id = id.to_owned();
let content_type = record.content_type.clone();
let initiation = tokio::spawn(async move {
let guard = _guard;
let journal = service.clone();
let journal_id = slot_id.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let mut records = journal.records.lock().unwrap();
let r = records
.get_mut(&journal_id)
.ok_or_else(|| anyhow::anyhow!("upload reservation missing"))?;
r.multipart_intent = true;
journal.persist(&records)
})
.await??;
let mut upload = backend.begin(&slot_id, &content_type).await?;
let upload_id = upload.upload_id().to_owned();
let cleanup = service.clone();
upload.after_abort(move || {
// HEAD must reconcile a possible remote commit before
// clearing the intent, even when abort returns 404.
tokio::task::spawn_blocking(move || {
let _ = cleanup.prune();
});
});
tokio::task::spawn_blocking(move || -> Result<()> {
let mut records = service.records.lock().unwrap();
let r = records
.get_mut(&slot_id)
.ok_or_else(|| anyhow::anyhow!("upload reservation missing"))?;
r.upload_id = Some(upload_id);
service.persist(&records)
})
.await??;
Ok::<_, anyhow::Error>((upload, guard))
});
let (mut upload, _guard) = match initiation.await {
Ok(Ok(value)) => value,
_ => return response(StatusCode::BAD_GATEWAY),
};
let mut body = req.into_body();
let mut count = 0u64;
let mut part = Vec::with_capacity(PART_SIZE);
loop {
let frame =
match tokio::time::timeout(std::time::Duration::from_secs(120), body.frame())
.await
{
Ok(Some(Ok(frame))) => frame,
Ok(Some(Err(_))) => return response(StatusCode::BAD_REQUEST),
Ok(None) => break,
Err(_) => return response(StatusCode::REQUEST_TIMEOUT),
};
if let Ok(bytes) = frame.into_data() {
count = count.saturating_add(bytes.len() as u64);
if count > record.size {
return response(StatusCode::PAYLOAD_TOO_LARGE);
}
let mut bytes = bytes.as_ref();
while !bytes.is_empty() {
let take = (PART_SIZE - part.len()).min(bytes.len());
part.extend_from_slice(&bytes[..take]);
bytes = &bytes[take..];
if part.len() == PART_SIZE
&& upload
.part(std::mem::replace(&mut part, Vec::with_capacity(PART_SIZE)))
.await
.is_err()
{
return response(StatusCode::BAD_GATEWAY);
}
}
}
}
if count != record.size {
return response(StatusCode::BAD_REQUEST);
}
if (!part.is_empty() || count == 0) && upload.part(part).await.is_err() {
return response(StatusCode::BAD_GATEWAY);
}
// Once completing begins, finish the durable commit even if the
// HTTP client disconnects. Keep the single-use claim until then.
let service = s.clone();
let id = id.to_owned();
let commit = tokio::spawn(async move {
let _guard = _guard;
let journal = service.clone();
let journal_id = id.clone();
if !matches!(
tokio::task::spawn_blocking(move || -> Result<()> {
let mut records = journal.records.lock().unwrap();
let r = records
.get_mut(&journal_id)
.ok_or_else(|| anyhow::anyhow!("upload reservation missing"))?;
r.completion_attempted = true;
journal.persist(&records)
})
.await,
Ok(Ok(()))
) {
return StatusCode::INTERNAL_SERVER_ERROR;
}
if let Err(error) = upload.complete().await {
if error
.downcast_ref::<crate::object_store::CompletionRejected>()
.is_some()
{
// Explicit terminal rejection is retryable after HEAD
// reconciliation. Network/XML uncertainty stays frozen.
let journal = service.clone();
let journal_id = id.clone();
let _ = tokio::task::spawn_blocking(move || -> Result<()> {
let mut records = journal.records.lock().unwrap();
if let Some(r) = records.get_mut(&journal_id) {
r.completion_attempted = false;
}
journal.persist(&records)
})
.await;
}
// Multipart::drop may finish its abort while this commit
// still owns the active claim. Its callback's prune then
// correctly skips the slot, so it cannot be our only
// reconciliation trigger. Reconcile under this same claim
// before returning the ambiguous completion response.
let pending = service.records.lock().unwrap().get(&id).cloned();
if let Some(record) = pending {
let _ = service.reconcile_record(id.clone(), record).await;
}
return StatusCode::BAD_GATEWAY;
}
tokio::task::spawn_blocking(move || {
let _guard = _guard;
let mut records = service.records.lock().unwrap();
if let Some(r) = records.get_mut(&id) {
r.complete = true;
r.upload_id = None;
r.multipart_intent = false;
r.completion_attempted = false;
}
if service.persist(&records).is_err() {
StatusCode::INTERNAL_SERVER_ERROR
} else {
StatusCode::CREATED
}
})
.await
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
});
return response(commit.await.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR));
}
if tokio::fs::create_dir_all(dest.parent().unwrap())
.await
.is_err()
@@ -769,36 +1209,78 @@ async fn handle_at(s: UploadService, req: Request, prefix: &str) -> Response {
return response(StatusCode::INTERNAL_SERVER_ERROR);
}
}
let mut records = s.records.lock().unwrap();
if let Some(r) = records.get_mut(id) {
r.complete = true;
}
if s.persist(&records).is_err() {
return response(StatusCode::INTERNAL_SERVER_ERROR);
};
return response(StatusCode::CREATED);
let id = id.to_owned();
return response(
tokio::task::spawn_blocking(move || {
let _guard = _guard;
let mut records = s.records.lock().unwrap();
if let Some(r) = records.get_mut(&id) {
r.complete = true;
}
if s.persist(&records).is_err() {
StatusCode::INTERNAL_SERVER_ERROR
} else {
StatusCode::CREATED
}
})
.await
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
);
}
if req.method() != "GET" && req.method() != "HEAD" {
return response(StatusCode::METHOD_NOT_ALLOWED);
}
let Ok(mut file) = tokio::fs::File::open(s.file(id)).await else {
if s.backend.is_some() && !record.complete {
return response(StatusCode::GONE);
}
let mut local_file = if s.backend.is_none() {
match tokio::fs::File::open(s.file(id)).await {
Ok(file) => Some(file),
Err(_) => return response(StatusCode::GONE),
}
} else {
None
};
let modified = chrono::DateTime::from_timestamp(record.created as i64, 0)
.map(|d| d.format("%a, %d %b %Y %H:%M:%S GMT").to_string())
.unwrap_or_default();
if req
.headers()
.get("if-modified-since")
.and_then(|v| v.to_str().ok())
== Some(modified.as_str())
{
return response(StatusCode::NOT_MODIFIED);
let etag = format!("\"{id}-{}\"", record.size);
let date_matches = |value: &str| {
chrono::DateTime::parse_from_rfc2822(value)
.ok()
.is_some_and(|d| d.timestamp() >= record.created as i64)
};
let not_modified = if let Some(value) = req.headers().get("if-none-match") {
value.to_str().ok().is_some_and(|v| {
v.split(',').any(|candidate| {
let candidate = candidate.trim();
candidate == "*" || candidate.strip_prefix("W/").unwrap_or(candidate) == etag
})
})
} else {
req.headers()
.get("if-modified-since")
.and_then(|v| v.to_str().ok())
.is_some_and(date_matches)
};
if not_modified {
return Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header("etag", &etag)
.header("last-modified", &modified)
.body(Body::empty())
.unwrap();
}
let range_allowed = req.headers().get("if-range").is_none_or(|value| {
value
.to_str()
.ok()
.is_some_and(|v| v == etag || date_matches(v))
});
let mut status = StatusCode::OK;
let mut start = 0;
let mut end = record.size.saturating_sub(1);
if let Some(range) = req.headers().get("range") {
if let Some(range) = req.headers().get("range").filter(|_| range_allowed) {
let parsed = range
.to_str()
.ok()
@@ -833,14 +1315,50 @@ async fn handle_at(s: UploadService, req: Request, prefix: &str) -> Response {
status = StatusCode::PARTIAL_CONTENT;
}
let length = if record.size == 0 { 0 } else { end - start + 1 };
if file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
return response(StatusCode::INTERNAL_SERVER_ERROR);
};
use tokio::io::AsyncReadExt;
let body = if req.method() == "HEAD" {
Body::empty()
let body = if let Some(backend) = &s.backend {
let range = (status == StatusCode::PARTIAL_CONTENT).then(|| format!("bytes={start}-{end}"));
let Ok(reply) = backend
.read(id, req.method() == "HEAD", range.as_deref())
.await
else {
return response(StatusCode::BAD_GATEWAY);
};
if reply.status() == StatusCode::NOT_FOUND {
return response(StatusCode::GONE);
}
if reply.status() != status {
return response(StatusCode::BAD_GATEWAY);
}
if reply
.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
!= Some(length)
{
return response(StatusCode::BAD_GATEWAY);
}
if status == StatusCode::PARTIAL_CONTENT
&& reply
.headers()
.get("content-range")
.and_then(|v| v.to_str().ok())
!= Some(format!("bytes {start}-{end}/{}", record.size).as_str())
{
return response(StatusCode::BAD_GATEWAY);
}
reply.into_body()
} else {
Body::from_stream(ReaderStream::new(file.take(length)))
let mut file = local_file.take().unwrap();
if file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
return response(StatusCode::INTERNAL_SERVER_ERROR);
}
if req.method() == "HEAD" {
Body::empty()
} else {
Body::from_stream(ReaderStream::new(file.take(length)))
}
};
let disposition = if record.content_type == "text/plain"
|| ["image/", "video/", "audio/"]
@@ -869,6 +1387,7 @@ async fn handle_at(s: UploadService, req: Request, prefix: &str) -> Response {
)
.header("access-control-allow-origin", "*")
.header("accept-ranges", "bytes")
.header("etag", etag)
.header("last-modified", modified)
.header("cache-control", "max-age=31556952, immutable")
.header("x-frame-options", "DENY")
@@ -991,6 +1510,60 @@ mod tests {
);
}
#[test]
fn upload_claim_refreshes_completion_state_after_an_earlier_snapshot() {
let temp = tempfile::tempdir().unwrap();
let service = UploadService::new(UploadConfig {
root: temp.path().into(),
host: "upload.example.test".into(),
base_url: "https://upload.example.test/file_share".into(),
size_limit: 1,
daily_quota: 3,
global_quota: 3,
expires_after: None,
allowed_types: vec![],
secret: None,
})
.unwrap();
service
.request_slot("a@example.test", "pending", 1, "text/plain")
.unwrap();
let (id, stale) = service
.records
.lock()
.unwrap()
.iter()
.next()
.map(|(id, r)| (id.clone(), r.clone()))
.unwrap();
assert!(!stale.completion_attempted);
// Another PUT finishes ambiguously after this request has read its
// initial snapshot, but before it acquires the active claim.
service
.records
.lock()
.unwrap()
.get_mut(&id)
.unwrap()
.completion_attempted = true;
let fresh = service.claim_upload(&id).unwrap();
assert!(fresh.completion_attempted);
assert!(service.active.lock().unwrap().contains(&id));
service.active.lock().unwrap().remove(&id);
service
.records
.lock()
.unwrap()
.get_mut(&id)
.unwrap()
.complete = true;
assert!(matches!(
service.claim_upload(&id),
Err(StatusCode::CONFLICT)
));
assert!(!service.active.lock().unwrap().contains(&id));
}
#[test]
fn abandoned_slots_release_quota_without_pruning_existing_or_active_files() {
let temp = tempfile::tempdir().unwrap();
+1
View File
@@ -30,6 +30,7 @@ pub enum HttpRoute {
Websocket,
Bosh,
Altconnect,
Upload,
}
#[derive(Clone)]
+4
View File
@@ -47,3 +47,7 @@ pub mod muc_recovery;
pub mod muc_history;
pub mod http_altconnect;
pub mod object_store;
pub mod upload_migration;
+14 -1
View File
@@ -12,6 +12,19 @@ async fn main() {
async fn run() -> Result<()> {
let args: Vec<_> = env::args().skip(1).collect();
match args.first().map(String::as_str) {
Some("upload-migrate") if args.len() == 5 || args.len() == 6 => {
anyhow::ensure!(
args[3] == "--component" && (args.len() == 5 || args[5] == "--execute"),
"usage: upload-migrate OLD_CONFIG NEW_CONFIG --component HOST [--execute]"
);
rust_xmpp::upload_migration::migrate(
Path::new(&args[1]),
Path::new(&args[2]),
&args[4],
args.len() == 6,
)
.await?;
}
Some("check") => {
let path = args
.get(1)
@@ -108,7 +121,7 @@ async fn run() -> Result<()> {
println!("Account created: {}@{}", args[2], domain);
}
Some("--help") | None => println!(
"rust-xmpp check-config PATH\nrust-xmpp convert PATH json|yaml|toml OUTPUT\nrust-xmpp --version\n\nExperimental runtime: serve CONFIG --experimental\ncreate-user CONFIG USER --password-stdin\ncheck [CONFIG] (running server healthcheck)\nshell CONFIG COMMAND\n\nSTARTTLS, SCRAM/PLAIN, local messaging, roster, MAM, MUC, PEP/pubsub, stream management, push, federation and HTTP transports are implemented with limitations; see docs/COMPATIBILITY.md."
"rust-xmpp upload-migrate OLD_CONFIG NEW_CONFIG --component HOST [--execute]\nrust-xmpp check-config PATH\nrust-xmpp convert PATH json|yaml|toml OUTPUT\nrust-xmpp --version\n\nExperimental runtime: serve CONFIG --experimental\ncreate-user CONFIG USER --password-stdin\ncheck [CONFIG] (running server healthcheck)\nshell CONFIG COMMAND\n\nSTARTTLS, SCRAM/PLAIN, local messaging, roster, MAM, MUC, PEP/pubsub, stream management, push, federation and HTTP transports are implemented with limitations; see docs/COMPATIBILITY.md."
),
_ => bail!("invalid command; use --help"),
}
+605
View File
@@ -0,0 +1,605 @@
//! Path-style S3-compatible storage with authenticated, bounded multipart writes.
use anyhow::{Result, ensure};
use axum::{
body::{Body, Bytes},
http::{Request, Response, Uri},
};
use hmac::{Hmac, Mac};
use http_body_util::{BodyExt, Full, Limited};
use sha2::{Digest, Sha256};
use std::{collections::BTreeMap, sync::Arc, time::Duration};
use tokio::{
io::{AsyncRead, AsyncWrite},
net::TcpStream,
};
use tokio_rustls::{
TlsConnector,
rustls::{ClientConfig, pki_types::ServerName},
};
#[derive(Clone, Default)]
pub enum UploadBackendConfig {
#[default]
Local,
S3(S3Config),
}
// Deliberately no Debug implementation: credentials must never enter diagnostics.
#[derive(Clone)]
pub struct S3Config {
pub endpoint: String,
pub bucket: String,
pub region: String,
pub access_key: String,
pub secret_key: String,
pub session_token: Option<String>,
pub key_prefix: String,
}
#[derive(Clone)]
pub struct ObjectStore {
config: S3Config,
endpoint: Uri,
tls: Option<Arc<ClientConfig>>,
uploads: Arc<tokio::sync::Semaphore>,
}
trait Transport: AsyncRead + AsyncWrite + Unpin + Send {}
impl<T: AsyncRead + AsyncWrite + Unpin + Send> Transport for T {}
fn encode(s: &str) -> String {
s.bytes()
.map(|b| {
if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
(b as char).to_string()
} else {
format!("%{b:02X}")
}
})
.collect()
}
fn decode_key(value: &str) -> Result<String> {
let mut bytes = value.bytes();
let mut decoded = Vec::with_capacity(value.len());
while let Some(byte) = bytes.next() {
if byte == b'%' {
let high = bytes.next().and_then(|v| (v as char).to_digit(16));
let low = bytes.next().and_then(|v| (v as char).to_digit(16));
match (high, low) {
(Some(h), Some(l)) => decoded.push((h * 16 + l) as u8),
_ => anyhow::bail!("invalid S3 listing key encoding"),
}
} else {
decoded.push(byte);
}
}
Ok(String::from_utf8(decoded)?)
}
fn mac(key: &[u8], data: &str) -> Vec<u8> {
let mut h = Hmac::<Sha256>::new_from_slice(key).unwrap();
h.update(data.as_bytes());
h.finalize().into_bytes().to_vec()
}
impl ObjectStore {
pub fn new(config: S3Config) -> Result<Self> {
let endpoint: Uri = config
.endpoint
.parse()
.map_err(|_| anyhow::anyhow!("invalid S3 endpoint"))?;
let scheme = endpoint.scheme_str().unwrap_or("");
let host = endpoint.host().unwrap_or("").trim_matches(['[', ']']);
ensure!(
!host.is_empty()
&& endpoint.query().is_none()
&& crate::http_config::valid_authority(endpoint.authority().unwrap())
&& !config.endpoint.contains('#')
&& !endpoint.path().contains('%')
&& !endpoint.path().split('/').any(|p| p == "." || p == ".."),
"invalid S3 endpoint"
);
ensure!(
scheme == "https"
|| (scheme == "http"
&& (host == "localhost"
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|a| a.is_loopback()))),
"S3 endpoint requires HTTPS (HTTP is allowed only for loopback)"
);
ensure!(
!config.bucket.is_empty()
&& config
.bucket
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b".-_".contains(&b)),
"invalid S3 bucket"
);
ensure!(
!config.region.is_empty()
&& !config.access_key.is_empty()
&& !config.secret_key.is_empty(),
"S3 credentials and region are required"
);
let tls = if scheme == "https" {
let roots = crate::federation::Federation::native_roots()?;
Some(Arc::new(
ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth(),
))
} else {
None
};
Ok(Self {
config,
endpoint,
tls,
uploads: Arc::new(tokio::sync::Semaphore::new(8)),
})
}
pub fn identity(&self) -> serde_json::Value {
serde_json::json!({"backend":"s3", "endpoint":self.config.endpoint.trim_end_matches('/'), "bucket":self.config.bucket, "prefix":self.config.key_prefix.trim_matches('/')})
}
fn path(&self, id: &str) -> String {
let key = [self.config.key_prefix.trim_matches('/'), id]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("/");
format!(
"{}/{}/{}",
self.endpoint.path().trim_end_matches('/'),
encode(&self.config.bucket),
key.split('/').map(encode).collect::<Vec<_>>().join("/")
)
}
async fn request(
&self,
method: &str,
id: &str,
query: &[(&str, &str)],
body: Bytes,
extra: &[(&str, &str)],
) -> Result<Response<hyper::body::Incoming>> {
// A deadline includes DNS, TLS, request transmission and response headers.
tokio::time::timeout(
Duration::from_secs(120),
self.request_inner(method, self.path(id), query, body, extra),
)
.await
.map_err(|_| anyhow::anyhow!("S3 request timed out"))?
}
async fn request_inner(
&self,
method: &str,
path: String,
query: &[(&str, &str)],
body: Bytes,
extra: &[(&str, &str)],
) -> Result<Response<hyper::body::Incoming>> {
let host = self.endpoint.host().unwrap().trim_matches(['[', ']']);
let authority = self.endpoint.authority().unwrap().as_str();
let port = self
.endpoint
.port_u16()
.unwrap_or(if self.tls.is_some() { 443 } else { 80 });
let mut query: Vec<_> = query.iter().map(|(k, v)| (encode(k), encode(v))).collect();
query.sort();
let query = query
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("&");
let uri = if query.is_empty() {
path.clone()
} else {
format!("{path}?{query}")
};
let date = chrono::Utc::now();
let stamp = date.format("%Y%m%dT%H%M%SZ").to_string();
let day = date.format("%Y%m%d").to_string();
let hash = hex::encode(Sha256::digest(&body));
let mut headers = BTreeMap::from([
("host".to_string(), authority.to_string()),
("x-amz-content-sha256".into(), hash.clone()),
("x-amz-date".into(), stamp.clone()),
]);
if let Some(token) = &self.config.session_token {
headers.insert("x-amz-security-token".into(), token.clone());
}
let signed = headers.keys().cloned().collect::<Vec<_>>().join(";");
let canonical_headers = headers
.iter()
.map(|(k, v)| {
format!(
"{k}:{}\n",
v.split_whitespace().collect::<Vec<_>>().join(" ")
)
})
.collect::<String>();
let canonical = format!("{method}\n{path}\n{query}\n{canonical_headers}\n{signed}\n{hash}");
let scope = format!("{day}/{}/s3/aws4_request", self.config.region);
let to_sign = format!(
"AWS4-HMAC-SHA256\n{stamp}\n{scope}\n{}",
hex::encode(Sha256::digest(canonical.as_bytes()))
);
let key = mac(format!("AWS4{}", self.config.secret_key).as_bytes(), &day);
let key = mac(&key, &self.config.region);
let key = mac(&key, "s3");
let key = mac(&key, "aws4_request");
let auth = format!(
"AWS4-HMAC-SHA256 Credential={}/{scope}, SignedHeaders={signed}, Signature={}",
self.config.access_key,
hex::encode(mac(&key, &to_sign))
);
let mut req = Request::builder()
.method(method)
.uri(uri)
.header("authorization", auth)
.header("content-length", body.len());
for (k, v) in &headers {
req = req.header(k, v);
}
for (k, v) in extra {
req = req.header(*k, *v);
}
let req = req.body(Full::new(body))?;
let tcp = TcpStream::connect((host, port)).await?;
let stream: Box<dyn Transport> = if let Some(tls) = &self.tls {
Box::new(
TlsConnector::from(tls.clone())
.connect(ServerName::try_from(host.to_string())?, tcp)
.await?,
)
} else {
Box::new(tcp)
};
let (mut sender, conn) =
hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream)).await?;
tokio::spawn(async move {
let _ = conn.await;
});
Ok(sender.send_request(req).await?)
}
async fn xml(response: Response<hyper::body::Incoming>) -> Result<String> {
ensure!(response.status().is_success(), "S3 operation failed");
Self::xml_unchecked(response).await
}
async fn xml_unchecked(response: Response<hyper::body::Incoming>) -> Result<String> {
let bytes = tokio::time::timeout(
Duration::from_secs(120),
Limited::new(response.into_body(), 1024 * 1024).collect(),
)
.await
.map_err(|_| anyhow::anyhow!("S3 response timed out"))?
.map_err(|_| anyhow::anyhow!("invalid or oversized S3 response"))?
.to_bytes();
Ok(String::from_utf8(bytes.to_vec())?)
}
pub async fn begin(&self, id: &str, content_type: &str) -> Result<Multipart> {
// Shield initiation: cancellation must still consume UploadId and abort it.
let store = self.clone();
let id = id.to_owned();
let content_type = content_type.to_owned();
tokio::spawn(async move { store.begin_inner(&id, &content_type).await }).await?
}
async fn begin_inner(&self, id: &str, content_type: &str) -> Result<Multipart> {
let permit = self
.uploads
.clone()
.try_acquire_owned()
.map_err(|_| anyhow::anyhow!("S3 uploads busy"))?;
let r = self
.request(
"POST",
id,
&[("uploads", "")],
Bytes::new(),
&[("content-type", content_type)],
)
.await?;
let xml = Self::xml(r).await?;
let doc = roxmltree::Document::parse(&xml)?;
let upload_id = doc
.descendants()
.find(|n| n.has_tag_name("UploadId"))
.and_then(|n| n.text())
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("S3 upload identifier missing"))?
.to_string();
Ok(Multipart {
store: self.clone(),
id: id.into(),
upload_id,
etags: Vec::new(),
finished: false,
after_abort: None,
_permit: permit,
})
}
pub async fn read(&self, id: &str, head: bool, range: Option<&str>) -> Result<Response<Body>> {
let extra: Vec<_> = range.map(|r| vec![("range", r)]).unwrap_or_default();
let r = self
.request(
if head { "HEAD" } else { "GET" },
id,
&[],
Bytes::new(),
&extra,
)
.await?;
let (parts, body) = r.into_parts();
Ok(Response::from_parts(
parts,
if head { Body::empty() } else { Body::new(body) },
))
}
/// Abort only multiparts for an exclusively owned, durable slot key. The
/// caller must exclude concurrent initiation for this key throughout recovery.
/// Listing a prefix alone does not establish ownership: compare the full key.
pub async fn abort_pending(&self, id: &str) -> Result<()> {
let key = [self.config.key_prefix.trim_matches('/'), id]
.into_iter()
.filter(|v| !v.is_empty())
.collect::<Vec<_>>()
.join("/");
let bucket_path = format!(
"{}/{}",
self.endpoint.path().trim_end_matches('/'),
encode(&self.config.bucket)
);
let mut markers: Option<(String, String)> = None;
let mut seen = std::collections::BTreeSet::new();
loop {
let mut query = vec![
("uploads", ""),
("prefix", key.as_str()),
("max-uploads", "1000"),
("encoding-type", "url"),
];
if let Some((key_marker, upload_marker)) = &markers {
query.push(("key-marker", key_marker));
query.push(("upload-id-marker", upload_marker));
}
let response = tokio::time::timeout(
Duration::from_secs(120),
self.request_inner("GET", bucket_path.clone(), &query, Bytes::new(), &[]),
)
.await
.map_err(|_| anyhow::anyhow!("S3 listing timed out"))??;
let xml = Self::xml(response).await?;
let doc = roxmltree::Document::parse(&xml)?;
let root = doc.root_element();
ensure!(
root.has_tag_name("ListMultipartUploadsResult"),
"invalid S3 multipart listing"
);
let field = |node: roxmltree::Node<'_, '_>, name: &str| {
node.children()
.find(|n| n.has_tag_name(name))
.and_then(|n| n.text())
.map(str::to_owned)
};
for upload in root.children().filter(|n| n.has_tag_name("Upload")) {
let listed_key = field(upload, "Key")
.ok_or_else(|| anyhow::anyhow!("S3 listing key missing"))?;
let upload_id = field(upload, "UploadId")
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("S3 listing upload identifier missing"))?;
if decode_key(&listed_key)? == key {
self.abort(id, &upload_id).await?;
}
}
match field(root, "IsTruncated").as_deref() {
Some("false") => break,
Some("true") => {
let next = (
decode_key(
&field(root, "NextKeyMarker")
.ok_or_else(|| anyhow::anyhow!("S3 listing marker missing"))?,
)?,
field(root, "NextUploadIdMarker")
.ok_or_else(|| anyhow::anyhow!("S3 listing upload marker missing"))?,
);
ensure!(
!next.0.is_empty()
&& !next.1.is_empty()
&& markers.as_ref().is_none_or(|previous| &next > previous)
&& seen.insert(next.clone()),
"S3 listing did not advance"
);
markers = Some(next);
}
_ => anyhow::bail!("invalid S3 listing truncation flag"),
}
}
Ok(())
}
/// HEAD with an explicit distinction between absence and service errors.
pub async fn stat(&self, id: &str) -> Result<Option<u64>> {
let reply = self.read(id, true, None).await?;
if reply.status() == 404 {
return Ok(None);
}
ensure!(reply.status() == 200, "S3 stat failed");
let size = reply
.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
.ok_or_else(|| anyhow::anyhow!("S3 object size missing"))?;
Ok(Some(size))
}
/// Bounded-memory streaming upload. Callers journal ownership before calling
/// this method and retain it until commit, allowing abort_pending after crash.
pub async fn write_stream(
&self,
id: &str,
content_type: &str,
mut body: Body,
expected_size: u64,
) -> Result<()> {
let mut upload = self.begin(id, content_type).await?;
let mut count = 0u64;
let mut part = Vec::with_capacity(PART_SIZE);
while let Some(frame) = tokio::time::timeout(Duration::from_secs(120), body.frame())
.await
.map_err(|_| anyhow::anyhow!("S3 source timed out"))?
{
let frame = frame.map_err(|_| anyhow::anyhow!("S3 source read failed"))?;
if let Ok(bytes) = frame.into_data() {
count = count
.checked_add(bytes.len() as u64)
.ok_or_else(|| anyhow::anyhow!("S3 source too large"))?;
ensure!(count <= expected_size, "S3 source too large");
let mut bytes = bytes.as_ref();
while !bytes.is_empty() {
let take = (PART_SIZE - part.len()).min(bytes.len());
part.extend_from_slice(&bytes[..take]);
bytes = &bytes[take..];
if part.len() == PART_SIZE {
upload
.part(std::mem::replace(&mut part, Vec::with_capacity(PART_SIZE)))
.await?;
}
}
}
}
ensure!(count == expected_size, "S3 source size mismatch");
if !part.is_empty() || count == 0 {
upload.part(part).await?;
}
upload.complete().await
}
pub async fn copy_from(
&self,
source: &ObjectStore,
id: &str,
content_type: &str,
expected_size: u64,
) -> Result<()> {
let reply = source.read(id, false, None).await?;
ensure!(reply.status() == 200, "S3 copy source unavailable");
self.write_stream(id, content_type, reply.into_body(), expected_size)
.await
}
pub async fn abort(&self, id: &str, upload_id: &str) -> Result<()> {
let r = self
.request("DELETE", id, &[("uploadId", upload_id)], Bytes::new(), &[])
.await?;
ensure!(
r.status().is_success() || r.status() == 404,
"S3 multipart abort failed"
);
Ok(())
}
pub async fn delete(&self, id: &str) -> Result<()> {
let r = self.request("DELETE", id, &[], Bytes::new(), &[]).await?;
ensure!(
r.status().is_success() || r.status() == 404,
"S3 deletion failed"
);
Ok(())
}
}
/// A well-formed terminal rejection from CompleteMultipartUpload. Transport,
/// timeout and malformed response errors deliberately do not carry this marker.
#[derive(Debug)]
pub struct CompletionRejected;
impl std::fmt::Display for CompletionRejected {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("S3 completion rejected")
}
}
impl std::error::Error for CompletionRejected {}
pub const PART_SIZE: usize = 8 * 1024 * 1024;
pub struct Multipart {
store: ObjectStore,
id: String,
upload_id: String,
etags: Vec<String>,
finished: bool,
after_abort: Option<Box<dyn FnOnce() + Send>>,
_permit: tokio::sync::OwnedSemaphorePermit,
}
impl Multipart {
pub fn upload_id(&self) -> &str {
&self.upload_id
}
pub fn after_abort(&mut self, callback: impl FnOnce() + Send + 'static) {
self.after_abort = Some(Box::new(callback));
}
pub async fn part(&mut self, data: Vec<u8>) -> Result<()> {
ensure!(self.etags.len() < 10000, "S3 part limit exceeded");
let number = (self.etags.len() + 1).to_string();
let r = self
.store
.request(
"PUT",
&self.id,
&[("partNumber", &number), ("uploadId", &self.upload_id)],
Bytes::from(data),
&[],
)
.await?;
ensure!(r.status().is_success(), "S3 part failed");
let etag = r
.headers()
.get("etag")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| anyhow::anyhow!("S3 part ETag missing"))?
.to_string();
self.etags.push(etag);
Ok(())
}
pub async fn complete(mut self) -> Result<()> {
let mut xml = "<CompleteMultipartUpload>".to_string();
for (i, etag) in self.etags.iter().enumerate() {
xml.push_str(&format!(
"<Part><PartNumber>{}</PartNumber><ETag>{}</ETag></Part>",
i + 1,
crate::wire::escape(etag)
));
}
xml.push_str("</CompleteMultipartUpload>");
let r = self
.store
.request(
"POST",
&self.id,
&[("uploadId", &self.upload_id)],
Bytes::from(xml),
&[("content-type", "application/xml"), ("if-none-match", "*")],
)
.await?;
let successful = r.status().is_success();
let xml = ObjectStore::xml_unchecked(r).await?;
let doc = roxmltree::Document::parse(&xml)?;
if doc.root_element().has_tag_name("Error") {
return Err(CompletionRejected.into());
}
ensure!(successful, "S3 completion failed");
ensure!(
doc.root_element()
.has_tag_name("CompleteMultipartUploadResult"),
"S3 completion failed"
);
self.finished = true;
Ok(())
}
}
impl Drop for Multipart {
fn drop(&mut self) {
if !self.finished {
let store = self.store.clone();
let id = self.id.clone();
let upload_id = self.upload_id.clone();
let after_abort = self.after_abort.take();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
handle.spawn(async move {
if store.abort(&id, &upload_id).await.is_ok()
&& let Some(callback) = after_abort
{
callback();
}
});
}
}
}
}
+156 -32
View File
@@ -49,6 +49,136 @@ pub struct Runtime {
federation: Option<crate::federation::Federation>,
stopping: Arc<std::sync::atomic::AtomicBool>,
}
/// Keep upload services separate: their HTTP hosts, mounts and credentials belong
/// to the component, while all listeners share one dispatcher.
#[derive(Clone)]
struct UploadRoute {
name: String,
config: crate::http_config::HttpConfig,
service: crate::http_service::UploadService,
}
pub(crate) fn upload_backend(
config: &Config,
name: &str,
) -> Result<crate::object_store::UploadBackendConfig> {
use crate::object_store::{S3Config, UploadBackendConfig};
let backend = config
.option(name, "http_file_share_backend")
.map(|value| {
value
.as_str()
.context("http_file_share_backend must be a string")
})
.transpose()?
.unwrap_or("local");
match backend {
"local" => Ok(UploadBackendConfig::Local),
"s3" => {
let options = config
.option(name, "http_file_share_s3")
.and_then(|value| value.as_object())
.context("http_file_share_s3 must be a table for S3 uploads")?;
let field = |key: &str| -> Result<Option<String>> {
if let Some(value) = options.get(&format!("{key}_env")) {
let variable = value.as_str().with_context(|| {
format!("http_file_share_s3.{key}_env must be a string")
})?;
return Ok(Some(std::env::var(variable).with_context(|| {
format!("environment variable for http_file_share_s3.{key} is unavailable")
})?));
}
options
.get(key)
.map(|value| {
value
.as_str()
.map(str::to_owned)
.with_context(|| format!("http_file_share_s3.{key} must be a string"))
})
.transpose()
};
let required = |key: &str| {
field(key)?
.filter(|value| !value.is_empty())
.with_context(|| format!("http_file_share_s3.{key} is required"))
};
Ok(UploadBackendConfig::S3(S3Config {
endpoint: required("endpoint")?,
bucket: required("bucket")?,
region: required("region")?,
access_key: required("access_key")?,
secret_key: required("secret_key")?,
session_token: field("session_token")?,
key_prefix: field("key_prefix")?.unwrap_or_default(),
}))
}
other => bail!("unsupported http_file_share_backend: {other}"),
}
}
fn upload_mount_matches(mount: &str, path: &str) -> bool {
mount == "/"
|| path == mount
|| path
.strip_prefix(mount)
.is_some_and(|rest| rest.starts_with('/'))
}
fn upload_routes(
config: &Config,
uploads: &BTreeMap<String, crate::http_service::UploadService>,
) -> Result<Arc<Vec<UploadRoute>>> {
let mut routes: Vec<UploadRoute> = Vec::new();
for (name, service) in uploads {
let http = crate::http_config::HttpConfig::for_host(config, name)?;
for existing in &routes {
ensure!(
existing.config.host != http.host
|| !(upload_mount_matches(
&existing.config.file_share_path,
&http.file_share_path
) || upload_mount_matches(
&http.file_share_path,
&existing.config.file_share_path
)),
"ambiguous HTTP upload components '{}' and '{}': HTTP host '{}' has overlapping mounts '{}' and '{}'",
existing.name,
name,
http.host,
existing.config.file_share_path,
http.file_share_path
);
}
routes.push(UploadRoute {
name: name.clone(),
config: http,
service: service.clone(),
});
}
Ok(Arc::new(routes))
}
async fn dispatch_upload(
routes: Arc<Vec<UploadRoute>>,
request: axum::extract::Request,
) -> axum::response::Response {
use axum::response::IntoResponse;
let host = request
.headers()
.get(axum::http::header::HOST)
.and_then(|value| value.to_str().ok());
if let Some(route) = routes.iter().find(|route| {
host.is_some_and(|host| route.config.accepts_host(host))
&& upload_mount_matches(&route.config.file_share_path, request.uri().path())
}) {
let mut response = route
.service
.handle_at(request, &route.config.file_share_path)
.await;
response
.extensions_mut()
.insert(crate::http_transport::HttpRoute::Upload);
return response;
}
axum::http::StatusCode::NOT_FOUND.into_response()
}
struct ConnectionLog {
logger: Logger,
id: String,
@@ -557,6 +687,7 @@ pub async fn serve(path: &Path) -> Result<()> {
let (data, _, hash) = account_config(path)?;
let data_guard = Arc::new(crate::data_lock::DataDirectoryGuard::acquire(&data)?);
crate::upload_migration::ensure_no_pending(&data)?;
let ssl = c.option(&domain, "ssl").context("ssl required")?;
let cert = ssl
.get("certificate")
@@ -630,8 +761,8 @@ pub async fn serve(path: &Path) -> Result<()> {
.and_then(|v| v.as_str())
.map(|s| s.trim_end_matches('/').to_owned())
.unwrap_or(base);
let service =
crate::http_service::UploadService::new(crate::http_service::UploadConfig {
let service = crate::http_service::UploadService::new_with_backend(
crate::http_service::UploadConfig {
root: data.clone(),
host: name.clone(),
base_url: base,
@@ -667,17 +798,30 @@ pub async fn serve(path: &Path) -> Result<()> {
.option(name, "http_file_share_secret")
.and_then(|v| v.as_str())
.map(str::to_owned),
})?;
},
upload_backend(&c, name)?,
)?;
service.recover_backend().await?;
for slot in service.quarantined_slots() {
logger.event(
Level::Warn,
"upload",
None,
"completion-quarantined",
&[
("component", name),
("slot", &slot),
("reason", "remote-completion-outcome-uncertain"),
],
);
}
uploads.insert(name.clone(), service);
}
Some("pubsub") | None => {}
Some(other) => bail!("component not integrated: {other}"),
}
}
ensure!(
uploads.len() <= 1,
"multiple HTTP upload components not yet integrated"
);
let upload_routes = upload_routes(&c, &uploads)?;
let runtime = Runtime {
_data_guard: data_guard,
domain: domain.clone(),
@@ -892,31 +1036,11 @@ pub async fn serve(path: &Path) -> Result<()> {
})
};
let http_config = crate::http_config::HttpConfig::for_host(&c, &domain)?;
let fallback = if let Some((name, service)) = runtime.uploads.iter().next() {
let config = crate::http_config::HttpConfig::for_host(&c, name)?;
service
.router_at(&config.file_share_path)?
.layer(axum::middleware::from_fn(
move |req: axum::extract::Request, next: axum::middleware::Next| {
let accepted = req
.headers()
.get(axum::http::header::HOST)
.and_then(|v| v.to_str().ok())
.is_some_and(|h| config.accepts_host(h));
async move {
if accepted {
next.run(req).await
} else {
axum::response::IntoResponse::into_response(
axum::http::StatusCode::NOT_FOUND,
)
}
}
},
))
} else {
axum::Router::new()
};
let routes = upload_routes.clone();
let fallback = axum::Router::new().fallback(move |request: axum::extract::Request| {
let routes = routes.clone();
async move { dispatch_upload(routes, request).await }
});
let app = crate::http_transport::configured_router_with_fallback(
handler,
http_config,
+565
View File
@@ -0,0 +1,565 @@
//! Offline, source-preserving upload migration. The journal is the startup barrier.
use crate::{
config::Config,
object_store::{ObjectStore, PART_SIZE, UploadBackendConfig},
storage,
};
use anyhow::{Context, Result, ensure};
use http_body_util::BodyExt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::{
collections::BTreeMap,
io::{Read, Write},
path::{Path, PathBuf},
};
const JOURNAL: &str = "rust_upload_migration.json";
#[derive(Serialize, Deserialize)]
struct Journal {
version: u32,
component: String,
source: Value,
destination: Value,
metadata: String,
committed: bool,
objects: BTreeMap<String, Progress>,
}
#[derive(Default, Serialize, Deserialize)]
struct Progress {
checksum: Option<String>,
verified: bool,
upload_id: Option<String>,
}
struct Backend(Option<ObjectStore>);
impl Backend {
fn new(c: UploadBackendConfig) -> Result<Self> {
Ok(Self(match c {
UploadBackendConfig::Local => None,
UploadBackendConfig::S3(c) => Some(ObjectStore::new(c)?),
}))
}
fn identity(&self) -> Value {
self.0
.as_ref()
.map(ObjectStore::identity)
.unwrap_or_else(|| serde_json::json!({"backend":"local"}))
}
}
fn save(path: &Path, journal: &Journal) -> Result<()> {
storage::atomic_write(path, &serde_json::to_vec_pretty(journal)?)
}
fn digest(bytes: &[u8]) -> String {
hex::encode(Sha256::digest(bytes))
}
fn local_path(dir: &Path, id: &str) -> PathBuf {
dir.join("http_file_share")
.join(format!("{}.bin", storage::encode_name(id)))
}
/// Check all component directories, including components removed from the new config.
pub fn ensure_no_pending(root: &Path) -> Result<()> {
if !root.exists() {
return Ok(());
}
for entry in std::fs::read_dir(root)? {
let path = entry?.path().join(JOURNAL);
if path.exists() {
let journal: Journal = serde_json::from_slice(&std::fs::read(&path)?)
.context("invalid upload migration journal; offline recovery required")?;
ensure!(
journal.version == 1 && journal.committed,
"pending upload migration; resume upload-migrate before starting the server"
);
}
}
Ok(())
}
fn validate_component(config: &Config, component: &str) -> Result<()> {
ensure!(
config
.hosts
.get(component)
.and_then(|h| h.component.as_deref())
== Some("http_file_share"),
"component must be an HTTP file share in both configurations"
);
Ok(())
}
fn stable_settings(old: &Config, new: &Config, component: &str) -> Result<()> {
// Keep URL construction and JWT verification exactly as configured, including inherited options.
for key in [
"http_file_share_secret",
"http_file_share_base_url",
"http_host",
"http_default_host",
"http_external_url",
"http_paths",
"http_ports",
"https_ports",
] {
ensure!(
old.option(component, key) == new.option(component, key),
"migration must preserve {key}; change public routing separately after migration"
);
}
Ok(())
}
/// Plan by default. Execution never deletes source objects or rewrites native metadata.
pub async fn migrate(
old_path: &Path,
new_path: &Path,
component: &str,
execute: bool,
) -> Result<()> {
let (old, old_domain) = crate::server::settings_config(old_path)?;
let (new, new_domain) = crate::server::settings_config(new_path)?;
ensure!(
old_domain == new_domain,
"virtual host must remain unchanged"
);
for key in ["http_ports", "https_ports", "data_path"] {
ensure!(
old.option(&old_domain, key) == new.option(&new_domain, key),
"migration must preserve runtime {key}"
);
}
stable_settings(&old, &new, component)?;
validate_component(&old, component)?;
validate_component(&new, component)?;
let root = PathBuf::from(
old.option(&old_domain, "data_path")
.and_then(Value::as_str)
.context("data_path required")?,
);
let new_root = PathBuf::from(
new.option(&new_domain, "data_path")
.and_then(Value::as_str)
.context("data_path required")?,
);
ensure!(
root.is_dir() && new_root.is_dir(),
"existing data directories required"
);
ensure!(
root.canonicalize()? == new_root.canonicalize()?,
"migration requires the same data directory; native metadata must stay in place"
);
let _guard = crate::data_lock::DataDirectoryGuard::acquire(&root)?;
let source = Backend::new(crate::server::upload_backend(&old, component)?)?;
let target = Backend::new(crate::server::upload_backend(&new, component)?)?;
ensure!(
source.identity() != target.identity(),
"source and destination backend identities are identical"
);
let dir = root.join(storage::encode_name(component));
let cache = match std::fs::read(dir.join("rust_uploads.json")) {
Ok(v) => v,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => b"{}".to_vec(),
Err(e) => return Err(e.into()),
};
let records: BTreeMap<String, Value> = serde_json::from_slice(&cache)?;
ensure!(
!storage::path(&root, component, None, "uploads")
.with_extension("rappend")
.exists(),
"upload archive has pending append recovery; recover source server before migration"
);
let entries = crate::archive::load_store(&root, component, None, "uploads")?;
let mut seen = std::collections::BTreeSet::new();
let mut objects = BTreeMap::new();
let mut fingerprint = Sha256::new();
// Lua table traversal can reorder reconstructed XML attributes between
// processes. Bind exact authoritative archive bytes, not reconstructed XML.
fingerprint.update((cache.len() as u64).to_le_bytes());
fingerprint.update(&cache);
let archive_path = storage::path(&root, component, None, "uploads").with_extension("list");
let archive_bytes = match std::fs::read(archive_path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => return Err(e.into()),
};
fingerprint.update((archive_bytes.len() as u64).to_le_bytes());
fingerprint.update(&archive_bytes);
for e in entries {
ensure!(
seen.insert(e.id.clone()),
"duplicate authoritative upload id"
);
let doc = roxmltree::Document::parse(&e.xml)?;
let node = doc.root_element();
let size: u64 = node
.attribute("size")
.context("upload size missing")?
.parse()?;
let filename = node
.attribute("filename")
.context("upload filename missing")?;
let r = records.get(&e.id);
if let Some(r) = r {
ensure!(
r.get("owner").and_then(Value::as_str) == Some(&e.peer)
&& r.get("filename").and_then(Value::as_str) == Some(filename)
&& r.get("size").and_then(Value::as_u64) == Some(size),
"slot metadata/cache mismatch; recover source server first"
);
ensure!(
r.get("upload_id").is_none_or(Value::is_null)
&& r.get("multipart_intent").and_then(Value::as_bool) != Some(true)
&& r.get("completion_attempted").and_then(Value::as_bool) != Some(true),
"slot has unfinished multipart state; recover source server first"
);
}
// Native Prosody files need no Rust completion sidecar. A present final
// object of exact declared size is completed; absent reservations stay pending.
let complete = r.and_then(|r| r.get("complete")).and_then(Value::as_bool) == Some(true);
let present = if complete {
true
} else if let Some(store) = &source.0 {
match store.stat(&e.id).await? {
Some(n) => {
ensure!(n == size, "source object size mismatch");
true
}
None => false,
}
} else {
local_path(&dir, &e.id).exists()
};
if complete || present {
ensure!(
objects
.insert(
e.id.clone(),
(
size,
node.attribute("content-type")
.unwrap_or("application/octet-stream")
.to_owned()
)
)
.is_none(),
"duplicate upload id"
);
}
}
let metadata = hex::encode(fingerprint.finalize());
let journal_path = dir.join(JOURNAL);
let mut resumed = false;
let fresh = || Journal {
version: 1,
component: component.into(),
source: source.identity(),
destination: target.identity(),
metadata: metadata.clone(),
committed: false,
objects: objects
.keys()
.map(|id| (id.clone(), Progress::default()))
.collect(),
};
let mut journal = if journal_path.exists() {
let j: Journal = serde_json::from_slice(&std::fs::read(&journal_path)?)?;
if j.version == 1
&& j.committed
&& (j.source != source.identity()
|| j.destination != target.identity()
|| j.metadata != metadata)
{
fresh()
} else {
ensure!(
j.version == 1
&& j.component == component
&& j.source == source.identity()
&& j.destination == target.identity()
&& j.metadata == metadata
&& j.objects.keys().eq(objects.keys()),
"migration journal does not match configurations or metadata"
);
resumed = true;
j
}
} else {
fresh()
};
let identity_path = dir.join("rust_upload_backend.json");
let current: Value = if identity_path.exists() {
serde_json::from_slice(&std::fs::read(&identity_path)?)?
} else {
serde_json::json!({"backend":"local"})
};
ensure!(
current == source.identity() || (resumed && current == target.identity()),
"recorded backend is not the migration source"
);
println!(
"{} completed objects; source objects and all native metadata will be preserved",
objects.len()
);
if !execute {
println!(
"Dry run: no objects, journal or backend identity changed. Use --execute to copy and verify."
);
return Ok(());
}
if journal.committed {
ensure!(
current == target.identity(),
"committed migration identity mismatch"
);
println!("Migration already committed.");
return Ok(());
}
save(&journal_path, &journal)?;
for (id, (size, content_type)) in &objects {
// Deterministic journal-owned spools can be removed safely after a crash.
let temp = dir.join(format!(".migration-{}.tmp", digest(id.as_bytes())));
if temp.exists() {
std::fs::remove_file(&temp)?;
}
let result = async {
let checksum = spool(&source, &dir, id, *size, &temp)
.await?
.context("source object missing")?;
if let Some(previous) = &journal.objects[id].checksum {
ensure!(
*previous == checksum,
"source checksum changed during migration"
);
}
journal.objects.get_mut(id).unwrap().checksum = Some(checksum.clone());
save(&journal_path, &journal)?;
// The durable per-object checksum precedes initiation. Sweep this
// exact key, including an initiation whose response was lost.
if let Some(store) = &target.0 {
store.abort_pending(id).await?;
}
if let Some(upload_id) = journal.objects[id].upload_id.as_ref() {
target
.0
.as_ref()
.context("invalid local multipart journal")?
.abort(id, upload_id)
.await?;
journal.objects.get_mut(id).unwrap().upload_id = None;
save(&journal_path, &journal)?;
}
let existing = object_checksum(&target, &dir, id, *size).await;
let existing = existing?;
if let Some(existing) = existing {
ensure!(
existing == checksum,
"destination object conflicts; refusing overwrite"
);
} else {
ensure!(
!journal.objects[id].verified,
"previously verified destination object disappeared"
);
if let Some(store) = &target.0 {
let mut upload = store.begin(id, content_type).await?;
journal.objects.get_mut(id).unwrap().upload_id =
Some(upload.upload_id().into());
save(&journal_path, &journal)?;
let mut file = std::fs::File::open(&temp)?;
loop {
let mut part = vec![0; PART_SIZE];
let mut n = 0;
while n < part.len() {
let read = file.read(&mut part[n..])?;
if read == 0 {
break;
}
n += read;
}
part.truncate(n);
if n == 0 {
if *size == 0 {
upload.part(part).await?;
}
break;
}
upload.part(part).await?;
}
upload.complete().await?;
journal.objects.get_mut(id).unwrap().upload_id = None;
save(&journal_path, &journal)?;
} else {
let path = local_path(&dir, id);
std::fs::create_dir_all(path.parent().unwrap())?;
// Hard-link publishes without clobbering an existing object; spool is on the same filesystem.
std::fs::hard_link(&temp, &path)?;
std::fs::File::open(path.parent().unwrap())?.sync_all()?;
}
let verified = object_checksum(&target, &dir, id, *size).await;
ensure!(
verified?.as_deref() == Some(checksum.as_str()),
"destination checksum mismatch"
);
}
journal.objects.get_mut(id).unwrap().verified = true;
save(&journal_path, &journal)?;
Ok::<_, anyhow::Error>(())
}
.await;
let _ = std::fs::remove_file(&temp);
result.with_context(|| {
format!(
"migration stopped at object {id}; source preserved, resume with the same command"
)
})?;
}
ensure!(
journal.objects.values().all(|p| p.verified),
"unverified migration objects"
);
storage::atomic_write(&identity_path, &serde_json::to_vec(&target.identity())?)?;
journal.committed = true;
save(&journal_path, &journal)?;
println!("Migration committed: all destination objects checksum verified; source preserved.");
Ok(())
}
/// Stream through a private, bounded spool; never trust ETag as a content checksum.
async fn spool(
backend: &Backend,
dir: &Path,
id: &str,
size: u64,
temp: &Path,
) -> Result<Option<String>> {
spool_to(backend, dir, id, size, Some(temp)).await
}
async fn object_checksum(
backend: &Backend,
dir: &Path,
id: &str,
size: u64,
) -> Result<Option<String>> {
spool_to(backend, dir, id, size, None).await
}
async fn spool_to(
backend: &Backend,
dir: &Path,
id: &str,
size: u64,
temp: Option<&Path>,
) -> Result<Option<String>> {
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = temp.map(|temp| options.open(temp)).transpose()?;
let mut hash = Sha256::new();
let mut total = 0u64;
if let Some(store) = &backend.0 {
let response = store.read(id, false, None).await?;
if response.status() == 404 {
return Ok(None);
}
ensure!(
response.status() == 200,
"object download failed or returned partial content"
);
let mut body = response.into_body();
while let Some(frame) =
tokio::time::timeout(std::time::Duration::from_secs(120), body.frame())
.await
.context("migration download timed out")?
{
let frame = frame?;
if let Some(bytes) = frame.data_ref() {
total = total
.checked_add(bytes.len() as u64)
.context("size overflow")?;
ensure!(total <= size, "object exceeds metadata size");
hash.update(bytes);
if let Some(file) = &mut file {
file.write_all(bytes)?;
}
}
}
} else {
let path = local_path(dir, id);
let metadata = match std::fs::symlink_metadata(&path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
ensure!(
metadata.file_type().is_file() && metadata.len() == size,
"local object is not a regular file of metadata size"
);
let mut source = std::fs::File::open(path)?;
let mut bytes = vec![0; PART_SIZE];
loop {
let n = source.read(&mut bytes)?;
if n == 0 {
break;
}
total += n as u64;
ensure!(total <= size, "object exceeds metadata size");
hash.update(&bytes[..n]);
if let Some(file) = &mut file {
file.write_all(&bytes[..n])?;
}
}
}
ensure!(total == size, "object size differs from metadata");
if let Some(file) = file {
file.sync_all()?;
}
Ok(Some(hex::encode(hash.finalize())))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn local_spool_checks_size_and_hash_and_preserves_source() {
let d = tempfile::tempdir().unwrap();
let path = local_path(d.path(), "a/b");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"payload").unwrap();
let temp = d.path().join("spool");
assert_eq!(
spool(&Backend(None), d.path(), "a/b", 7, &temp)
.await
.unwrap(),
Some(digest(b"payload"))
);
assert_eq!(std::fs::read(&path).unwrap(), b"payload");
assert!(
spool(&Backend(None), d.path(), "a/b", 8, &d.path().join("bad"))
.await
.is_err()
);
}
#[test]
fn pending_and_corrupt_journals_block_startup() {
let d = tempfile::tempdir().unwrap();
let path = d.path().join("removed-component").join(JOURNAL);
let mut j = Journal {
version: 1,
component: "upload".into(),
source: Value::Null,
destination: Value::Null,
metadata: "a".into(),
committed: false,
objects: BTreeMap::new(),
};
save(&path, &j).unwrap();
assert!(ensure_no_pending(d.path()).is_err());
j.committed = true;
save(&path, &j).unwrap();
assert!(ensure_no_pending(d.path()).is_ok());
std::fs::write(path, b"broken").unwrap();
assert!(ensure_no_pending(d.path()).is_err());
}
}