mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-11-03 11:37:16 +01:00
feat: sealed part uploads
This commit is contained in:
1 parent
bf25b39dff
commit
efbd1a8681
21 files changed
+2259
-410
No files matched your search
@@ -35,8 +35,10 @@ S3_FORCE_PATH_STYLE=true
|
||||
# S3_PUBLIC_ENDPOINT=https://storage.example.com
|
||||
|
||||
# SYNC_AT_REST_KEY: encrypts every stored object with this server's key, even
|
||||
# when E2E encryption is off in Donut Browser. Transfers then go through this
|
||||
# server instead of straight to storage. Format id:base64 (32 bytes), e.g.
|
||||
# when E2E encryption is off in Donut Browser. Downloads and uploads from
|
||||
# current apps then go through this server; older apps still upload straight to
|
||||
# storage, and the server encrypts those files minutes later. Format id:base64
|
||||
# (32 bytes), e.g.
|
||||
# k1:$(openssl rand -base64 32). To rotate, put the new key first:
|
||||
# k2:...,k1:... The first key encrypts; every key listed decrypts.
|
||||
# Objects stored before stay readable; `donut-sync migrate-at-rest` encrypts them.
|
||||
|
||||
@@ -20,8 +20,10 @@ SYNC_TOKEN=... S3_ENDPOINT=... S3_ACCESS_KEY_ID=... S3_SECRET_ACCESS_KEY=... \
|
||||
`.env.example` lists the variables. A `.env` file in the working directory is
|
||||
also read.
|
||||
|
||||
With `SYNC_AT_REST_KEY` set, the server encrypts every object it stores, and
|
||||
transfers go through it instead of straight to storage. Objects stored before
|
||||
With `SYNC_AT_REST_KEY` set, the server encrypts every object it stores.
|
||||
Downloads and uploads from current apps go through it, large files in parts.
|
||||
Older apps still upload straight to storage, and the server encrypts those
|
||||
files minutes later. Objects stored before
|
||||
stay readable; `donut-sync migrate-at-rest --help` encrypts them in place, and
|
||||
`donut-sync verify-at-rest` checks that none is left in plaintext.
|
||||
|
||||
|
||||
+81
-21
@@ -203,25 +203,53 @@ pub struct Sealer {
|
||||
pending: Vec<u8>,
|
||||
}
|
||||
|
||||
pub type Salt = [u8; SALT];
|
||||
|
||||
pub fn new_salt() -> Salt {
|
||||
let mut salt = [0u8; SALT];
|
||||
rand::fill(&mut salt);
|
||||
salt
|
||||
}
|
||||
|
||||
fn header_of(key_id: &str, salt: &Salt) -> Vec<u8> {
|
||||
let mut header = Vec::with_capacity(header_len(key_id) as usize);
|
||||
header.extend_from_slice(MAGIC);
|
||||
header.push(VERSION);
|
||||
header.push(key_id.len() as u8);
|
||||
header.extend_from_slice(key_id.as_bytes());
|
||||
header.extend_from_slice(salt);
|
||||
header
|
||||
}
|
||||
|
||||
impl Sealer {
|
||||
fn with(key: &Key, salt: &Salt, first_frame: u64) -> Self {
|
||||
Self {
|
||||
key: object_key(&key.secret, salt),
|
||||
header: header_of(&key.id, salt),
|
||||
index: first_frame,
|
||||
pending: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A sealer and the header that starts its output.
|
||||
pub fn new(keyring: &Keyring) -> (Self, Bytes) {
|
||||
let current = keyring.current();
|
||||
let mut salt = [0u8; SALT];
|
||||
rand::fill(&mut salt);
|
||||
let mut header = Vec::with_capacity(header_len(¤t.id) as usize);
|
||||
header.extend_from_slice(MAGIC);
|
||||
header.push(VERSION);
|
||||
header.push(current.id.len() as u8);
|
||||
header.extend_from_slice(current.id.as_bytes());
|
||||
header.extend_from_slice(&salt);
|
||||
let sealer = Self {
|
||||
key: object_key(¤t.secret, &salt),
|
||||
header: header.clone(),
|
||||
index: 0,
|
||||
pending: Vec::new(),
|
||||
};
|
||||
(sealer, Bytes::from(header))
|
||||
let sealer = Self::with(keyring.current(), &new_salt(), 0);
|
||||
let header = Bytes::from(sealer.header.clone());
|
||||
(sealer, header)
|
||||
}
|
||||
|
||||
/// One part of an object sealed in parts under `key_id` and `salt`: its
|
||||
/// frames are numbered from `first_frame`, and only part 1 carries the
|
||||
/// header. `None` when the keyring does not hold `key_id`.
|
||||
pub fn for_part(
|
||||
keyring: &Keyring,
|
||||
key_id: &str,
|
||||
salt: &Salt,
|
||||
first_frame: u64,
|
||||
) -> Option<(Self, Option<Bytes>)> {
|
||||
let sealer = Self::with(keyring.find(key_id)?, salt, first_frame);
|
||||
let header = (first_frame == 0).then(|| Bytes::from(sealer.header.clone()));
|
||||
Some((sealer, header))
|
||||
}
|
||||
|
||||
fn seal(&mut self, mut frame: Vec<u8>, last: bool) -> Bytes {
|
||||
@@ -247,6 +275,28 @@ impl Sealer {
|
||||
let frame = std::mem::take(&mut self.pending);
|
||||
self.seal(frame, true)
|
||||
}
|
||||
|
||||
/// The end of a part: the object's final frame on the last part, else the
|
||||
/// part's own last full frame.
|
||||
fn finish_part(mut self, last: bool) -> Option<Bytes> {
|
||||
if last {
|
||||
return Some(self.finish());
|
||||
}
|
||||
let frame = std::mem::take(&mut self.pending);
|
||||
(!frame.is_empty()).then(|| self.seal(frame, false))
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored size of one part of `plain` bytes. A part before the last holds
|
||||
/// whole frames only.
|
||||
pub fn sealed_part_len(key_id: &str, plain: u64, first: bool, last: bool) -> u64 {
|
||||
let header = if first { header_len(key_id) } else { 0 };
|
||||
let frames = if last {
|
||||
frames(plain)
|
||||
} else {
|
||||
plain / FRAME as u64
|
||||
};
|
||||
header + plain + frames * TAG as u64
|
||||
}
|
||||
|
||||
/// Decrypts a sealed byte stream, frame by frame.
|
||||
@@ -332,14 +382,15 @@ impl Opener {
|
||||
}
|
||||
|
||||
enum Codec {
|
||||
Seal(Option<Sealer>, Option<Bytes>),
|
||||
/// The sealer, the header still to send, and whether this ends the object.
|
||||
Seal(Option<Sealer>, Option<Bytes>, bool),
|
||||
Open(Option<Opener>),
|
||||
}
|
||||
|
||||
impl Codec {
|
||||
fn push(&mut self, data: &[u8], out: &mut VecDeque<Bytes>) -> Result<(), StoreError> {
|
||||
match self {
|
||||
Codec::Seal(sealer, header) => {
|
||||
Codec::Seal(sealer, header, _) => {
|
||||
if let Some(header) = header.take() {
|
||||
out.push_back(header);
|
||||
}
|
||||
@@ -352,11 +403,11 @@ impl Codec {
|
||||
|
||||
fn finish(&mut self, out: &mut VecDeque<Bytes>) -> Result<(), StoreError> {
|
||||
match self {
|
||||
Codec::Seal(sealer, header) => {
|
||||
Codec::Seal(sealer, header, last) => {
|
||||
if let Some(header) = header.take() {
|
||||
out.push_back(header);
|
||||
}
|
||||
out.push_back(sealer.take().expect("finished once").finish());
|
||||
out.extend(sealer.take().expect("finished once").finish_part(*last));
|
||||
Ok(())
|
||||
}
|
||||
Codec::Open(opener) => {
|
||||
@@ -414,7 +465,16 @@ fn transform(input: ByteStream, codec: Codec) -> ByteStream {
|
||||
/// `input` sealed under the current key.
|
||||
pub fn seal_stream(keyring: &Keyring, input: ByteStream) -> ByteStream {
|
||||
let (sealer, header) = Sealer::new(keyring);
|
||||
transform(input, Codec::Seal(Some(sealer), Some(header)))
|
||||
transform(input, Codec::Seal(Some(sealer), Some(header), true))
|
||||
}
|
||||
|
||||
/// One part of an object sealed in parts (see `Sealer::for_part`).
|
||||
pub fn seal_part_stream(
|
||||
(sealer, header): (Sealer, Option<Bytes>),
|
||||
last: bool,
|
||||
input: ByteStream,
|
||||
) -> ByteStream {
|
||||
transform(input, Codec::Seal(Some(sealer), header, last))
|
||||
}
|
||||
|
||||
/// `input`, a sealed object, opened. A forged, cut or wrongly keyed object
|
||||
|
||||
@@ -385,3 +385,64 @@ mod sealed_store {
|
||||
assert_eq!(store.get_object_text("b").await.unwrap(), "x");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_object_sealed_in_parts_reads_like_one_sealed_whole() {
|
||||
let keys = keyring(K1);
|
||||
let part = 2 * FRAME;
|
||||
for size in [
|
||||
0,
|
||||
1,
|
||||
part - 1,
|
||||
part,
|
||||
part + 1,
|
||||
2 * part,
|
||||
3 * part + FRAME + 5,
|
||||
] {
|
||||
let plain = sample(size);
|
||||
let salt = new_salt();
|
||||
let parts = size.div_ceil(part).max(1);
|
||||
let mut sealed = Vec::new();
|
||||
for i in 0..parts {
|
||||
let start = i * part;
|
||||
let chunk = plain[start..(start + part).min(size)].to_vec();
|
||||
let last = i + 1 == parts;
|
||||
let sealer = Sealer::for_part(&keys, "k1", &salt, (start / FRAME) as u64).unwrap();
|
||||
let len = chunk.len() as u64;
|
||||
let out = collect(seal_part_stream(sealer, last, chunked(chunk, &[777])))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out.len() as u64,
|
||||
sealed_part_len("k1", len, i == 0, last),
|
||||
"size {size} part {i}"
|
||||
);
|
||||
sealed.extend(out);
|
||||
}
|
||||
assert_eq!(
|
||||
sealed.len() as u64,
|
||||
sealed_len("k1", size as u64),
|
||||
"size {size}"
|
||||
);
|
||||
assert_eq!(open(keys.clone(), &sealed).unwrap(), plain, "size {size}");
|
||||
}
|
||||
assert!(Sealer::for_part(&keys, "k9", &new_salt(), 0).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parts_out_of_place_do_not_open() {
|
||||
let keys = keyring(K1);
|
||||
let salt = new_salt();
|
||||
let part = |first: u64, last: bool, data: Vec<u8>| {
|
||||
let sealer = Sealer::for_part(&keys, "k1", &salt, first).unwrap();
|
||||
collect(seal_part_stream(sealer, last, chunked(data, &[FRAME])))
|
||||
};
|
||||
let one = part(0, false, sample(FRAME)).await.unwrap();
|
||||
let two = part(1, true, sample(10)).await.unwrap();
|
||||
assert!(open(keys.clone(), &[one.clone(), two.clone()].concat()).is_ok());
|
||||
// A part numbered as if it came later, or a non-final part last.
|
||||
let skipped = part(2, true, sample(10)).await.unwrap();
|
||||
assert!(open(keys.clone(), &[one.clone(), skipped].concat()).is_err());
|
||||
let unfinished = part(1, false, sample(FRAME)).await.unwrap();
|
||||
assert!(open(keys.clone(), &[one, unfinished].concat()).is_err());
|
||||
}
|
||||
@@ -39,7 +39,7 @@ pub fn full(bytes: impl Into<Bytes>) -> ResBody {
|
||||
.boxed_unsync()
|
||||
}
|
||||
|
||||
fn json_response(status: u16, body: &Value) -> Response<ResBody> {
|
||||
pub(crate) fn json_response(status: u16, body: &Value) -> Response<ResBody> {
|
||||
let text = body.to_string();
|
||||
Response::builder()
|
||||
.status(status)
|
||||
|
||||
@@ -18,12 +18,14 @@ pub mod migrate;
|
||||
pub mod net;
|
||||
pub mod s3;
|
||||
pub mod sealed_store;
|
||||
pub mod sealer;
|
||||
pub mod sigv4;
|
||||
pub mod store;
|
||||
pub mod subscribe;
|
||||
pub mod sync;
|
||||
pub mod time;
|
||||
pub mod transfer;
|
||||
pub mod transfer_parts;
|
||||
pub mod verify;
|
||||
|
||||
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
@@ -167,11 +169,11 @@ pub async fn build_state(config: &Config) -> Result<Arc<AppState>, String> {
|
||||
),
|
||||
);
|
||||
(
|
||||
Arc::new(SealedStore::new(s3, keyring.clone())),
|
||||
Arc::new(SealedStore::new(s3.clone(), keyring.clone())),
|
||||
Some(TransferSigner::new(keyring)),
|
||||
)
|
||||
}
|
||||
None => (s3, None),
|
||||
None => (s3.clone(), None),
|
||||
};
|
||||
let presigner = Presigner::new(
|
||||
Location::parse(&public_endpoint, &storage.bucket, storage.force_path_style)?,
|
||||
@@ -202,6 +204,9 @@ pub async fn build_state(config: &Config) -> Result<Arc<AppState>, String> {
|
||||
|
||||
sync.ensure_bucket_exists().await?;
|
||||
sync.warn_if_presign_endpoint_is_server_only();
|
||||
if let Some(keyring) = keyring {
|
||||
crate::sealer::spawn(s3, keyring);
|
||||
}
|
||||
|
||||
Ok(Arc::new(AppState {
|
||||
guard,
|
||||
|
||||
@@ -9,8 +9,8 @@ use async_trait::async_trait;
|
||||
use md5::{Digest, Md5};
|
||||
|
||||
use crate::store::{
|
||||
ByteStream, Download, HeadOutput, ListInput, ListObject, ListOutput, Store, StoreError, Upload,
|
||||
collect, once,
|
||||
ByteStream, Download, HeadOutput, ListInput, ListObject, ListOutput, PendingUpload, Store,
|
||||
StoreError, Upload, collect, once,
|
||||
};
|
||||
|
||||
pub type HeadHook = Box<dyn Fn(&str) -> Result<HeadOutput, StoreError> + Send + Sync>;
|
||||
@@ -24,6 +24,17 @@ pub struct Object {
|
||||
pub modified_ms: i64,
|
||||
}
|
||||
|
||||
/// S3's smallest part, except the last.
|
||||
pub const MIN_PART: usize = 5 * 1024 * 1024;
|
||||
|
||||
pub struct Multipart {
|
||||
key: String,
|
||||
content_type: Option<String>,
|
||||
metadata: Vec<(String, String)>,
|
||||
parts: BTreeMap<u32, Vec<u8>>,
|
||||
initiated_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MemoryStore {
|
||||
pub objects: Mutex<BTreeMap<String, Object>>,
|
||||
@@ -32,6 +43,7 @@ pub struct MemoryStore {
|
||||
pub clock: AtomicI64,
|
||||
pub head_hook: Option<HeadHook>,
|
||||
pub list_hook: Option<ListHook>,
|
||||
pub uploads: Mutex<BTreeMap<String, Multipart>>,
|
||||
}
|
||||
|
||||
fn etag(body: &[u8]) -> String {
|
||||
@@ -268,4 +280,110 @@ impl Store for MemoryStore {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_multipart(
|
||||
&self,
|
||||
key: &str,
|
||||
content_type: Option<&str>,
|
||||
metadata: &[(String, String)],
|
||||
) -> Result<String, StoreError> {
|
||||
self.call();
|
||||
let id = uuid::Uuid::new_v4().simple().to_string();
|
||||
self.uploads.lock().unwrap().insert(
|
||||
id.clone(),
|
||||
Multipart {
|
||||
key: key.to_string(),
|
||||
content_type: content_type.map(str::to_string),
|
||||
metadata: metadata.to_vec(),
|
||||
parts: BTreeMap::new(),
|
||||
initiated_ms: self.tick(),
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn upload_part(
|
||||
&self,
|
||||
key: &str,
|
||||
upload_id: &str,
|
||||
part: u32,
|
||||
body: ByteStream,
|
||||
length: u64,
|
||||
) -> Result<String, StoreError> {
|
||||
self.call();
|
||||
let body = collect(body).await?;
|
||||
if body.len() as u64 != length {
|
||||
return Err(StoreError::Transport("length mismatch".to_string()));
|
||||
}
|
||||
let mut uploads = self.uploads.lock().unwrap();
|
||||
let upload = uploads
|
||||
.get_mut(upload_id)
|
||||
.filter(|u| u.key == key)
|
||||
.ok_or(StoreError::NotFound)?;
|
||||
let tag = etag(&body);
|
||||
upload.parts.insert(part, body);
|
||||
Ok(tag)
|
||||
}
|
||||
|
||||
async fn complete_multipart(
|
||||
&self,
|
||||
key: &str,
|
||||
upload_id: &str,
|
||||
etags: &[String],
|
||||
) -> Result<(), StoreError> {
|
||||
self.call();
|
||||
let upload = self
|
||||
.uploads
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(upload_id)
|
||||
.filter(|u| u.key == key)
|
||||
.ok_or(StoreError::NotFound)?;
|
||||
let mut body = Vec::new();
|
||||
for (i, expected) in etags.iter().enumerate() {
|
||||
let part = upload
|
||||
.parts
|
||||
.get(&(i as u32 + 1))
|
||||
.filter(|part| etag(part) == *expected)
|
||||
.ok_or_else(|| StoreError::Service {
|
||||
status: 400,
|
||||
code: "InvalidPart".to_string(),
|
||||
message: String::new(),
|
||||
})?;
|
||||
if i + 1 < etags.len() && part.len() < MIN_PART {
|
||||
return Err(StoreError::Service {
|
||||
status: 400,
|
||||
code: "EntityTooSmall".to_string(),
|
||||
message: String::new(),
|
||||
});
|
||||
}
|
||||
body.extend_from_slice(part);
|
||||
}
|
||||
self.insert(key, body, upload.metadata, upload.content_type);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn abort_multipart(&self, _key: &str, upload_id: &str) -> Result<(), StoreError> {
|
||||
self.call();
|
||||
self.uploads.lock().unwrap().remove(upload_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_multipart(&self, prefix: &str) -> Result<Vec<PendingUpload>, StoreError> {
|
||||
self.call();
|
||||
Ok(
|
||||
self
|
||||
.uploads
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(_, u)| u.key.starts_with(prefix))
|
||||
.map(|(id, u)| PendingUpload {
|
||||
key: u.key.clone(),
|
||||
upload_id: id.clone(),
|
||||
initiated_ms: Some(u.initiated_ms),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ use crate::store::{ByteStream, ListInput, Store, StoreError, Upload};
|
||||
pub const COMMAND: &str = "migrate-at-rest";
|
||||
const CONTEXT: &str = "Migrate";
|
||||
/// Temporary sealed copies. No client prefix matches it.
|
||||
pub const TMP_PREFIX: &str = ".donut-sync-at-rest-tmp/";
|
||||
pub const TMP_PREFIX: &str = ".donut-sync-at-rest/tmp/";
|
||||
const PROGRESS_EVERY: Duration = Duration::from_secs(10);
|
||||
/// Tries per object: storage has transient failures.
|
||||
const ATTEMPTS: u32 = 3;
|
||||
@@ -148,7 +148,7 @@ pub struct Report {
|
||||
pub failed: u64,
|
||||
}
|
||||
|
||||
enum Outcome {
|
||||
pub(crate) enum Outcome {
|
||||
Current,
|
||||
Sealed(u64),
|
||||
Rotated(u64),
|
||||
@@ -191,7 +191,7 @@ fn throttled(body: ByteStream, limit: Option<Arc<RateLimit>>) -> ByteStream {
|
||||
}
|
||||
}
|
||||
|
||||
struct Migration {
|
||||
pub(crate) struct Migration {
|
||||
store: Arc<dyn Store>,
|
||||
keyring: Arc<Keyring>,
|
||||
dry_run: bool,
|
||||
@@ -199,6 +199,19 @@ struct Migration {
|
||||
limit: Option<Arc<RateLimit>>,
|
||||
}
|
||||
|
||||
impl Migration {
|
||||
/// Seals in place, as the migration does, without a rate limit.
|
||||
pub(crate) fn sealing(store: Arc<dyn Store>, keyring: Arc<Keyring>, conditional: bool) -> Self {
|
||||
Self {
|
||||
store,
|
||||
keyring,
|
||||
dry_run: false,
|
||||
conditional,
|
||||
limit: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn precondition_failed(error: &StoreError) -> bool {
|
||||
matches!(error, StoreError::Service { status: 412, .. })
|
||||
}
|
||||
@@ -234,7 +247,7 @@ pub async fn honors_if_match(store: &dyn Store) -> Result<bool, StoreError> {
|
||||
impl Migration {
|
||||
/// `listed_ms` is the LastModified the listing gave: it has milliseconds,
|
||||
/// where a HEAD has seconds.
|
||||
async fn object(&self, key: &str, listed_ms: Option<i64>) -> Outcome {
|
||||
pub(crate) async fn object(&self, key: &str, listed_ms: Option<i64>) -> Outcome {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
@@ -432,7 +445,7 @@ pub async fn migrate(
|
||||
.contents
|
||||
.into_iter()
|
||||
.map(|object| (object.key, object.last_modified_ms))
|
||||
.filter(|(key, _)| !key.starts_with(TMP_PREFIX))
|
||||
.filter(|(key, _)| !key.starts_with(crate::sealer::INTERNAL_ROOT))
|
||||
.collect();
|
||||
let keys: Vec<&String> = objects.iter().map(|(key, _)| key).collect();
|
||||
let outcomes: Vec<(String, Outcome)> = futures_util::stream::iter(objects.iter())
|
||||
|
||||
+178
-1
@@ -11,7 +11,8 @@ use reqwest::Method;
|
||||
use crate::jsval::js_number;
|
||||
use crate::sigv4::{self, Credentials};
|
||||
use crate::store::{
|
||||
ByteStream, Download, HeadOutput, ListInput, ListObject, ListOutput, Store, StoreError, Upload,
|
||||
ByteStream, Download, HeadOutput, ListInput, ListObject, ListOutput, PendingUpload, Store,
|
||||
StoreError, Upload,
|
||||
};
|
||||
use crate::time;
|
||||
|
||||
@@ -329,6 +330,38 @@ fn restore_controls(text: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
type UploadsPage = (Vec<PendingUpload>, Option<(String, String)>);
|
||||
|
||||
/// A ListMultipartUploads reply: the uploads, and where the next page starts.
|
||||
fn parse_uploads(body: &[u8]) -> Result<UploadsPage, StoreError> {
|
||||
let text = std::str::from_utf8(body)
|
||||
.map_err(|e| StoreError::Transport(format!("ListMultipartUploads reply is not UTF-8: {e}")))?;
|
||||
let doc = roxmltree::Document::parse(text)
|
||||
.map_err(|e| StoreError::Transport(format!("ListMultipartUploads reply is not XML: {e}")))?;
|
||||
let root = doc.root_element();
|
||||
let mut uploads = Vec::new();
|
||||
let mut truncated = false;
|
||||
for node in root.children().filter(|n| n.is_element()) {
|
||||
match node.tag_name().name() {
|
||||
"Upload" => uploads.push(PendingUpload {
|
||||
key: child_text(node, "Key").unwrap_or("").to_string(),
|
||||
upload_id: child_text(node, "UploadId").unwrap_or("").to_string(),
|
||||
initiated_ms: child_text(node, "Initiated").and_then(time::parse_rfc3339_ms),
|
||||
}),
|
||||
"IsTruncated" => truncated = node.text().unwrap_or("").trim() == "true",
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let next = if truncated {
|
||||
child_text(root, "NextKeyMarker")
|
||||
.zip(child_text(root, "NextUploadIdMarker"))
|
||||
.map(|(key, id)| (key.to_string(), id.to_string()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((uploads, next))
|
||||
}
|
||||
|
||||
fn parse_list(body: &[u8]) -> Result<ListOutput, StoreError> {
|
||||
let text = std::str::from_utf8(body)
|
||||
.map_err(|e| StoreError::Transport(format!("ListObjectsV2 reply is not UTF-8: {e}")))?;
|
||||
@@ -748,6 +781,150 @@ impl Store for S3Store {
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_multipart(
|
||||
&self,
|
||||
key: &str,
|
||||
content_type: Option<&str>,
|
||||
metadata: &[(String, String)],
|
||||
) -> Result<String, StoreError> {
|
||||
let path = self.location.object_path(key);
|
||||
let mut headers = Vec::new();
|
||||
if let Some(content_type) = content_type {
|
||||
headers.push(("content-type".to_string(), content_type.to_string()));
|
||||
}
|
||||
for (name, value) in metadata {
|
||||
headers.push((format!("x-amz-meta-{name}"), value.clone()));
|
||||
}
|
||||
let reply = self
|
||||
.expect_success(
|
||||
Method::POST,
|
||||
&path,
|
||||
&[("uploads".to_string(), String::new())],
|
||||
&headers,
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
let text = String::from_utf8_lossy(&reply.body);
|
||||
let doc = roxmltree::Document::parse(&text)
|
||||
.map_err(|e| StoreError::Transport(format!("CreateMultipartUpload reply is not XML: {e}")))?;
|
||||
child_text(doc.root_element(), "UploadId")
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| {
|
||||
StoreError::Transport("CreateMultipartUpload reply has no UploadId".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// One attempt: a stream cannot be sent twice.
|
||||
async fn upload_part(
|
||||
&self,
|
||||
key: &str,
|
||||
upload_id: &str,
|
||||
part: u32,
|
||||
body: ByteStream,
|
||||
length: u64,
|
||||
) -> Result<String, StoreError> {
|
||||
let path = self.location.object_path(key);
|
||||
let query = [
|
||||
("partNumber".to_string(), part.to_string()),
|
||||
("uploadId".to_string(), upload_id.to_string()),
|
||||
];
|
||||
let headers = [("content-length".to_string(), length.to_string())];
|
||||
let response = self
|
||||
.request(
|
||||
&Method::PUT,
|
||||
&path,
|
||||
&query,
|
||||
&headers,
|
||||
sigv4::UNSIGNED_PAYLOAD.to_string(),
|
||||
)
|
||||
.body(reqwest::Body::wrap_stream(body))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| StoreError::Transport(transport_message(&e)))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(refusal(response).await);
|
||||
}
|
||||
head_of(response.headers())
|
||||
.etag
|
||||
.ok_or_else(|| StoreError::Transport("UploadPart reply has no ETag".to_string()))
|
||||
}
|
||||
|
||||
async fn complete_multipart(
|
||||
&self,
|
||||
key: &str,
|
||||
upload_id: &str,
|
||||
etags: &[String],
|
||||
) -> Result<(), StoreError> {
|
||||
let mut xml = String::from("<CompleteMultipartUpload>");
|
||||
for (i, etag) in etags.iter().enumerate() {
|
||||
xml.push_str(&format!(
|
||||
"<Part><PartNumber>{}</PartNumber><ETag>{}</ETag></Part>",
|
||||
i + 1,
|
||||
xml_escape(etag)
|
||||
));
|
||||
}
|
||||
xml.push_str("</CompleteMultipartUpload>");
|
||||
let path = self.location.object_path(key);
|
||||
let reply = self
|
||||
.expect_success(
|
||||
Method::POST,
|
||||
&path,
|
||||
&[("uploadId".to_string(), upload_id.to_string())],
|
||||
&[("content-type".to_string(), "application/xml".to_string())],
|
||||
xml.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
// Like a copy, a completion can fail with 200 and an error document.
|
||||
if let Some((code, message)) = error_document(&reply.body) {
|
||||
return Err(StoreError::Service {
|
||||
status: reply.status,
|
||||
code,
|
||||
message,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn abort_multipart(&self, key: &str, upload_id: &str) -> Result<(), StoreError> {
|
||||
let path = self.location.object_path(key);
|
||||
self
|
||||
.expect_success(
|
||||
Method::DELETE,
|
||||
&path,
|
||||
&[("uploadId".to_string(), upload_id.to_string())],
|
||||
&[],
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
async fn list_multipart(&self, prefix: &str) -> Result<Vec<PendingUpload>, StoreError> {
|
||||
let path = self.location.bucket_path();
|
||||
let mut out = Vec::new();
|
||||
let mut marker: Option<(String, String)> = None;
|
||||
loop {
|
||||
let mut query = vec![
|
||||
("uploads".to_string(), String::new()),
|
||||
("prefix".to_string(), prefix.to_string()),
|
||||
];
|
||||
if let Some((key, id)) = &marker {
|
||||
query.push(("key-marker".to_string(), key.clone()));
|
||||
query.push(("upload-id-marker".to_string(), id.clone()));
|
||||
}
|
||||
let reply = self
|
||||
.expect_success(Method::GET, &path, &query, &[], &[])
|
||||
.await?;
|
||||
let (uploads, next) = parse_uploads(&reply.body)?;
|
||||
out.extend(uploads);
|
||||
match next {
|
||||
Some(next) if Some(&next) != marker.as_ref() => marker = Some(next),
|
||||
_ => return Ok(out),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One attempt: a stream cannot be sent twice.
|
||||
async fn put_object_stream(
|
||||
&self,
|
||||
|
||||
@@ -12,7 +12,7 @@ use async_trait::async_trait;
|
||||
|
||||
use crate::at_rest::{self, Keyring, MARKER};
|
||||
use crate::store::{
|
||||
ByteStream, Download, HeadOutput, ListInput, ListOutput, Store, StoreError, Upload,
|
||||
ByteStream, Download, HeadOutput, ListInput, ListOutput, PendingUpload, Store, StoreError, Upload,
|
||||
};
|
||||
|
||||
/// User metadata (`x-amz-meta-donut-orig-mtime`): the object's LastModified
|
||||
@@ -185,4 +185,50 @@ impl Store for SealedStore {
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// Multipart parts pass through as they come: the caller (transfer.rs)
|
||||
// seals each part as whole frames of one object.
|
||||
|
||||
async fn create_multipart(
|
||||
&self,
|
||||
key: &str,
|
||||
content_type: Option<&str>,
|
||||
metadata: &[(String, String)],
|
||||
) -> Result<String, StoreError> {
|
||||
self
|
||||
.inner
|
||||
.create_multipart(key, content_type, &self.with_marker(metadata))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn upload_part(
|
||||
&self,
|
||||
key: &str,
|
||||
upload_id: &str,
|
||||
part: u32,
|
||||
body: ByteStream,
|
||||
length: u64,
|
||||
) -> Result<String, StoreError> {
|
||||
self
|
||||
.inner
|
||||
.upload_part(key, upload_id, part, body, length)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn complete_multipart(
|
||||
&self,
|
||||
key: &str,
|
||||
upload_id: &str,
|
||||
etags: &[String],
|
||||
) -> Result<(), StoreError> {
|
||||
self.inner.complete_multipart(key, upload_id, etags).await
|
||||
}
|
||||
|
||||
async fn abort_multipart(&self, key: &str, upload_id: &str) -> Result<(), StoreError> {
|
||||
self.inner.abort_multipart(key, upload_id).await
|
||||
}
|
||||
|
||||
async fn list_multipart(&self, prefix: &str) -> Result<Vec<PendingUpload>, StoreError> {
|
||||
self.inner.list_multipart(prefix).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
//! Seals what older apps upload in plaintext. Older apps PUT to the direct
|
||||
//! storage URL that presign-upload has always returned, because one request
|
||||
//! through a proxy such as Cloudflare cannot carry a large file. The server
|
||||
//! notes each key it hands such a URL for (`note_direct_uploads`), in the
|
||||
//! bucket so a restart keeps the list, and seals those objects in place with
|
||||
//! the migration's ETag-checked rewrite: on the first pass after the URL is
|
||||
//! handed out (`PASS_EVERY`), and once more after the URL is dead. An hourly pass over everything written
|
||||
//! since the last one catches what a lost record missed, and storage uploads
|
||||
//! in parts that were never finished are aborted.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::at_rest::Keyring;
|
||||
use crate::log;
|
||||
use crate::migrate::{Migration, Outcome, honors_if_match};
|
||||
use crate::sealed_store::SealedStore;
|
||||
use crate::store::{ListInput, Store, StoreError};
|
||||
use crate::time;
|
||||
|
||||
/// This server's own objects. No client prefix matches it.
|
||||
pub const INTERNAL_ROOT: &str = ".donut-sync-at-rest/";
|
||||
const PENDING: &str = ".donut-sync-at-rest/pending/";
|
||||
const STATE: &str = ".donut-sync-at-rest/state.json";
|
||||
const CONTEXT: &str = "Sealer";
|
||||
const PASS_EVERY: Duration = Duration::from_secs(120);
|
||||
const BACKSTOP_EVERY: Duration = Duration::from_secs(3600);
|
||||
/// Overlap between backstop passes, for clock skew between server and storage.
|
||||
const BACKSTOP_OVERLAP_MS: i64 = 10 * 60 * 1000;
|
||||
/// A first backstop pass looks back this far; the migration covers the rest.
|
||||
const FIRST_BACKSTOP_MS: i64 = 2 * 3600 * 1000;
|
||||
/// A record is kept this long after its URLs expire, for uploads in flight.
|
||||
const RECORD_GRACE_MS: i64 = 5 * 60 * 1000;
|
||||
/// Unfinished part uploads older than this are aborted: past any upload token.
|
||||
const ABANDONED_MS: i64 = 26 * 3600 * 1000;
|
||||
const CONCURRENCY: usize = 4;
|
||||
|
||||
/// Notes `keys`, which devices may write in plaintext through direct storage
|
||||
/// URLs until `expires_ms`. `store` is the sealed store: the record is sealed.
|
||||
pub async fn note_direct_uploads(
|
||||
store: &dyn Store,
|
||||
keys: &[String],
|
||||
expires_ms: i64,
|
||||
) -> Result<(), StoreError> {
|
||||
let record = format!(
|
||||
"{PENDING}{expires_ms:013}-{}",
|
||||
uuid::Uuid::new_v4().simple()
|
||||
);
|
||||
store
|
||||
.put_object(
|
||||
&record,
|
||||
json!({ "keys": keys }).to_string().into_bytes(),
|
||||
"application/json",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn record_expiry(record: &str) -> Option<i64> {
|
||||
record
|
||||
.strip_prefix(PENDING)?
|
||||
.split('-')
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, PartialEq)]
|
||||
pub struct PassReport {
|
||||
pub records: u64,
|
||||
pub records_done: u64,
|
||||
pub checked: u64,
|
||||
pub sealed: u64,
|
||||
pub failed: u64,
|
||||
}
|
||||
|
||||
impl PassReport {
|
||||
fn add(&mut self, outcome: &Outcome) {
|
||||
self.checked += 1;
|
||||
match outcome {
|
||||
Outcome::Sealed(_) | Outcome::Rotated(_) => self.sealed += 1,
|
||||
Outcome::Failed(_) => self.failed += 1,
|
||||
Outcome::Current | Outcome::Changed => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Sealer {
|
||||
raw: Arc<dyn Store>,
|
||||
sealed: SealedStore,
|
||||
migration: Migration,
|
||||
/// Records sealed once while their URLs live. A restart forgets them, and
|
||||
/// they are sealed once more: harmless.
|
||||
first_round: Mutex<HashSet<String>>,
|
||||
}
|
||||
|
||||
impl Sealer {
|
||||
pub async fn new(raw: Arc<dyn Store>, keyring: Arc<Keyring>) -> Result<Self, StoreError> {
|
||||
let conditional = honors_if_match(raw.as_ref()).await?;
|
||||
Ok(Self {
|
||||
sealed: SealedStore::new(raw.clone(), keyring.clone()),
|
||||
migration: Migration::sealing(raw.clone(), keyring, conditional),
|
||||
raw,
|
||||
first_round: Mutex::new(HashSet::new()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn seal_all(&self, keys: Vec<(String, Option<i64>)>, report: &mut PassReport) {
|
||||
let outcomes: Vec<(String, Outcome)> = futures_util::stream::iter(keys)
|
||||
.map(|(key, listed)| async move {
|
||||
let outcome = self.migration.object(&key, listed).await;
|
||||
(key, outcome)
|
||||
})
|
||||
.buffer_unordered(CONCURRENCY)
|
||||
.collect()
|
||||
.await;
|
||||
for (key, outcome) in outcomes {
|
||||
if let Outcome::Failed(err) = &outcome {
|
||||
log::warn(
|
||||
CONTEXT,
|
||||
&format!("Object not sealed key={key} err=\"{err}\""),
|
||||
);
|
||||
}
|
||||
report.add(&outcome);
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(&self, prefix: Option<&str>) -> Result<Vec<(String, Option<i64>)>, StoreError> {
|
||||
let mut out = Vec::new();
|
||||
let mut token = None;
|
||||
loop {
|
||||
let page = self
|
||||
.raw
|
||||
.list_objects_v2(&ListInput {
|
||||
prefix: prefix.map(str::to_string),
|
||||
max_keys: Some("1000".to_string()),
|
||||
continuation_token: token.clone(),
|
||||
..ListInput::default()
|
||||
})
|
||||
.await?;
|
||||
out.extend(
|
||||
page
|
||||
.contents
|
||||
.into_iter()
|
||||
.map(|object| (object.key, object.last_modified_ms)),
|
||||
);
|
||||
token = page.next_continuation_token.filter(|_| page.is_truncated);
|
||||
if token.is_none() {
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Seals the keys of each new record, and of each record whose URLs are
|
||||
/// dead, which it then drops.
|
||||
pub async fn pending_pass(&self, now_ms: i64) -> Result<PassReport, StoreError> {
|
||||
let mut report = PassReport::default();
|
||||
for (record, _) in self.list(Some(PENDING)).await? {
|
||||
report.records += 1;
|
||||
let dead = record_expiry(&record).is_some_and(|expiry| now_ms > expiry + RECORD_GRACE_MS);
|
||||
if !dead && !self.first_round.lock().unwrap().insert(record.clone()) {
|
||||
continue;
|
||||
}
|
||||
let keys: Vec<(String, Option<i64>)> = match self.sealed.get_object_text(&record).await {
|
||||
Ok(text) => serde_json::from_str::<Value>(&text)
|
||||
.ok()
|
||||
.and_then(|v| v["keys"].as_array().cloned())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|k| k.as_str().map(|k| (k.to_string(), None)))
|
||||
.collect(),
|
||||
Err(StoreError::NotFound) => continue,
|
||||
Err(e) => {
|
||||
log::warn(
|
||||
CONTEXT,
|
||||
&format!("Record unreadable key={record} err=\"{e}\""),
|
||||
);
|
||||
report.failed += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let failed_before = report.failed;
|
||||
self.seal_all(keys, &mut report).await;
|
||||
if dead && report.failed == failed_before {
|
||||
self.raw.delete_object(&record).await?;
|
||||
self.first_round.lock().unwrap().remove(&record);
|
||||
report.records_done += 1;
|
||||
}
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Seals everything written since the last pass, and aborts abandoned
|
||||
/// uploads in parts.
|
||||
pub async fn backstop(&self, now_ms: i64) -> Result<PassReport, StoreError> {
|
||||
let since = match self.sealed.get_object_text(STATE).await {
|
||||
Ok(text) => serde_json::from_str::<Value>(&text)
|
||||
.ok()
|
||||
.and_then(|v| v["backstopSince"].as_i64()),
|
||||
Err(StoreError::NotFound) => None,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
.unwrap_or(now_ms - FIRST_BACKSTOP_MS);
|
||||
let recent: Vec<(String, Option<i64>)> = self
|
||||
.list(None)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|(key, modified)| {
|
||||
!key.starts_with(INTERNAL_ROOT)
|
||||
&& modified.is_none_or(|ms| ms >= since - BACKSTOP_OVERLAP_MS)
|
||||
})
|
||||
.collect();
|
||||
let mut report = PassReport::default();
|
||||
self.seal_all(recent, &mut report).await;
|
||||
if report.failed == 0 {
|
||||
self
|
||||
.sealed
|
||||
.put_object(
|
||||
STATE,
|
||||
json!({ "backstopSince": now_ms }).to_string().into_bytes(),
|
||||
"application/json",
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
match self.raw.list_multipart("").await {
|
||||
Ok(uploads) => {
|
||||
for upload in uploads {
|
||||
if upload
|
||||
.initiated_ms
|
||||
.is_some_and(|ms| now_ms - ms > ABANDONED_MS)
|
||||
{
|
||||
match self
|
||||
.raw
|
||||
.abort_multipart(&upload.key, &upload.upload_id)
|
||||
.await
|
||||
{
|
||||
Ok(()) => log::log(
|
||||
CONTEXT,
|
||||
&format!("Abandoned upload aborted key={}", upload.key),
|
||||
),
|
||||
Err(e) => log::warn(
|
||||
CONTEXT,
|
||||
&format!(
|
||||
"Abandoned upload not aborted key={} err=\"{e}\"",
|
||||
upload.key
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Some stores (rclone) cannot list them, and keep none.
|
||||
Err(StoreError::NotFound) => {}
|
||||
Err(e) => log::warn(
|
||||
CONTEXT,
|
||||
&format!("Unfinished uploads not listed err=\"{e}\""),
|
||||
),
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
}
|
||||
|
||||
fn log_pass(name: &str, outcome: Result<PassReport, StoreError>, started: Instant) {
|
||||
match outcome {
|
||||
Ok(report) if report.sealed > 0 || report.failed > 0 || report.records_done > 0 => {
|
||||
let line = format!(
|
||||
"{name} records={} records_done={} checked={} sealed={} failed={} elapsed_ms={}",
|
||||
report.records,
|
||||
report.records_done,
|
||||
report.checked,
|
||||
report.sealed,
|
||||
report.failed,
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
if report.failed > 0 {
|
||||
log::warn(CONTEXT, &line);
|
||||
} else {
|
||||
log::log(CONTEXT, &line);
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn(CONTEXT, &format!("{name} stopped err=\"{e}\"")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the passes for as long as the server does.
|
||||
pub fn spawn(raw: Arc<dyn Store>, keyring: Arc<Keyring>) {
|
||||
tokio::spawn(async move {
|
||||
// The first pass waits too: starting writes a probe object, which
|
||||
// should not race the server's first requests.
|
||||
tokio::time::sleep(PASS_EVERY).await;
|
||||
let sealer = loop {
|
||||
match Sealer::new(raw.clone(), keyring.clone()).await {
|
||||
Ok(sealer) => break sealer,
|
||||
Err(e) => {
|
||||
log::warn(CONTEXT, &format!("Sealer not started err=\"{e}\""));
|
||||
tokio::time::sleep(PASS_EVERY).await;
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut last_backstop: Option<Instant> = None;
|
||||
loop {
|
||||
let started = Instant::now();
|
||||
log_pass(
|
||||
"Pending pass",
|
||||
sealer.pending_pass(time::now_ms()).await,
|
||||
started,
|
||||
);
|
||||
if last_backstop.is_none_or(|at| at.elapsed() >= BACKSTOP_EVERY) {
|
||||
let started = Instant::now();
|
||||
log_pass(
|
||||
"Backstop pass",
|
||||
sealer.backstop(time::now_ms()).await,
|
||||
started,
|
||||
);
|
||||
last_backstop = Some(Instant::now());
|
||||
}
|
||||
tokio::time::sleep(PASS_EVERY).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::memory_store::MemoryStore;
|
||||
use crate::sealed_store::sealed_by;
|
||||
|
||||
const K1: &str = "k1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
|
||||
async fn setup() -> (Arc<MemoryStore>, SealedStore, Sealer) {
|
||||
let raw = Arc::new(MemoryStore::default());
|
||||
let keyring = Arc::new(Keyring::parse(K1).unwrap());
|
||||
let sealed = SealedStore::new(raw.clone(), keyring.clone());
|
||||
let sealer = Sealer::new(raw.clone(), keyring).await.unwrap();
|
||||
(raw, sealed, sealer)
|
||||
}
|
||||
|
||||
async fn is_sealed(raw: &MemoryStore, key: &str) -> bool {
|
||||
sealed_by(&raw.head_object(key).await.unwrap()).is_some()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn records_seal_direct_uploads_until_their_urls_are_dead() {
|
||||
let (raw, sealed, sealer) = setup().await;
|
||||
note_direct_uploads(&sealed, &["a".to_string(), "b".to_string()], 10_000)
|
||||
.await
|
||||
.unwrap();
|
||||
let record = raw
|
||||
.keys()
|
||||
.into_iter()
|
||||
.find(|k| k.starts_with(PENDING))
|
||||
.unwrap();
|
||||
assert!(
|
||||
raw.get(&record).unwrap().starts_with("DSAR"),
|
||||
"the record is sealed"
|
||||
);
|
||||
|
||||
// "a" is uploaded, "b" not yet.
|
||||
raw.set("a", "plain a");
|
||||
let report = sealer.pending_pass(5_000).await.unwrap();
|
||||
assert_eq!(
|
||||
(report.records, report.sealed, report.records_done),
|
||||
(1, 1, 0)
|
||||
);
|
||||
assert!(is_sealed(&raw, "a").await);
|
||||
assert_eq!(sealed.get_object_text("a").await.unwrap(), "plain a");
|
||||
|
||||
// A live record is not sealed again.
|
||||
raw.set("b", "plain b");
|
||||
let report = sealer.pending_pass(6_000).await.unwrap();
|
||||
assert_eq!((report.records, report.checked), (1, 0));
|
||||
|
||||
// The URL writes again before it expires; the pass after it is dead
|
||||
// seals that too.
|
||||
raw.set("a", "plain a2");
|
||||
let report = sealer
|
||||
.pending_pass(10_000 + RECORD_GRACE_MS + 1)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((report.sealed, report.records_done), (2, 1));
|
||||
assert_eq!(sealed.get_object_text("a").await.unwrap(), "plain a2");
|
||||
assert!(raw.keys().iter().all(|k| !k.starts_with(PENDING)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_backstop_seals_recent_plaintext_and_remembers_where_it_stopped() {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
let (raw, sealed, sealer) = setup().await;
|
||||
let start = 10 * 3600 * 1000;
|
||||
raw.clock.store(start, SeqCst);
|
||||
raw.set("old", "x");
|
||||
raw.clock.store(start + 3 * 3600 * 1000, SeqCst);
|
||||
raw.set("new", "y");
|
||||
let first = raw.clock.load(SeqCst) + 1;
|
||||
let report = sealer.backstop(first).await.unwrap();
|
||||
assert_eq!(
|
||||
report.sealed, 1,
|
||||
"a first pass looks back FIRST_BACKSTOP_MS"
|
||||
);
|
||||
assert!(is_sealed(&raw, "new").await);
|
||||
assert!(
|
||||
!is_sealed(&raw, "old").await,
|
||||
"older objects are the migration's"
|
||||
);
|
||||
assert!(
|
||||
sealed
|
||||
.get_object_text(STATE)
|
||||
.await
|
||||
.unwrap()
|
||||
.contains(&first.to_string())
|
||||
);
|
||||
|
||||
raw.set("later", "z");
|
||||
let report = sealer.backstop(raw.clock.load(SeqCst) + 1).await.unwrap();
|
||||
assert_eq!(report.sealed, 1);
|
||||
assert!(is_sealed(&raw, "later").await);
|
||||
assert!(!is_sealed(&raw, "old").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn abandoned_uploads_in_parts_are_aborted() {
|
||||
let (raw, _, sealer) = setup().await;
|
||||
raw.create_multipart("k", None, &[]).await.unwrap();
|
||||
sealer.backstop(1).await.unwrap();
|
||||
assert_eq!(raw.list_multipart("").await.unwrap().len(), 1);
|
||||
sealer.backstop(ABANDONED_MS + 1_000_000).await.unwrap();
|
||||
assert!(raw.list_multipart("").await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_name_their_expiry() {
|
||||
assert_eq!(
|
||||
record_expiry(&format!("{PENDING}0000000012345-abc")),
|
||||
Some(12345)
|
||||
);
|
||||
assert_eq!(record_expiry("other"), None);
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,40 @@ pub trait Store: Send + Sync {
|
||||
body: ByteStream,
|
||||
upload: Upload<'_>,
|
||||
) -> Result<(), StoreError>;
|
||||
/// Starts a multipart upload; the object gets `content_type` and `metadata`.
|
||||
async fn create_multipart(
|
||||
&self,
|
||||
key: &str,
|
||||
content_type: Option<&str>,
|
||||
metadata: &[(String, String)],
|
||||
) -> Result<String, StoreError>;
|
||||
/// One part, `length` bytes from `body`. Returns its ETag.
|
||||
async fn upload_part(
|
||||
&self,
|
||||
key: &str,
|
||||
upload_id: &str,
|
||||
part: u32,
|
||||
body: ByteStream,
|
||||
length: u64,
|
||||
) -> Result<String, StoreError>;
|
||||
/// Joins the parts, in order, by their ETags.
|
||||
async fn complete_multipart(
|
||||
&self,
|
||||
key: &str,
|
||||
upload_id: &str,
|
||||
etags: &[String],
|
||||
) -> Result<(), StoreError>;
|
||||
async fn abort_multipart(&self, key: &str, upload_id: &str) -> Result<(), StoreError>;
|
||||
/// Unfinished uploads under `prefix`.
|
||||
async fn list_multipart(&self, prefix: &str) -> Result<Vec<PendingUpload>, StoreError>;
|
||||
}
|
||||
|
||||
/// An unfinished multipart upload.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PendingUpload {
|
||||
pub key: String,
|
||||
pub upload_id: String,
|
||||
pub initiated_ms: Option<i64>,
|
||||
}
|
||||
|
||||
pub struct Download {
|
||||
|
||||
+48
-18
@@ -195,17 +195,36 @@ impl SyncService {
|
||||
self.transfer.as_ref()
|
||||
}
|
||||
|
||||
fn put_url(
|
||||
/// `transfer` of a presign-upload answer when the store is sealed: the
|
||||
/// signed URL newer apps upload through, in parts. Older apps keep the
|
||||
/// direct storage URL, whose writes the sealer seals afterwards.
|
||||
fn transfer_json(
|
||||
&self,
|
||||
origin: &str,
|
||||
key: &str,
|
||||
expires_in: f64,
|
||||
metadata: &[(String, String)],
|
||||
now: i64,
|
||||
) -> String {
|
||||
match &self.transfer {
|
||||
Some(signer) => signer.url(origin, "PUT", key, expires_ms(now, expires_in), metadata),
|
||||
None => self.presigner.put_url(key, expires_in, metadata, now),
|
||||
) -> Option<Value> {
|
||||
let signer = self.transfer.as_ref()?;
|
||||
Some(json!({
|
||||
"url": signer.url(origin, "PUT", key, expires_ms(now, expires_in), metadata),
|
||||
"partSize": crate::transfer_parts::PART_SIZE,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Notes the keys handed direct storage URLs, so the sealer seals what
|
||||
/// older apps write there.
|
||||
async fn note_direct_uploads(&self, keys: &[String], now: i64, expires_in: f64) {
|
||||
if self.transfer.is_none() || keys.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) =
|
||||
crate::sealer::note_direct_uploads(self.store.as_ref(), keys, expires_ms(now, expires_in))
|
||||
.await
|
||||
{
|
||||
// The hourly backstop pass still finds these objects.
|
||||
log::warn(CONTEXT, &format!("Direct uploads not noted err=\"{e}\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,16 +259,13 @@ impl SyncService {
|
||||
/// publish its storage host.
|
||||
/// Withheld too when transfers come here: devices never reach storage.
|
||||
pub fn diagnostic_storage_endpoint(&self) -> Option<&str> {
|
||||
(!self.cloud && self.transfer.is_none()).then_some(self.public_endpoint.as_str())
|
||||
(!self.cloud).then_some(self.public_endpoint.as_str())
|
||||
}
|
||||
|
||||
/// A single-label host (`minio`, `s3`) resolves only inside a container
|
||||
/// network, so every presigned URL built from it fails for the desktop
|
||||
/// client while the server's own S3 calls succeed. Say it once at boot.
|
||||
pub fn warn_if_presign_endpoint_is_server_only(&self) {
|
||||
if self.transfer.is_some() {
|
||||
return;
|
||||
}
|
||||
let Ok(url) = url::Url::parse(&self.public_endpoint) else {
|
||||
return;
|
||||
};
|
||||
@@ -508,13 +524,15 @@ impl SyncService {
|
||||
let expires_in = clamp_expires_in(body.field("expiresIn")?);
|
||||
let now = time::now_ms();
|
||||
let expires_at = iso_at(now as f64 + expires_in * 1000.0);
|
||||
let url = self.put_url(
|
||||
origin,
|
||||
require_key(&key)?,
|
||||
expires_in,
|
||||
metadata.as_deref().unwrap_or(&[]),
|
||||
now,
|
||||
);
|
||||
let scoped = require_key(&key)?;
|
||||
let metadata_or_none = metadata.as_deref().unwrap_or(&[]);
|
||||
let url = self
|
||||
.presigner
|
||||
.put_url(scoped, expires_in, metadata_or_none, now);
|
||||
let transfer = self.transfer_json(origin, scoped, expires_in, metadata_or_none, now);
|
||||
self
|
||||
.note_direct_uploads(&[scoped.to_string()], now, expires_in)
|
||||
.await;
|
||||
|
||||
if ctx.mode == Mode::Cloud && field_starts_with(raw_key, "profiles/")? {
|
||||
self.report_profile_usage_async(ctx);
|
||||
@@ -527,6 +545,9 @@ impl SyncService {
|
||||
if let Some(metadata) = &metadata {
|
||||
out.insert("metadata".to_string(), metadata_json(metadata));
|
||||
}
|
||||
if let Some(transfer) = transfer {
|
||||
out.insert("transfer".to_string(), transfer);
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
|
||||
@@ -632,7 +653,9 @@ impl SyncService {
|
||||
let objects: Vec<Value> = response
|
||||
.contents
|
||||
.iter()
|
||||
.filter(|object| !object.key.ends_with(MANIFEST_KEY))
|
||||
.filter(|object| {
|
||||
!object.key.ends_with(MANIFEST_KEY) && !object.key.starts_with(crate::sealer::INTERNAL_ROOT)
|
||||
})
|
||||
.map(|object| {
|
||||
let key = if !user_prefix.is_empty() {
|
||||
object.key.strip_prefix(user_prefix).unwrap_or(&object.key)
|
||||
@@ -708,16 +731,23 @@ impl SyncService {
|
||||
)
|
||||
.await?;
|
||||
let mut out_items = Vec::with_capacity(keys.len());
|
||||
let mut direct = Vec::with_capacity(keys.len());
|
||||
for (raw, key) in raw_keys.iter().zip(&keys) {
|
||||
let url = self.put_url(origin, require_key(key)?, expires_in, &[], now);
|
||||
let scoped = require_key(key)?;
|
||||
let url = self.presigner.put_url(scoped, expires_in, &[], now);
|
||||
let mut item = Map::new();
|
||||
if let Field::Value(value) = raw {
|
||||
item.insert("key".to_string(), (*value).clone());
|
||||
}
|
||||
item.insert("url".to_string(), Value::String(url));
|
||||
item.insert("expiresAt".to_string(), Value::String(expires_at.clone()));
|
||||
if let Some(transfer) = self.transfer_json(origin, scoped, expires_in, &[], now) {
|
||||
item.insert("transfer".to_string(), transfer);
|
||||
}
|
||||
out_items.push(Value::Object(item));
|
||||
direct.push(scoped.to_string());
|
||||
}
|
||||
self.note_direct_uploads(&direct, now, expires_in).await;
|
||||
|
||||
if ctx.mode == Mode::Cloud {
|
||||
let mut any_profile = false;
|
||||
|
||||
+173
-70
@@ -3,6 +3,9 @@
|
||||
//! signed and expiring like S3's, and the body streams through the sealed
|
||||
//! store. A device sends and reads exactly what it did with S3: the same
|
||||
//! method, `Content-Length`, `Content-Type` and the signed `x-amz-meta-*`.
|
||||
//!
|
||||
//! A request body through a proxy such as Cloudflare has a size and time
|
||||
//! limit, so newer apps upload large files in parts (`transfer_parts`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -19,16 +22,23 @@ use crate::at_rest::Keyring;
|
||||
use crate::http::{BoxError, ResBody, drain, full};
|
||||
use crate::store::{Store, StoreError, Upload};
|
||||
use crate::time;
|
||||
use crate::transfer_parts;
|
||||
|
||||
pub const PATH: &str = "/v1/transfer";
|
||||
/// Transfers streamed at a time; more wait their turn. Each holds a few
|
||||
/// frames in memory.
|
||||
const MAX_TRANSFERS: usize = 64;
|
||||
const META_PARAM: &str = "meta-";
|
||||
/// Query parameters a part request adds to a signed URL. The upload token
|
||||
/// authenticates them, so they are not part of the URL's signature.
|
||||
const UPLOAD_PARAM: &str = "upload";
|
||||
const PART_PARAM: &str = "part";
|
||||
const OP_PARAM: &str = "op";
|
||||
|
||||
pub struct TransferSigner {
|
||||
/// The current key's first; any of them verifies.
|
||||
keys: Vec<[u8; 32]>,
|
||||
pub(crate) keyring: Arc<Keyring>,
|
||||
slots: Arc<tokio::sync::Semaphore>,
|
||||
}
|
||||
|
||||
@@ -39,6 +49,16 @@ pub struct Grant {
|
||||
pub metadata: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// A URL whose signature holds, with what a part request added to it.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Signed {
|
||||
pub grant: Grant,
|
||||
pub expires_ms: i64,
|
||||
pub upload: Option<String>,
|
||||
pub part: Option<String>,
|
||||
pub op: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Refusal {
|
||||
Expired,
|
||||
@@ -51,9 +71,10 @@ fn field(mac: &mut Hmac<Sha256>, value: &str) {
|
||||
}
|
||||
|
||||
impl TransferSigner {
|
||||
pub fn new(keyring: &Keyring) -> Self {
|
||||
pub fn new(keyring: &Arc<Keyring>) -> Self {
|
||||
Self {
|
||||
keys: keyring.url_keys(),
|
||||
keyring: keyring.clone(),
|
||||
slots: Arc::new(tokio::sync::Semaphore::new(MAX_TRANSFERS)),
|
||||
}
|
||||
}
|
||||
@@ -102,15 +123,28 @@ impl TransferSigner {
|
||||
}
|
||||
|
||||
pub fn verify(&self, method: &str, query: &str, now_ms: i64) -> Result<Grant, Refusal> {
|
||||
let signed = self.check(method, query)?;
|
||||
if now_ms > signed.expires_ms {
|
||||
return Err(Refusal::Expired);
|
||||
}
|
||||
Ok(signed.grant)
|
||||
}
|
||||
|
||||
/// The signature only; the caller decides whether the URL's time counts.
|
||||
pub fn check(&self, method: &str, query: &str) -> Result<Signed, Refusal> {
|
||||
let mut key = None;
|
||||
let mut expires = None;
|
||||
let mut signature = None;
|
||||
let mut metadata = Vec::new();
|
||||
let (mut upload, mut part, mut op) = (None, None, None);
|
||||
for (name, value) in url::form_urlencoded::parse(query.as_bytes()) {
|
||||
match name.as_ref() {
|
||||
"key" if key.is_none() => key = Some(value.into_owned()),
|
||||
"expires" if expires.is_none() => expires = Some(value.into_owned()),
|
||||
"sig" if signature.is_none() => signature = Some(value.into_owned()),
|
||||
UPLOAD_PARAM if upload.is_none() => upload = Some(value.into_owned()),
|
||||
PART_PARAM if part.is_none() => part = Some(value.into_owned()),
|
||||
OP_PARAM if op.is_none() => op = Some(value.into_owned()),
|
||||
other => match other.strip_prefix(META_PARAM) {
|
||||
Some(meta) => metadata.push((meta.to_string(), value.into_owned())),
|
||||
None => return Err(Refusal::BadSignature),
|
||||
@@ -130,15 +164,34 @@ impl TransferSigner {
|
||||
return Err(Refusal::BadSignature);
|
||||
}
|
||||
let expires_ms: i64 = expires.parse().map_err(|_| Refusal::BadSignature)?;
|
||||
if now_ms > expires_ms {
|
||||
return Err(Refusal::Expired);
|
||||
}
|
||||
Ok(Grant { key, metadata })
|
||||
Ok(Signed {
|
||||
grant: Grant { key, metadata },
|
||||
expires_ms,
|
||||
upload,
|
||||
part,
|
||||
op,
|
||||
})
|
||||
}
|
||||
|
||||
/// HMAC of `data` under the current key, for upload tokens.
|
||||
pub(crate) fn tag(&self, data: &[u8]) -> Vec<u8> {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(&self.keys[0]).expect("HMAC takes any key length");
|
||||
mac.update(data);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Whether `tag` is `data`'s under any key.
|
||||
pub(crate) fn tag_holds(&self, data: &[u8], tag: &[u8]) -> bool {
|
||||
self.keys.iter().any(|key| {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC takes any key length");
|
||||
mac.update(data);
|
||||
mac.verify_slice(tag).is_ok()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// An S3-style error document, so a device logs what S3 would have said.
|
||||
fn s3_error(status: u16, code: &str, message: &str) -> Response<ResBody> {
|
||||
pub(crate) fn s3_error(status: u16, code: &str, message: &str) -> Response<ResBody> {
|
||||
let body = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Error><Code>{code}</Code><Message>{message}</Message></Error>"
|
||||
);
|
||||
@@ -150,7 +203,7 @@ fn s3_error(status: u16, code: &str, message: &str) -> Response<ResBody> {
|
||||
.expect("static response parts are valid")
|
||||
}
|
||||
|
||||
fn store_refusal(error: StoreError) -> Response<ResBody> {
|
||||
pub(crate) fn store_refusal(error: StoreError) -> Response<ResBody> {
|
||||
match error {
|
||||
StoreError::NotFound => s3_error(404, "NoSuchKey", "The specified key does not exist."),
|
||||
StoreError::Service { status, code, .. } if (400..500).contains(&status) => {
|
||||
@@ -167,7 +220,7 @@ fn store_refusal(error: StoreError) -> Response<ResBody> {
|
||||
}
|
||||
}
|
||||
|
||||
fn header_text(parts: &Parts, name: impl header::AsHeaderName) -> Option<String> {
|
||||
pub(crate) fn header_text(parts: &Parts, name: impl header::AsHeaderName) -> Option<String> {
|
||||
parts
|
||||
.headers
|
||||
.get(name)
|
||||
@@ -180,37 +233,110 @@ pub async fn serve(
|
||||
parts: &Parts,
|
||||
body: Incoming,
|
||||
) -> Response<ResBody> {
|
||||
let method = parts.method.as_str();
|
||||
let grant = match signer.verify(method, parts.uri.query().unwrap_or(""), time::now_ms()) {
|
||||
Ok(grant) if parts.method == Method::GET || parts.method == Method::PUT => grant,
|
||||
Ok(_) | Err(Refusal::BadSignature) => {
|
||||
// A GET URL reads; a PUT URL writes, in one request or in parts.
|
||||
let signed_for = if parts.method == Method::GET {
|
||||
"GET"
|
||||
} else {
|
||||
"PUT"
|
||||
};
|
||||
let signed = match signer.check(signed_for, parts.uri.query().unwrap_or("")) {
|
||||
Ok(signed) => signed,
|
||||
Err(_) => {
|
||||
drain(body).await;
|
||||
return s3_error(
|
||||
403,
|
||||
"SignatureDoesNotMatch",
|
||||
"The request signature we calculated does not match the signature you provided.",
|
||||
);
|
||||
}
|
||||
Err(Refusal::Expired) => {
|
||||
drain(body).await;
|
||||
return s3_error(403, "AccessDenied", "Request has expired");
|
||||
return bad_signature();
|
||||
}
|
||||
};
|
||||
// Parts carry an upload token, which outlives the URL that started them.
|
||||
if signed.upload.is_none() && time::now_ms() > signed.expires_ms {
|
||||
drain(body).await;
|
||||
return s3_error(403, "AccessDenied", "Request has expired");
|
||||
}
|
||||
let slot = signer
|
||||
.slots
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.expect("the semaphore is never closed");
|
||||
if parts.method == Method::GET {
|
||||
drain(body).await;
|
||||
return download(store, &grant.key, slot).await;
|
||||
}
|
||||
let response = upload(store, grant, parts, body).await;
|
||||
let response = match (&parts.method, signed.upload.is_some()) {
|
||||
(&Method::GET, false) => {
|
||||
drain(body).await;
|
||||
return download(store, &signed.grant.key, slot).await;
|
||||
}
|
||||
(&Method::PUT, false) => upload(store, signed.grant, parts, body).await,
|
||||
(&Method::POST, false) => transfer_parts::start(store, signer, signed.grant, parts, body).await,
|
||||
(&Method::PUT | &Method::POST | &Method::DELETE, true) => {
|
||||
transfer_parts::serve(store, signer, signed, parts, body).await
|
||||
}
|
||||
_ => {
|
||||
drain(body).await;
|
||||
bad_signature()
|
||||
}
|
||||
};
|
||||
drop(slot);
|
||||
response
|
||||
}
|
||||
|
||||
pub(crate) fn bad_signature() -> Response<ResBody> {
|
||||
s3_error(
|
||||
403,
|
||||
"SignatureDoesNotMatch",
|
||||
"The request signature we calculated does not match the signature you provided.",
|
||||
)
|
||||
}
|
||||
|
||||
/// The `x-amz-meta-*` a request sent, when they are exactly the signed ones.
|
||||
pub(crate) fn signed_metadata(
|
||||
parts: &Parts,
|
||||
grant: Grant,
|
||||
) -> Result<Vec<(String, String)>, Box<Response<ResBody>>> {
|
||||
let mut sent: Vec<(String, String)> = parts
|
||||
.headers
|
||||
.iter()
|
||||
.filter_map(|(name, value)| {
|
||||
name.as_str().strip_prefix("x-amz-meta-").map(|n| {
|
||||
(
|
||||
n.to_string(),
|
||||
value.as_bytes().iter().map(|&b| b as char).collect(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
sent.sort();
|
||||
let mut signed = grant.metadata;
|
||||
signed.sort();
|
||||
if sent != signed {
|
||||
return Err(Box::new(s3_error(
|
||||
403,
|
||||
"AccessDenied",
|
||||
"There were headers present in the request which were not signed",
|
||||
)));
|
||||
}
|
||||
Ok(signed)
|
||||
}
|
||||
|
||||
/// A request body as a store stream.
|
||||
pub(crate) fn body_stream(body: Incoming) -> crate::store::ByteStream {
|
||||
Box::pin(
|
||||
body
|
||||
.into_data_stream()
|
||||
.map(|chunk: Result<Bytes, hyper::Error>| {
|
||||
chunk.map_err(|e| StoreError::Transport(e.to_string()))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn content_length(parts: &Parts) -> Option<u64> {
|
||||
header_text(parts, header::CONTENT_LENGTH).and_then(|v| v.trim().parse().ok())
|
||||
}
|
||||
|
||||
pub(crate) fn missing_length() -> Response<ResBody> {
|
||||
s3_error(
|
||||
411,
|
||||
"MissingContentLength",
|
||||
"You must provide the Content-Length HTTP header.",
|
||||
)
|
||||
}
|
||||
|
||||
async fn download(
|
||||
store: &Arc<dyn Store>,
|
||||
key: &str,
|
||||
@@ -259,48 +385,23 @@ async fn upload(
|
||||
parts: &Parts,
|
||||
body: Incoming,
|
||||
) -> Response<ResBody> {
|
||||
let mut sent: Vec<(String, String)> = parts
|
||||
.headers
|
||||
.iter()
|
||||
.filter_map(|(name, value)| {
|
||||
name.as_str().strip_prefix("x-amz-meta-").map(|n| {
|
||||
(
|
||||
n.to_string(),
|
||||
value.as_bytes().iter().map(|&b| b as char).collect(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
sent.sort();
|
||||
let mut signed = grant.metadata;
|
||||
signed.sort();
|
||||
if sent != signed {
|
||||
let key = grant.key.clone();
|
||||
let signed = match signed_metadata(parts, grant) {
|
||||
Ok(signed) => signed,
|
||||
Err(refusal) => {
|
||||
drain(body).await;
|
||||
return *refusal;
|
||||
}
|
||||
};
|
||||
let Some(length) = content_length(parts) else {
|
||||
drain(body).await;
|
||||
return s3_error(
|
||||
403,
|
||||
"AccessDenied",
|
||||
"There were headers present in the request which were not signed",
|
||||
);
|
||||
}
|
||||
let Some(length) = header_text(parts, header::CONTENT_LENGTH).and_then(|v| v.trim().parse().ok())
|
||||
else {
|
||||
drain(body).await;
|
||||
return s3_error(
|
||||
411,
|
||||
"MissingContentLength",
|
||||
"You must provide the Content-Length HTTP header.",
|
||||
);
|
||||
return missing_length();
|
||||
};
|
||||
let content_type = header_text(parts, header::CONTENT_TYPE);
|
||||
let stream = body
|
||||
.into_data_stream()
|
||||
.map(|chunk: Result<Bytes, hyper::Error>| {
|
||||
chunk.map_err(|e| StoreError::Transport(e.to_string()))
|
||||
});
|
||||
match store
|
||||
.put_object_stream(
|
||||
&grant.key,
|
||||
Box::pin(stream),
|
||||
&key,
|
||||
body_stream(body),
|
||||
Upload {
|
||||
length,
|
||||
content_type: content_type.as_deref(),
|
||||
@@ -324,7 +425,9 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn signer() -> TransferSigner {
|
||||
TransferSigner::new(&Keyring::parse("k1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=").unwrap())
|
||||
TransferSigner::new(&Arc::new(
|
||||
Keyring::parse("k1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=").unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
fn query(url: &str) -> &str {
|
||||
@@ -377,16 +480,16 @@ mod tests {
|
||||
fn an_old_key_still_verifies_after_rotation() {
|
||||
let old = signer();
|
||||
let url = old.url("http://h", "GET", "k", 50, &[]);
|
||||
let rotated = TransferSigner::new(
|
||||
&Keyring::parse(
|
||||
let rotated = TransferSigner::new(&Arc::new(
|
||||
Keyring::parse(
|
||||
"k2:AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=,k1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
));
|
||||
assert!(rotated.verify("GET", query(&url), 0).is_ok());
|
||||
let removed = TransferSigner::new(
|
||||
&Keyring::parse("k2:AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=").unwrap(),
|
||||
);
|
||||
let removed = TransferSigner::new(&Arc::new(
|
||||
Keyring::parse("k2:AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=").unwrap(),
|
||||
));
|
||||
assert_eq!(
|
||||
removed.verify("GET", query(&url), 0),
|
||||
Err(Refusal::BadSignature)
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
//! Uploads in parts through this server, for a store sealed at rest. A proxy
|
||||
//! in front of the server (Cloudflare) limits each request's size and time,
|
||||
//! so a large file goes up in `PART_SIZE` pieces on the signed PUT URL that
|
||||
//! presign-upload hands out as `transfer.url`:
|
||||
//!
|
||||
//! ```text
|
||||
//! POST url x-donut-upload-length: N, the signed x-amz-meta-*, Content-Type
|
||||
//! -> 200 {"upload": T, "partSize": P, "parts": n}
|
||||
//! PUT url&upload=T&part=i i in 1..=n, exactly P bytes (the last part: the rest)
|
||||
//! -> 200, ETag
|
||||
//! POST url&upload=T&op=complete {"parts": [ETag of part 1, ...]} -> 200
|
||||
//! DELETE url&upload=T -> 204
|
||||
//! ```
|
||||
//!
|
||||
//! The token T is signed and names the object, the storage upload and the
|
||||
//! sealing salt, so the server keeps no state between requests. A part may be
|
||||
//! sent again. Each part is sealed as whole frames of one object, numbered
|
||||
//! where the part starts, so the joined object reads like any sealed object.
|
||||
|
||||
use base64::Engine;
|
||||
use bytes::Bytes;
|
||||
use http_body_util::BodyExt;
|
||||
use hyper::body::Incoming;
|
||||
use hyper::http::request::Parts;
|
||||
use hyper::{Method, Response, StatusCode, header};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::at_rest::{self, FRAME, Salt, Sealer};
|
||||
use crate::http::{ResBody, drain, full, json_response};
|
||||
use crate::store::Store;
|
||||
use crate::time;
|
||||
use crate::transfer::{
|
||||
Grant, Signed, TransferSigner, bad_signature, body_stream, content_length, header_text,
|
||||
missing_length, s3_error, signed_metadata, store_refusal,
|
||||
};
|
||||
|
||||
/// Plaintext bytes per part: far under a proxy's request limit, and sent
|
||||
/// within its timeout on a slow uplink. A whole number of frames, and above
|
||||
/// S3's 5 MB minimum part.
|
||||
pub const PART_SIZE: u64 = 8 * 1024 * 1024;
|
||||
/// How long an upload may take once started.
|
||||
const UPLOAD_TTL_MS: i64 = 24 * 3600 * 1000;
|
||||
pub const LENGTH_HEADER: &str = "x-donut-upload-length";
|
||||
/// S3's most parts in one upload.
|
||||
const MAX_PARTS: u64 = 10_000;
|
||||
const MAX_COMPLETE_BODY: usize = 1024 * 1024;
|
||||
const TOKEN_CONTEXT: &[u8] = b"donut-sync upload token v1\n";
|
||||
|
||||
const _: () = assert!(PART_SIZE.is_multiple_of(FRAME as u64) && PART_SIZE >= 5 * 1024 * 1024);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct Token {
|
||||
key: String,
|
||||
upload_id: String,
|
||||
key_id: String,
|
||||
salt: String,
|
||||
length: u64,
|
||||
part_size: u64,
|
||||
expires_ms: i64,
|
||||
}
|
||||
|
||||
impl Token {
|
||||
fn parts(&self) -> u64 {
|
||||
self.length.div_ceil(self.part_size).max(1)
|
||||
}
|
||||
|
||||
fn part_len(&self, part: u64) -> u64 {
|
||||
if part < self.parts() {
|
||||
self.part_size
|
||||
} else {
|
||||
self.length - (self.parts() - 1) * self.part_size
|
||||
}
|
||||
}
|
||||
|
||||
fn salt(&self) -> Option<Salt> {
|
||||
hex::decode(&self.salt).ok()?.try_into().ok()
|
||||
}
|
||||
|
||||
fn seal(&self, signer: &TransferSigner) -> String {
|
||||
let json = serde_json::to_vec(self).expect("a token serializes");
|
||||
let tag = signer.tag(&[TOKEN_CONTEXT, &json].concat());
|
||||
format!(
|
||||
"{}.{}",
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&json),
|
||||
hex::encode(tag)
|
||||
)
|
||||
}
|
||||
|
||||
/// The token, when it is ours, for `key`, and not expired.
|
||||
fn open(signer: &TransferSigner, text: &str, key: &str, now_ms: i64) -> Option<Self> {
|
||||
let (body, tag) = text.split_once('.')?;
|
||||
let json = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(body)
|
||||
.ok()?;
|
||||
if !signer.tag_holds(&[TOKEN_CONTEXT, &json].concat(), &hex::decode(tag).ok()?) {
|
||||
return None;
|
||||
}
|
||||
let token: Self = serde_json::from_slice(&json).ok()?;
|
||||
(token.key == key && now_ms <= token.expires_ms).then_some(token)
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid(code: &str, message: &str) -> Response<ResBody> {
|
||||
s3_error(400, code, message)
|
||||
}
|
||||
|
||||
/// Starts an upload of `x-donut-upload-length` bytes.
|
||||
pub async fn start(
|
||||
store: &Arc<dyn Store>,
|
||||
signer: &TransferSigner,
|
||||
grant: Grant,
|
||||
parts: &Parts,
|
||||
body: Incoming,
|
||||
) -> Response<ResBody> {
|
||||
drain(body).await;
|
||||
let key = grant.key.clone();
|
||||
let metadata = match signed_metadata(parts, grant) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(refusal) => return *refusal,
|
||||
};
|
||||
let Some(length) = header_text(parts, LENGTH_HEADER).and_then(|v| v.trim().parse::<u64>().ok())
|
||||
else {
|
||||
return invalid(
|
||||
"MissingUploadLength",
|
||||
"Send the file size as x-donut-upload-length.",
|
||||
);
|
||||
};
|
||||
if length.div_ceil(PART_SIZE) > MAX_PARTS {
|
||||
return invalid(
|
||||
"EntityTooLarge",
|
||||
"The file is too large to upload in parts.",
|
||||
);
|
||||
}
|
||||
let content_type = header_text(parts, header::CONTENT_TYPE);
|
||||
let upload_id = match store
|
||||
.create_multipart(&key, content_type.as_deref(), &metadata)
|
||||
.await
|
||||
{
|
||||
Ok(id) => id,
|
||||
Err(e) => return store_refusal(e),
|
||||
};
|
||||
let token = Token {
|
||||
key,
|
||||
upload_id,
|
||||
key_id: signer.keyring.current().id().to_string(),
|
||||
salt: hex::encode(at_rest::new_salt()),
|
||||
length,
|
||||
part_size: PART_SIZE,
|
||||
expires_ms: time::now_ms() + UPLOAD_TTL_MS,
|
||||
};
|
||||
json_response(
|
||||
200,
|
||||
&serde_json::json!({
|
||||
"upload": token.seal(signer),
|
||||
"partSize": token.part_size,
|
||||
"parts": token.parts(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// A part, the completion or the abort of a started upload.
|
||||
pub async fn serve(
|
||||
store: &Arc<dyn Store>,
|
||||
signer: &TransferSigner,
|
||||
signed: Signed,
|
||||
parts: &Parts,
|
||||
body: Incoming,
|
||||
) -> Response<ResBody> {
|
||||
let token = signed
|
||||
.upload
|
||||
.as_deref()
|
||||
.and_then(|text| Token::open(signer, text, &signed.grant.key, time::now_ms()));
|
||||
let Some(token) = token else {
|
||||
drain(body).await;
|
||||
return bad_signature();
|
||||
};
|
||||
match (&parts.method, signed.op.as_deref()) {
|
||||
(&Method::PUT, None) => part(store, signer, &token, signed.part.as_deref(), parts, body).await,
|
||||
(&Method::POST, Some("complete")) => complete(store, &token, body).await,
|
||||
(&Method::DELETE, None) => {
|
||||
drain(body).await;
|
||||
match store.abort_multipart(&token.key, &token.upload_id).await {
|
||||
Ok(()) => Response::builder()
|
||||
.status(StatusCode::NO_CONTENT)
|
||||
.body(full(Bytes::new()))
|
||||
.expect("static response parts are valid"),
|
||||
Err(e) => store_refusal(e),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
drain(body).await;
|
||||
bad_signature()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn part(
|
||||
store: &Arc<dyn Store>,
|
||||
signer: &TransferSigner,
|
||||
token: &Token,
|
||||
number: Option<&str>,
|
||||
parts: &Parts,
|
||||
body: Incoming,
|
||||
) -> Response<ResBody> {
|
||||
let Some(number) = number
|
||||
.and_then(|n| n.parse::<u64>().ok())
|
||||
.filter(|n| (1..=token.parts()).contains(n))
|
||||
else {
|
||||
drain(body).await;
|
||||
return invalid(
|
||||
"InvalidPart",
|
||||
"The part number is not one of this upload's.",
|
||||
);
|
||||
};
|
||||
let Some(length) = content_length(parts) else {
|
||||
drain(body).await;
|
||||
return missing_length();
|
||||
};
|
||||
let expected = token.part_len(number);
|
||||
if length != expected {
|
||||
drain(body).await;
|
||||
return invalid(
|
||||
"InvalidPart",
|
||||
&format!("Part {number} must be {expected} bytes, not {length}."),
|
||||
);
|
||||
}
|
||||
let first_frame = (number - 1) * token.part_size / FRAME as u64;
|
||||
let sealer = token
|
||||
.salt()
|
||||
.and_then(|salt| Sealer::for_part(&signer.keyring, &token.key_id, &salt, first_frame));
|
||||
let Some(sealer) = sealer else {
|
||||
drain(body).await;
|
||||
return invalid("InvalidUpload", "This upload's key is no longer held.");
|
||||
};
|
||||
let last = number == token.parts();
|
||||
let sealed_len = at_rest::sealed_part_len(&token.key_id, length, number == 1, last);
|
||||
let sealed = at_rest::seal_part_stream(sealer, last, body_stream(body));
|
||||
match store
|
||||
.upload_part(
|
||||
&token.key,
|
||||
&token.upload_id,
|
||||
number as u32,
|
||||
sealed,
|
||||
sealed_len,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(etag) => Response::builder()
|
||||
.status(200)
|
||||
.header(header::ETAG, etag)
|
||||
.header(header::CONTENT_LENGTH, 0)
|
||||
.body(full(Bytes::new()))
|
||||
.unwrap_or_else(|_| s3_error(500, "InternalError", "Storage gave an invalid ETag.")),
|
||||
Err(e) => store_refusal(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Completion {
|
||||
parts: Vec<String>,
|
||||
}
|
||||
|
||||
async fn complete(store: &Arc<dyn Store>, token: &Token, body: Incoming) -> Response<ResBody> {
|
||||
let bytes = match http_body_util::Limited::new(body, MAX_COMPLETE_BODY)
|
||||
.collect()
|
||||
.await
|
||||
{
|
||||
Ok(collected) => collected.to_bytes(),
|
||||
Err(_) => return invalid("MalformedXML", "The completion body is too large."),
|
||||
};
|
||||
let Ok(completion) = serde_json::from_slice::<Completion>(&bytes) else {
|
||||
return invalid("MalformedXML", "Send {\"parts\": [ETag, ...]}.");
|
||||
};
|
||||
if completion.parts.len() as u64 != token.parts() {
|
||||
return invalid(
|
||||
"InvalidPart",
|
||||
&format!("This upload has {} parts.", token.parts()),
|
||||
);
|
||||
}
|
||||
match store
|
||||
.complete_multipart(&token.key, &token.upload_id, &completion.parts)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Response::builder()
|
||||
.status(200)
|
||||
.header(header::CONTENT_LENGTH, 0)
|
||||
.body(full(Bytes::new()))
|
||||
.expect("static response parts are valid"),
|
||||
Err(e) => store_refusal(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::at_rest::Keyring;
|
||||
|
||||
fn signer(keys: &str) -> TransferSigner {
|
||||
TransferSigner::new(&Arc::new(Keyring::parse(keys).unwrap()))
|
||||
}
|
||||
|
||||
const K1: &str = "k1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||
|
||||
fn token(length: u64) -> Token {
|
||||
Token {
|
||||
key: "users/u/a".to_string(),
|
||||
upload_id: "id".to_string(),
|
||||
key_id: "k1".to_string(),
|
||||
salt: hex::encode([7u8; 32]),
|
||||
length,
|
||||
part_size: PART_SIZE,
|
||||
expires_ms: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_are_bound_to_their_key_signer_and_time() {
|
||||
let signer = signer(K1);
|
||||
let text = token(5).seal(&signer);
|
||||
assert_eq!(
|
||||
Token::open(&signer, &text, "users/u/a", 1000),
|
||||
Some(token(5))
|
||||
);
|
||||
assert_eq!(Token::open(&signer, &text, "users/u/b", 0), None);
|
||||
assert_eq!(Token::open(&signer, &text, "users/u/a", 1001), None);
|
||||
let other = signer_other();
|
||||
assert_eq!(Token::open(&other, &text, "users/u/a", 0), None);
|
||||
let (body, tag) = text.split_once('.').unwrap();
|
||||
let mut json = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(body)
|
||||
.unwrap();
|
||||
let at = json.iter().position(|&b| b == b'5').unwrap();
|
||||
json[at] = b'6';
|
||||
let forged = format!(
|
||||
"{}.{tag}",
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json)
|
||||
);
|
||||
assert_eq!(Token::open(&signer, &forged, "users/u/a", 0), None);
|
||||
}
|
||||
|
||||
fn signer_other() -> TransferSigner {
|
||||
signer("k2:AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parts_split_the_length() {
|
||||
assert_eq!((token(0).parts(), token(0).part_len(1)), (1, 0));
|
||||
assert_eq!(token(PART_SIZE).parts(), 1);
|
||||
let t = token(2 * PART_SIZE + 3);
|
||||
assert_eq!(t.parts(), 3);
|
||||
assert_eq!(
|
||||
(t.part_len(1), t.part_len(2), t.part_len(3)),
|
||||
(PART_SIZE, PART_SIZE, 3)
|
||||
);
|
||||
}
|
||||
}
|
||||
+10
-10
@@ -12,7 +12,6 @@ use futures_util::StreamExt;
|
||||
use crate::at_rest::{self, Keyring, MARKER};
|
||||
use crate::config::Config;
|
||||
use crate::log;
|
||||
use crate::migrate::TMP_PREFIX;
|
||||
use crate::sealed_store::sealed_by;
|
||||
use crate::store::{ListInput, Store, StoreError};
|
||||
|
||||
@@ -135,8 +134,9 @@ pub struct Report {
|
||||
pub unknown_key: u64,
|
||||
/// A marker of another format version, or a size no sealed object has.
|
||||
pub invalid: u64,
|
||||
/// Leftover temporary copies of an interrupted migration.
|
||||
pub temporary: u64,
|
||||
/// This server's own objects: records of direct uploads, and leftovers of
|
||||
/// an interrupted migration.
|
||||
pub internal: u64,
|
||||
/// Gone between the listing and the HEAD.
|
||||
pub vanished: u64,
|
||||
pub sampled: u64,
|
||||
@@ -160,10 +160,10 @@ impl Report {
|
||||
));
|
||||
}
|
||||
line.push_str(&format!(
|
||||
" unknown_key={} invalid={} temporary={} vanished={} sampled={} sample_failed={}",
|
||||
" unknown_key={} invalid={} internal={} vanished={} sampled={} sample_failed={}",
|
||||
self.unknown_key,
|
||||
self.invalid,
|
||||
self.temporary,
|
||||
self.internal,
|
||||
self.vanished,
|
||||
self.sampled,
|
||||
self.sample_failed
|
||||
@@ -252,12 +252,12 @@ pub async fn verify(
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Listing failed: {e}"))?;
|
||||
let (temporary, keys): (Vec<String>, Vec<String>) = page
|
||||
let (internal, keys): (Vec<String>, Vec<String>) = page
|
||||
.contents
|
||||
.into_iter()
|
||||
.map(|object| object.key)
|
||||
.partition(|key| key.starts_with(TMP_PREFIX));
|
||||
report.temporary += temporary.len() as u64;
|
||||
.partition(|key| key.starts_with(crate::sealer::INTERNAL_ROOT));
|
||||
report.internal += internal.len() as u64;
|
||||
let classified: Vec<(String, Classified)> = futures_util::stream::iter(keys)
|
||||
.map(|key| {
|
||||
let store = store.clone();
|
||||
@@ -394,7 +394,7 @@ mod tests {
|
||||
.put_object("c", b"ccc".to_vec(), "text/plain", &[])
|
||||
.await
|
||||
.unwrap();
|
||||
raw.set(&format!("{TMP_PREFIX}left"), "x");
|
||||
raw.set(&format!("{}left", crate::migrate::TMP_PREFIX), "x");
|
||||
let keys = keyring(&format!("{K1},{K2}"));
|
||||
|
||||
let before = verify(raw.clone(), keys.clone(), &Options::default())
|
||||
@@ -408,7 +408,7 @@ mod tests {
|
||||
assert_eq!(before.sealed["k2"].objects, 1);
|
||||
assert_eq!(before.sealed["k2"].plain_bytes, 3);
|
||||
assert_eq!(
|
||||
(before.temporary, before.sampled, before.sample_failed),
|
||||
(before.internal, before.sampled, before.sample_failed),
|
||||
(1, 1, 0)
|
||||
);
|
||||
|
||||
|
||||
+240
-5
@@ -33,7 +33,8 @@ async fn call(server: &Server, route: &str, body: Value) -> (u16, Value) {
|
||||
.await
|
||||
}
|
||||
|
||||
/// What the app does: presign, then PUT with the echoed metadata.
|
||||
/// What a newer app does with a small file: presign, then PUT to the
|
||||
/// transfer URL with the echoed metadata.
|
||||
async fn upload(server: &Server, key: &str, body: Vec<u8>, updated_at: Option<&str>) -> String {
|
||||
let mut request = json!({ "key": key, "contentType": "application/octet-stream" });
|
||||
if let Some(updated_at) = updated_at {
|
||||
@@ -41,7 +42,15 @@ async fn upload(server: &Server, key: &str, body: Vec<u8>, updated_at: Option<&s
|
||||
}
|
||||
let (status, presign) = call(server, "presign-upload", request).await;
|
||||
assert_eq!(status, 200, "{presign}");
|
||||
let url = presign["url"].as_str().unwrap().to_string();
|
||||
// Older apps keep the direct storage URL.
|
||||
assert!(
|
||||
presign["url"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with(common::S3_ENDPOINT)
|
||||
);
|
||||
assert_eq!(presign["transfer"]["partSize"], json!(8 * 1024 * 1024));
|
||||
let url = presign["transfer"]["url"].as_str().unwrap().to_string();
|
||||
let mut put = http()
|
||||
.put(&url)
|
||||
.header("Content-Length", body.len().to_string())
|
||||
@@ -111,7 +120,11 @@ async fn transfers_go_through_the_server_and_are_sealed_in_storage() {
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(readyz, json!({ "status": "ready", "s3": true }));
|
||||
// Older apps still upload to storage directly, so its address is named.
|
||||
assert_eq!(
|
||||
readyz,
|
||||
json!({ "status": "ready", "s3": true, "storageEndpoint": common::S3_ENDPOINT })
|
||||
);
|
||||
assert!(
|
||||
server
|
||||
.logs()
|
||||
@@ -132,8 +145,13 @@ async fn batches_and_downloads_keep_the_s3_shapes() {
|
||||
for item in batch["items"].as_array().unwrap() {
|
||||
let key = item["key"].as_str().unwrap();
|
||||
assert!(item["expiresAt"].is_string());
|
||||
// "a" as a newer app sends it, "b" as an older one.
|
||||
let url = match key {
|
||||
"a" => item["transfer"]["url"].as_str().unwrap(),
|
||||
_ => item["url"].as_str().unwrap(),
|
||||
};
|
||||
let response = http()
|
||||
.put(item["url"].as_str().unwrap())
|
||||
.put(url)
|
||||
.header("Content-Length", key.len().to_string())
|
||||
.body(key.to_string())
|
||||
.send()
|
||||
@@ -178,7 +196,7 @@ async fn transfer_urls_refuse_what_s3_would_refuse() {
|
||||
json!({ "key": "k", "metadata": { "updated-at": "5" } }),
|
||||
)
|
||||
.await;
|
||||
let url = presign["url"].as_str().unwrap();
|
||||
let url = presign["transfer"]["url"].as_str().unwrap();
|
||||
let put = |url: String| client.put(url).header("Content-Length", "1").body("x");
|
||||
|
||||
// The signed metadata must be sent, exactly.
|
||||
@@ -538,3 +556,220 @@ async fn a_bad_key_stops_the_server_at_start() {
|
||||
assert_eq!(code, 1);
|
||||
assert!(out.contains("must be 32 bytes"), "{out}");
|
||||
}
|
||||
|
||||
const PART: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// Presign as a newer app does, and return (transfer URL, signed metadata).
|
||||
async fn presign_transfer(server: &Server, key: &str) -> (String, Value) {
|
||||
let (status, presign) = call(
|
||||
server,
|
||||
"presign-upload",
|
||||
json!({ "key": key, "contentType": "application/octet-stream", "metadata": { "updated-at": "77" } }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "{presign}");
|
||||
(
|
||||
presign["transfer"]["url"].as_str().unwrap().to_string(),
|
||||
presign["metadata"].clone(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn start_parts(url: &str, length: usize) -> reqwest::Response {
|
||||
http()
|
||||
.post(url)
|
||||
.header("x-donut-upload-length", length.to_string())
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.header("x-amz-meta-updated-at", "77")
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn part_url(url: &str, upload: &str, part: usize) -> String {
|
||||
format!("{url}&upload={upload}&part={part}")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn large_files_upload_in_parts_into_one_sealed_object() {
|
||||
let (server, bucket) = sealed_server("parts", K1).await;
|
||||
let key = "profiles/p/files/large";
|
||||
let plain = sample(2 * PART + 12_345);
|
||||
let (url, _) = presign_transfer(&server, key).await;
|
||||
|
||||
let started = start_parts(&url, plain.len()).await;
|
||||
assert_eq!(started.status(), 200);
|
||||
let started: Value = started.json().await.unwrap();
|
||||
assert_eq!(
|
||||
(started["partSize"].as_u64(), started["parts"].as_u64()),
|
||||
(Some(PART as u64), Some(3))
|
||||
);
|
||||
let upload = started["upload"].as_str().unwrap();
|
||||
|
||||
let mut etags = Vec::new();
|
||||
for (i, chunk) in plain.chunks(PART).enumerate() {
|
||||
let response = http()
|
||||
.put(part_url(&url, upload, i + 1))
|
||||
.header("Content-Length", chunk.len().to_string())
|
||||
.body(chunk.to_vec())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 200, "{}", response.text().await.unwrap());
|
||||
etags.push(response.headers()["etag"].to_str().unwrap().to_string());
|
||||
}
|
||||
// A part sent again replaces itself.
|
||||
let again = http()
|
||||
.put(part_url(&url, upload, 2))
|
||||
.header("Content-Length", PART.to_string())
|
||||
.body(plain[PART..2 * PART].to_vec())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
etags[1] = again.headers()["etag"].to_str().unwrap().to_string();
|
||||
let done = http()
|
||||
.post(format!("{url}&upload={upload}&op=complete"))
|
||||
.json(&json!({ "parts": etags }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(done.status(), 200, "{}", done.text().await.unwrap());
|
||||
|
||||
let raw = raw_store(&bucket);
|
||||
let head = raw.head_object(key).await.unwrap();
|
||||
assert_eq!(head.metadata_value("donut-at-rest"), Some("1:k1"));
|
||||
let (_, stat) = call(&server, "stat", json!({ "key": key })).await;
|
||||
assert_eq!(stat["size"], json!(plain.len()));
|
||||
assert_eq!(stat["metadata"]["updated-at"], "77");
|
||||
assert_eq!(download(&server, key).await, (200, plain));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn part_requests_refuse_what_does_not_fit_the_upload() {
|
||||
let (server, _) = sealed_server("partrefuse", K1).await;
|
||||
let (url, _) = presign_transfer(&server, "k").await;
|
||||
// Unsigned metadata, and no length.
|
||||
let response = http()
|
||||
.post(&url)
|
||||
.header("x-donut-upload-length", "10")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 403);
|
||||
let response = http()
|
||||
.post(&url)
|
||||
.header("x-amz-meta-updated-at", "77")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 400);
|
||||
|
||||
let started: Value = start_parts(&url, PART + 1).await.json().await.unwrap();
|
||||
let upload = started["upload"].as_str().unwrap().to_string();
|
||||
let put = |part: usize, len: usize| {
|
||||
http()
|
||||
.put(part_url(&url, &upload, part))
|
||||
.header("Content-Length", len.to_string())
|
||||
.body(vec![0u8; len])
|
||||
.send()
|
||||
};
|
||||
assert_eq!(
|
||||
put(1, PART - 1).await.unwrap().status(),
|
||||
400,
|
||||
"a short part"
|
||||
);
|
||||
assert_eq!(put(3, 1).await.unwrap().status(), 400, "no part 3");
|
||||
assert_eq!(put(0, 1).await.unwrap().status(), 400, "no part 0");
|
||||
// A forged token, or this token on another key's URL.
|
||||
let forged = upload.replacen('.', ".0", 1);
|
||||
let response = http()
|
||||
.put(part_url(&url, &forged, 2))
|
||||
.header("Content-Length", "1")
|
||||
.body(vec![0u8])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 403);
|
||||
let (other, _) = presign_transfer(&server, "other").await;
|
||||
let response = http()
|
||||
.put(part_url(&other, &upload, 2))
|
||||
.header("Content-Length", "1")
|
||||
.body(vec![0u8])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 403);
|
||||
// The wrong number of parts on completion.
|
||||
let one = put(2, 1).await.unwrap();
|
||||
let etag = one.headers()["etag"].to_str().unwrap().to_string();
|
||||
let done = http()
|
||||
.post(format!("{url}&upload={upload}&op=complete"))
|
||||
.json(&json!({ "parts": [etag] }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(done.status(), 400);
|
||||
// Aborted, the upload takes no more parts.
|
||||
let aborted = http()
|
||||
.delete(format!("{url}&upload={upload}"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(aborted.status(), 204);
|
||||
assert_eq!(download(&server, "k").await.0, 404);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn older_apps_upload_directly_and_the_sealer_seals_it() {
|
||||
let (server, bucket) = sealed_server("direct", K1).await;
|
||||
let key = "profiles/p/files/old-app";
|
||||
let (status, presign) = call(
|
||||
&server,
|
||||
"presign-upload",
|
||||
json!({ "key": key, "metadata": { "updated-at": "5" } }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200);
|
||||
let plain = sample(300_000);
|
||||
let response = http()
|
||||
.put(presign["url"].as_str().unwrap())
|
||||
.header("Content-Length", plain.len().to_string())
|
||||
.header("x-amz-meta-updated-at", "5")
|
||||
.body(plain.clone())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), 200);
|
||||
let raw: std::sync::Arc<dyn Store> = std::sync::Arc::new(raw_store(&bucket));
|
||||
assert!(
|
||||
raw
|
||||
.head_object(key)
|
||||
.await
|
||||
.unwrap()
|
||||
.metadata_value("donut-at-rest")
|
||||
.is_none()
|
||||
);
|
||||
// Readable before it is sealed.
|
||||
assert_eq!(download(&server, key).await, (200, plain.clone()));
|
||||
|
||||
let keyring = std::sync::Arc::new(donut_sync::at_rest::Keyring::parse(K1).unwrap());
|
||||
let sealer = donut_sync::sealer::Sealer::new(raw.clone(), keyring)
|
||||
.await
|
||||
.unwrap();
|
||||
let report = sealer
|
||||
.pending_pass(donut_sync::time::now_ms())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((report.records, report.sealed, report.failed), (1, 1, 0));
|
||||
let head = raw.head_object(key).await.unwrap();
|
||||
assert_eq!(head.metadata_value("donut-at-rest"), Some("1:k1"));
|
||||
assert_eq!(head.metadata_value("updated-at"), Some("5"));
|
||||
assert_eq!(download(&server, key).await, (200, plain));
|
||||
// The record stays until its URL is dead.
|
||||
let (_, list) = call(&server, "list", json!({ "prefix": "" })).await;
|
||||
assert_eq!(list["objects"].as_array().unwrap().len(), 1, "{list}");
|
||||
let report = sealer
|
||||
.pending_pass(donut_sync::time::now_ms() + 2 * 3600 * 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(report.records_done, 1);
|
||||
}
|
||||
+398
-19
@@ -23,6 +23,29 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
/// only when nothing arrives for a full minute.
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Tries per part of an upload in parts. The file's own retries come on top.
|
||||
const PART_ATTEMPTS: u32 = 3;
|
||||
const PART_RETRY_DELAY: Duration = Duration::from_secs(1);
|
||||
/// The size of a file uploaded in parts, which the server checks every part against.
|
||||
const UPLOAD_LENGTH_HEADER: &str = "x-donut-upload-length";
|
||||
|
||||
/// The server's answer to the start of an upload in parts.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct PartsStarted {
|
||||
upload: String,
|
||||
#[serde(rename = "partSize")]
|
||||
part_size: u64,
|
||||
parts: u64,
|
||||
}
|
||||
|
||||
/// `url` with `pairs` added to its query.
|
||||
fn with_query(url: &str, pairs: &[(&str, &str)]) -> SyncResult<String> {
|
||||
let mut url = reqwest::Url::parse(url)
|
||||
.map_err(|e| SyncError::NetworkError(format!("Invalid transfer URL: {e}")))?;
|
||||
url.query_pairs_mut().extend_pairs(pairs);
|
||||
Ok(url.into())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SyncClient {
|
||||
client: Client,
|
||||
@@ -227,42 +250,201 @@ impl SyncClient {
|
||||
Ok(all_objects)
|
||||
}
|
||||
|
||||
/// Uploads to a presign answer: through `transfer` when the server offers
|
||||
/// it, else to `presigned_url`.
|
||||
pub async fn upload_bytes(
|
||||
&self,
|
||||
presigned_url: &str,
|
||||
transfer: Option<&SealedTransfer>,
|
||||
data: &[u8],
|
||||
content_type: Option<&str>,
|
||||
) -> SyncResult<()> {
|
||||
self
|
||||
.upload_bytes_with_metadata(presigned_url, data, content_type, None)
|
||||
.upload_bytes_with_metadata(presigned_url, transfer, data, content_type, None)
|
||||
.await
|
||||
}
|
||||
|
||||
/// PUT to a presigned URL, sending `metadata` as `x-amz-meta-*` headers. These
|
||||
/// MUST be exactly the metadata the presign signed (from
|
||||
/// `PresignUploadResponse::metadata`) or S3 rejects the request.
|
||||
/// `upload_bytes`, sending `metadata` as `x-amz-meta-*` headers. These MUST
|
||||
/// be exactly the metadata the presign signed (from
|
||||
/// `PresignUploadResponse::metadata`) or the upload is refused.
|
||||
pub async fn upload_bytes_with_metadata(
|
||||
&self,
|
||||
presigned_url: &str,
|
||||
transfer: Option<&SealedTransfer>,
|
||||
data: &[u8],
|
||||
content_type: Option<&str>,
|
||||
metadata: Option<&std::collections::HashMap<String, String>>,
|
||||
) -> SyncResult<()> {
|
||||
match transfer {
|
||||
Some(transfer) if data.len() as u64 > transfer.part_size => {
|
||||
self
|
||||
.upload_in_parts(transfer, data, content_type, metadata)
|
||||
.await
|
||||
}
|
||||
Some(transfer) => {
|
||||
self
|
||||
.put_once(&transfer.url, data, content_type, metadata)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self
|
||||
.put_once(presigned_url, data, content_type, metadata)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn with_upload_headers(
|
||||
mut req: reqwest::RequestBuilder,
|
||||
content_type: Option<&str>,
|
||||
metadata: Option<&std::collections::HashMap<String, String>>,
|
||||
) -> reqwest::RequestBuilder {
|
||||
if let Some(ct) = content_type {
|
||||
req = req.header("Content-Type", ct);
|
||||
}
|
||||
for (k, v) in metadata.into_iter().flatten() {
|
||||
req = req.header(format!("x-amz-meta-{k}"), v);
|
||||
}
|
||||
req
|
||||
}
|
||||
|
||||
/// Sends a request of an upload in parts; any answer but a success is an
|
||||
/// error carrying the server's own words.
|
||||
async fn send_part_request(
|
||||
&self,
|
||||
url: &str,
|
||||
req: reqwest::RequestBuilder,
|
||||
) -> SyncResult<reqwest::Response> {
|
||||
let response = req
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::NetworkError(super::preflight::transport_reason_for(url, &e)))?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(SyncError::NetworkError(format!(
|
||||
"Upload failed with status {status}: {body}"
|
||||
)));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// The server's protocol for files larger than one request may carry:
|
||||
/// start with the size, send each part (retried on its own), then join
|
||||
/// them. A failure aborts the upload, so storage keeps nothing half done.
|
||||
async fn upload_in_parts(
|
||||
&self,
|
||||
transfer: &SealedTransfer,
|
||||
data: &[u8],
|
||||
content_type: Option<&str>,
|
||||
metadata: Option<&std::collections::HashMap<String, String>>,
|
||||
) -> SyncResult<()> {
|
||||
let start = Self::with_upload_headers(
|
||||
self
|
||||
.client
|
||||
.post(&transfer.url)
|
||||
.header(UPLOAD_LENGTH_HEADER, data.len().to_string())
|
||||
.header("Content-Length", "0"),
|
||||
content_type,
|
||||
metadata,
|
||||
);
|
||||
let started: PartsStarted = self
|
||||
.send_part_request(&transfer.url, start)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| SyncError::SerializationError(e.to_string()))?;
|
||||
let outcome = self.send_parts(transfer, &started, data).await;
|
||||
if outcome.is_err() {
|
||||
let abort = with_query(&transfer.url, &[("upload", &started.upload)])?;
|
||||
if let Err(e) = self.client.delete(&abort).send().await {
|
||||
log::debug!("Upload abort failed err=\"{e}\"");
|
||||
}
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
async fn send_parts(
|
||||
&self,
|
||||
transfer: &SealedTransfer,
|
||||
started: &PartsStarted,
|
||||
data: &[u8],
|
||||
) -> SyncResult<()> {
|
||||
let part_size = usize::try_from(started.part_size)
|
||||
.ok()
|
||||
.filter(|size| *size > 0)
|
||||
.ok_or_else(|| SyncError::InvalidData("The server gave no part size".to_string()))?;
|
||||
let chunks: Vec<&[u8]> = data.chunks(part_size).collect();
|
||||
if chunks.len() as u64 != started.parts {
|
||||
return Err(SyncError::InvalidData(format!(
|
||||
"The server expects {} parts, the file has {}",
|
||||
started.parts,
|
||||
chunks.len()
|
||||
)));
|
||||
}
|
||||
let mut etags = Vec::with_capacity(chunks.len());
|
||||
for (index, chunk) in chunks.iter().enumerate() {
|
||||
let number = (index + 1).to_string();
|
||||
let url = with_query(
|
||||
&transfer.url,
|
||||
&[("upload", &started.upload), ("part", &number)],
|
||||
)?;
|
||||
let mut attempt = 0;
|
||||
let etag = loop {
|
||||
attempt += 1;
|
||||
let req = self
|
||||
.client
|
||||
.put(&url)
|
||||
.header("Content-Length", chunk.len().to_string())
|
||||
.body(chunk.to_vec());
|
||||
match self.send_part_request(&url, req).await {
|
||||
Ok(response) => {
|
||||
break response
|
||||
.headers()
|
||||
.get("etag")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| {
|
||||
SyncError::InvalidData(format!("Part {number} came back without an ETag"))
|
||||
})?;
|
||||
}
|
||||
Err(e) if attempt >= PART_ATTEMPTS => return Err(e),
|
||||
Err(e) => {
|
||||
log::debug!("Part retry part={number} attempt={attempt} err=\"{e}\"");
|
||||
tokio::time::sleep(PART_RETRY_DELAY * attempt).await;
|
||||
}
|
||||
}
|
||||
};
|
||||
etags.push(etag);
|
||||
}
|
||||
let complete = with_query(
|
||||
&transfer.url,
|
||||
&[("upload", &started.upload), ("op", "complete")],
|
||||
)?;
|
||||
let req = self
|
||||
.client
|
||||
.post(&complete)
|
||||
.json(&serde_json::json!({ "parts": etags }));
|
||||
self.send_part_request(&complete, req).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// One PUT, as S3 takes it.
|
||||
async fn put_once(
|
||||
&self,
|
||||
presigned_url: &str,
|
||||
data: &[u8],
|
||||
content_type: Option<&str>,
|
||||
metadata: Option<&std::collections::HashMap<String, String>>,
|
||||
) -> SyncResult<()> {
|
||||
let mut req = self
|
||||
.client
|
||||
.put(presigned_url)
|
||||
.header("Content-Length", data.len().to_string())
|
||||
.body(data.to_vec());
|
||||
|
||||
if let Some(ct) = content_type {
|
||||
req = req.header("Content-Type", ct);
|
||||
}
|
||||
|
||||
if let Some(meta) = metadata {
|
||||
for (k, v) in meta {
|
||||
req = req.header(format!("x-amz-meta-{k}"), v);
|
||||
}
|
||||
}
|
||||
let req = Self::with_upload_headers(
|
||||
self
|
||||
.client
|
||||
.put(presigned_url)
|
||||
.header("Content-Length", data.len().to_string())
|
||||
.body(data.to_vec()),
|
||||
content_type,
|
||||
metadata,
|
||||
);
|
||||
|
||||
// The storage host here comes from the presigned URL, so on a self-hosted
|
||||
// server it is whatever the server signed against, frequently an address
|
||||
@@ -491,3 +673,200 @@ impl SyncClient {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use wiremock::matchers::{body_bytes, body_json, header, method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn client() -> SyncClient {
|
||||
SyncClient::new("http://127.0.0.1:1".to_string(), "unused".to_string())
|
||||
}
|
||||
|
||||
fn transfer(server: &MockServer, part_size: u64) -> SealedTransfer {
|
||||
SealedTransfer {
|
||||
url: format!("{}/v1/transfer?key=k&expires=1&sig=s", server.uri()),
|
||||
part_size,
|
||||
}
|
||||
}
|
||||
|
||||
fn meta() -> HashMap<String, String> {
|
||||
HashMap::from([("updated-at".to_string(), "7".to_string())])
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn without_a_transfer_the_presigned_url_takes_the_file() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("PUT"))
|
||||
.and(path("/bucket/k"))
|
||||
.and(body_bytes(b"data".to_vec()))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
client()
|
||||
.upload_bytes(&format!("{}/bucket/k", server.uri()), None, b"data", None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_file_within_one_part_goes_through_the_server_in_one_request() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("PUT"))
|
||||
.and(path("/v1/transfer"))
|
||||
.and(header("x-amz-meta-updated-at", "7"))
|
||||
.and(header("content-type", "application/json"))
|
||||
.and(body_bytes(b"data".to_vec()))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
client()
|
||||
.upload_bytes_with_metadata(
|
||||
"http://storage.invalid/never",
|
||||
Some(&transfer(&server, 4)),
|
||||
b"data",
|
||||
Some("application/json"),
|
||||
Some(&meta()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_larger_file_goes_up_in_parts_and_a_failed_part_is_sent_again() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/transfer"))
|
||||
.and(header("x-donut-upload-length", "10"))
|
||||
.and(header("x-amz-meta-updated-at", "7"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(serde_json::json!({ "upload": "T.1", "partSize": 4, "parts": 3 })),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
for (part, body) in [("1", &b"0123"[..]), ("2", b"4567"), ("3", b"89")] {
|
||||
if part == "2" {
|
||||
Mock::given(method("PUT"))
|
||||
.and(query_param("part", part))
|
||||
.respond_with(ResponseTemplate::new(503))
|
||||
.up_to_n_times(1)
|
||||
.with_priority(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
Mock::given(method("PUT"))
|
||||
.and(query_param("upload", "T.1"))
|
||||
.and(query_param("part", part))
|
||||
.and(query_param("key", "k"))
|
||||
.and(body_bytes(body.to_vec()))
|
||||
.respond_with(ResponseTemplate::new(200).insert_header("etag", format!("\"e{part}\"")))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
Mock::given(method("POST"))
|
||||
.and(query_param("op", "complete"))
|
||||
.and(query_param("upload", "T.1"))
|
||||
.and(body_json(
|
||||
serde_json::json!({ "parts": ["\"e1\"", "\"e2\"", "\"e3\""] }),
|
||||
))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
client()
|
||||
.upload_bytes_with_metadata(
|
||||
"http://storage.invalid/never",
|
||||
Some(&transfer(&server, 4)),
|
||||
b"0123456789",
|
||||
None,
|
||||
Some(&meta()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_part_that_keeps_failing_aborts_the_upload() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/transfer"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(serde_json::json!({ "upload": "T.2", "partSize": 4, "parts": 2 })),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("PUT"))
|
||||
.respond_with(ResponseTemplate::new(500).set_body_string("no"))
|
||||
.expect(u64::from(PART_ATTEMPTS))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("DELETE"))
|
||||
.and(query_param("upload", "T.2"))
|
||||
.respond_with(ResponseTemplate::new(204))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let error = client()
|
||||
.upload_bytes(
|
||||
"http://storage.invalid/never",
|
||||
Some(&transfer(&server, 4)),
|
||||
b"01234567",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("500"), "{error}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_part_count_that_disagrees_with_the_file_is_refused_before_sending() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_json(serde_json::json!({ "upload": "T.3", "partSize": 4, "parts": 5 })),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("DELETE"))
|
||||
.respond_with(ResponseTemplate::new(204))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
assert!(client()
|
||||
.upload_bytes(
|
||||
"http://x.invalid",
|
||||
Some(&transfer(&server, 4)),
|
||||
b"0123456",
|
||||
None
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answers_from_servers_that_do_not_seal_have_no_transfer() {
|
||||
let old: PresignUploadResponse =
|
||||
serde_json::from_str(r#"{"url":"u","expiresAt":"t"}"#).unwrap();
|
||||
assert_eq!(old.transfer, None);
|
||||
let new: PresignUploadBatchItemResponse = serde_json::from_str(
|
||||
r#"{"key":"k","url":"u","expiresAt":"t","transfer":{"url":"v","partSize":8388608}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
new.transfer,
|
||||
Some(SealedTransfer {
|
||||
url: "v".to_string(),
|
||||
part_size: 8_388_608
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -753,6 +753,7 @@ impl SyncEngine {
|
||||
.client
|
||||
.upload_bytes_with_metadata(
|
||||
&presign.url,
|
||||
presign.transfer.as_ref(),
|
||||
&payload,
|
||||
Some(content_type),
|
||||
presign.metadata.as_ref(),
|
||||
@@ -1324,7 +1325,12 @@ impl SyncEngine {
|
||||
|
||||
self
|
||||
.client
|
||||
.upload_bytes(&presign.url, &upload_data, Some(content_type))
|
||||
.upload_bytes(
|
||||
&presign.url,
|
||||
presign.transfer.as_ref(),
|
||||
&upload_data,
|
||||
Some(content_type),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
@@ -1525,10 +1531,10 @@ impl SyncEngine {
|
||||
let batch_response = self.client.presign_upload_batch(items).await?;
|
||||
|
||||
// Build URL map
|
||||
let url_map: HashMap<String, String> = batch_response
|
||||
let url_map: HashMap<String, (String, Option<SealedTransfer>)> = batch_response
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| (item.key, item.url))
|
||||
.map(|item| (item.key, (item.url, item.transfer)))
|
||||
.collect();
|
||||
|
||||
let total_bytes: u64 = files.iter().map(|f| f.size).sum();
|
||||
@@ -1602,7 +1608,7 @@ impl SyncEngine {
|
||||
continue;
|
||||
}
|
||||
|
||||
let url = url.unwrap();
|
||||
let (url, transfer) = url.unwrap();
|
||||
let client = client.clone();
|
||||
let tracker = tracker.clone();
|
||||
let resume_state = resume_state.clone();
|
||||
@@ -1653,7 +1659,7 @@ impl SyncEngine {
|
||||
let mut last_err = String::new();
|
||||
for attempt in 0..MAX_FILE_RETRIES {
|
||||
match client
|
||||
.upload_bytes(&url, &upload_data, content_type.as_deref())
|
||||
.upload_bytes(&url, transfer.as_ref(), &upload_data, content_type.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
@@ -2674,7 +2680,12 @@ impl SyncEngine {
|
||||
.await?;
|
||||
self
|
||||
.client
|
||||
.upload_bytes(&file_presign.url, &file_payload, Some(file_content_type))
|
||||
.upload_bytes(
|
||||
&file_presign.url,
|
||||
file_presign.transfer.as_ref(),
|
||||
&file_payload,
|
||||
Some(file_content_type),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -4961,7 +4972,7 @@ mod tests {
|
||||
?X-Amz-Signature=deadbeef";
|
||||
|
||||
let error = client
|
||||
.upload_bytes(presigned, b"payload", None)
|
||||
.upload_bytes(presigned, None, b"payload", None)
|
||||
.await
|
||||
.expect_err("a host that cannot resolve must not report a successful upload");
|
||||
|
||||
|
||||
@@ -41,6 +41,20 @@ pub struct PresignUploadResponse {
|
||||
/// from older servers → client sends no metadata headers (body-GET fallback).
|
||||
#[serde(default)]
|
||||
pub metadata: Option<HashMap<String, String>>,
|
||||
/// Set by a server that encrypts at rest: upload through it instead of
|
||||
/// `url`, in parts when the file is larger than one.
|
||||
#[serde(default)]
|
||||
pub transfer: Option<SealedTransfer>,
|
||||
}
|
||||
|
||||
/// A server's own upload URL, for a store it encrypts at rest. A request
|
||||
/// through a proxy in front of it has a size and time limit, so a file larger
|
||||
/// than `part_size` goes up in parts (`SyncClient::upload_bytes`).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SealedTransfer {
|
||||
pub url: String,
|
||||
#[serde(rename = "partSize")]
|
||||
pub part_size: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -126,6 +140,8 @@ pub struct PresignUploadBatchItemResponse {
|
||||
pub url: String,
|
||||
#[serde(rename = "expiresAt")]
|
||||
pub expires_at: String,
|
||||
#[serde(default)]
|
||||
pub transfer: Option<SealedTransfer>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuRotateCcw, LuSquare, LuX } from "react-icons/lu";
|
||||
import { ProgressBar } from "@/components/agent-console-parts";
|
||||
import { useNumber } from "@/components/agent-tasks/parts";
|
||||
import { StatusDot } from "@/components/cookie-bot-shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useMotionAllowed } from "@/hooks/use-motion-allowed";
|
||||
import {
|
||||
type AgentBatch,
|
||||
type AgentTask,
|
||||
batchProgress,
|
||||
dismissAgentBatch,
|
||||
promptTitle,
|
||||
retryAgentBatch,
|
||||
statusTone,
|
||||
stopAgentBatch,
|
||||
} from "@/lib/agent-tasks";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { MOTION_SPRING } from "@/lib/motion";
|
||||
import { showErrorToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** The one line a tile shows: the newest step, or what the status means. */
|
||||
function statusLine(
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
task: AgentTask,
|
||||
): string {
|
||||
if (task.status === "needs_approval") return t("agent.tasks.needsYou");
|
||||
if (task.status === "done" && task.result) return task.result;
|
||||
if (task.lastStep && task.status !== "queued") return task.lastStep.text;
|
||||
return t(`agent.tasks.status.${task.status}`);
|
||||
}
|
||||
|
||||
function ProfileTile({
|
||||
task,
|
||||
name,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
task: AgentTask;
|
||||
name: string;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const format = useNumber();
|
||||
const live = task.status === "running" || task.status === "launching";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="agent-batch-tile"
|
||||
data-profile-id={task.profileId}
|
||||
data-status={task.status}
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"flex min-w-0 cursor-pointer flex-col gap-1 rounded-xl px-3 py-2.5 text-left transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
selected ? "bg-primary/10" : "bg-foreground/3 hover:bg-foreground/5",
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<StatusDot tone={statusTone(task.status)} pulse={live} />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{name}
|
||||
</span>
|
||||
{task.creditsUsed > 0 && (
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||
{format(task.creditsUsed)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-xs",
|
||||
task.status === "needs_approval"
|
||||
? "text-warning-text"
|
||||
: task.status === "failed"
|
||||
? "text-destructive-text"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{statusLine(t, task)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** One run on one or many profiles: totals, the batch controls and a tile per profile. */
|
||||
export function BatchCard({
|
||||
batch,
|
||||
profileNames,
|
||||
selectedProfileId,
|
||||
onSelect,
|
||||
onDismissed,
|
||||
}: {
|
||||
batch: AgentBatch;
|
||||
profileNames: Map<string, string>;
|
||||
selectedProfileId: string | null;
|
||||
onSelect: (profileId: string) => void;
|
||||
onDismissed: (batchId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const format = useNumber();
|
||||
const animate = useMotionAllowed();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const { tally } = batch;
|
||||
const total = batch.tasks.length;
|
||||
const ended = tally.done + tally.failed + tally.stopped;
|
||||
const nameOf = (profileId: string) =>
|
||||
profileNames.get(profileId) ?? t("agent.tasks.unknownProfile");
|
||||
|
||||
const act = async (work: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await work();
|
||||
} catch (error) {
|
||||
showErrorToast(translateBackendError(t, error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.article
|
||||
layout={animate ? "position" : false}
|
||||
initial={animate ? { opacity: 0.6, y: 6 } : false}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={animate ? MOTION_SPRING.gentle : { duration: 0 }}
|
||||
data-slot="agent-batch"
|
||||
data-batch-id={batch.id}
|
||||
data-finished={batch.finished ? "true" : "false"}
|
||||
className="flex flex-col gap-2.5"
|
||||
>
|
||||
<header className="flex items-start gap-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<h4 className="truncate text-sm font-medium" title={batch.prompt}>
|
||||
{promptTitle(batch.prompt)}
|
||||
</h4>
|
||||
<p
|
||||
data-slot="agent-batch-summary"
|
||||
className="flex flex-wrap gap-x-2 text-xs text-muted-foreground tabular-nums"
|
||||
>
|
||||
<span>{t("agent.tasks.progress", { done: ended, total })}</span>
|
||||
{tally.failed > 0 && (
|
||||
<span className="text-destructive-text">
|
||||
{t("agent.tasks.failedCount", { n: tally.failed })}
|
||||
</span>
|
||||
)}
|
||||
{tally.creditsUsed > 0 && (
|
||||
<span>
|
||||
{t("agent.tasks.credits", { n: format(tally.creditsUsed) })}
|
||||
</span>
|
||||
)}
|
||||
{batch.routineId && <span>{t("agent.tasks.fromRoutine")}</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{!batch.finished && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
disabled={busy || batch.stopping}
|
||||
data-slot="agent-batch-stop"
|
||||
className="h-7 gap-1.5 rounded-lg text-xs hover:bg-destructive/10 hover:text-destructive-text"
|
||||
onClick={() => void act(() => stopAgentBatch(batch.id))}
|
||||
>
|
||||
<LuSquare className="size-3 fill-current" aria-hidden="true" />
|
||||
{total > 1 ? t("agent.tasks.stopAll") : t("agent.tasks.stop")}
|
||||
</Button>
|
||||
)}
|
||||
{tally.failed > 0 && batch.finished && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
data-slot="agent-batch-retry"
|
||||
className="h-7 gap-1.5 rounded-lg text-xs"
|
||||
onClick={() => void act(() => retryAgentBatch(batch.id))}
|
||||
>
|
||||
<LuRotateCcw className="size-3.5" aria-hidden="true" />
|
||||
{t("agent.tasks.retryFailed")}
|
||||
</Button>
|
||||
)}
|
||||
{batch.finished && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
aria-label={t("agent.tasks.dismiss")}
|
||||
data-slot="agent-batch-dismiss"
|
||||
className="h-7 rounded-lg px-2"
|
||||
onClick={() =>
|
||||
void act(async () => {
|
||||
await dismissAgentBatch(batch.id);
|
||||
onDismissed(batch.id);
|
||||
})
|
||||
}
|
||||
>
|
||||
<LuX className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{total > 1 && (
|
||||
<>
|
||||
<ProgressBar
|
||||
percent={batchProgress(tally, total)}
|
||||
label={t("agent.tasks.progress", { done: ended, total })}
|
||||
/>
|
||||
<div className="grid gap-1.5 [grid-template-columns:repeat(auto-fill,minmax(12.5rem,1fr))]">
|
||||
{batch.tasks.map((task) => (
|
||||
<ProfileTile
|
||||
key={task.profileId}
|
||||
task={task}
|
||||
name={nameOf(task.profileId)}
|
||||
selected={task.profileId === selectedProfileId}
|
||||
onSelect={() => {
|
||||
onSelect(task.profileId);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{total === 1 && (
|
||||
<ProfileTile
|
||||
task={batch.tasks[0]}
|
||||
name={nameOf(batch.tasks[0].profileId)}
|
||||
selected={batch.tasks[0].profileId === selectedProfileId}
|
||||
onSelect={() => {
|
||||
onSelect(batch.tasks[0].profileId);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</motion.article>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user