Save and delete files in an aindrive drive without overwriting another change (#2)
CI / checks (push) Successful in 38s
CI / checks (push) Successful in 38s
This commit was merged in pull request #2.
This commit is contained in:
+150
-32
@@ -2,7 +2,7 @@
|
||||
//! remote MCP endpoint through the host's HTTP, with the drive's access token.
|
||||
|
||||
mod address;
|
||||
mod decode;
|
||||
mod codec;
|
||||
mod mcp;
|
||||
mod sdk;
|
||||
#[cfg(test)]
|
||||
@@ -31,14 +31,28 @@ const MAX_CHUNK: u32 = 1024 * 1024;
|
||||
const MAX_READ: u64 = 8 * 1024 * 1024;
|
||||
/// A whole 8 MiB file in base64, with room for the reply around it.
|
||||
const MAX_REPLY: usize = 12 * 1024 * 1024;
|
||||
/// The largest file a save sends. `write_file` takes a file whole, and its
|
||||
/// base64 has to be encoded within one call's fuel, which a single transfer,
|
||||
/// the most Memona saves a remote page in, leaves room for.
|
||||
const MAX_WRITE: usize = MAX_CHUNK as usize;
|
||||
/// Without these the folder cannot even be browsed.
|
||||
const REQUIRED_TOOLS: [&str; 3] = ["list_files", "stat", "read_file"];
|
||||
/// A token offers these only when it may write.
|
||||
const WRITE_TOOLS: [&str; 2] = ["write_file", "delete_path"];
|
||||
|
||||
struct Session {
|
||||
endpoint: String,
|
||||
authorization: String,
|
||||
}
|
||||
|
||||
/// Whether a request may change the drive. Once one has gone out whole, a
|
||||
/// lost answer leaves the change unknown rather than failed.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Effect {
|
||||
Read,
|
||||
Change,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn from_config(config: &[(String, Setting)]) -> Result<Self, ErrorCode> {
|
||||
let lookup = |wanted: &str| {
|
||||
@@ -96,7 +110,7 @@ fn header(name: &str, value: &str) -> host::Header {
|
||||
/// the host handed over. They are not joined here: Memona's host charges
|
||||
/// fuel for every byte a plugin copies, and a file's reply joined whole would
|
||||
/// spend most of a call's budget before any of it was decoded.
|
||||
fn post(body: &[u8]) -> Result<Vec<Vec<u8>>, ErrorCode> {
|
||||
fn post(body: &[u8], effect: Effect) -> Result<Vec<Vec<u8>>, ErrorCode> {
|
||||
let (url, authorization) = SESSION.with_borrow(|session| {
|
||||
session
|
||||
.as_ref()
|
||||
@@ -116,12 +130,27 @@ fn post(body: &[u8]) -> Result<Vec<Vec<u8>>, ErrorCode> {
|
||||
for piece in body.chunks(MAX_CHUNK as usize) {
|
||||
call.push(piece).map_err(transport)?;
|
||||
}
|
||||
let head = call.respond().map_err(transport)?;
|
||||
mcp::status(head.status)?;
|
||||
let unsettled = |error: ErrorCode| match (effect, error) {
|
||||
(Effect::Change, ErrorCode::Unavailable | ErrorCode::Cancelled) => {
|
||||
ErrorCode::OutcomeUnknown
|
||||
}
|
||||
_ => error,
|
||||
};
|
||||
let head = call
|
||||
.respond()
|
||||
.map_err(|error| unsettled(transport(error)))?;
|
||||
// A server error can come after the drive's server already acted, while a
|
||||
// refused token, permission, drive or rate comes before it does anything.
|
||||
mcp::status(head.status).map_err(|error| match head.status {
|
||||
500.. => unsettled(error),
|
||||
_ => error,
|
||||
})?;
|
||||
let mut pieces = Vec::new();
|
||||
let mut total = 0;
|
||||
loop {
|
||||
let piece = call.pull(MAX_CHUNK).map_err(transport)?;
|
||||
let piece = call
|
||||
.pull(MAX_CHUNK)
|
||||
.map_err(|error| unsettled(transport(error)))?;
|
||||
if piece.is_empty() {
|
||||
return Ok(pieces);
|
||||
}
|
||||
@@ -141,12 +170,22 @@ fn joined(mut pieces: Vec<Vec<u8>>) -> Vec<u8> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Calls one of the drive's tools and reads its structured answer.
|
||||
/// Calls one of the drive's tools that only reads, and reads its structured
|
||||
/// answer. A call that timed out on the drive's device changed nothing.
|
||||
fn tool<T: DeserializeOwned>(
|
||||
name: &str,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<T, ErrorCode> {
|
||||
mcp::reply(&joined(post(&mcp::call(name, arguments))?))
|
||||
let pieces = post(&mcp::call(name, arguments), Effect::Read)?;
|
||||
mcp::reply(&joined(pieces)).map_err(|error| match error {
|
||||
ErrorCode::OutcomeUnknown => ErrorCode::Unavailable,
|
||||
error => error,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sends a call that changes the drive and reads its structured answer.
|
||||
fn change<T: DeserializeOwned>(body: &[u8]) -> Result<T, ErrorCode> {
|
||||
mcp::reply(&joined(post(body, Effect::Change)?))
|
||||
}
|
||||
|
||||
fn info_of(entry: &mcp::Entry) -> FileInfo {
|
||||
@@ -188,7 +227,7 @@ impl Download {
|
||||
chars[filled..filled + slice.len()].copy_from_slice(slice);
|
||||
filled += slice.len();
|
||||
}
|
||||
let bytes = decode::last(chars)?;
|
||||
let bytes = codec::last(chars)?;
|
||||
let final_group = self.encoded.len() / 4 - 1;
|
||||
if filled != 4 || (index != final_group && bytes.len() != 3) {
|
||||
return Err(ErrorCode::Unavailable);
|
||||
@@ -214,7 +253,7 @@ impl Download {
|
||||
let mut bytes = Vec::with_capacity((end - first) * 3);
|
||||
bytes.resize((whole - first) * 3, 0);
|
||||
let chars = self.chars(first)..self.chars(whole);
|
||||
decode::run(mcp::slices(&self.reply, chars), &mut bytes)?;
|
||||
codec::decode(mcp::slices(&self.reply, chars), &mut bytes)?;
|
||||
if end > final_group {
|
||||
bytes.extend(self.group(final_group)?);
|
||||
}
|
||||
@@ -258,10 +297,10 @@ fn download(target: &Locator, offset: u64) -> Result<Download, ErrorCode> {
|
||||
} else {
|
||||
let length = usize::try_from(entry.size.div_ceil(3) * 4)
|
||||
.map_err(|_| ErrorCode::Unsupported)?;
|
||||
let reply = post(&mcp::call(
|
||||
"read_file",
|
||||
&json!({ "path": path, "encoding": "base64" }),
|
||||
))?;
|
||||
let reply = post(
|
||||
&mcp::call("read_file", &json!({ "path": path, "encoding": "base64" })),
|
||||
Effect::Read,
|
||||
)?;
|
||||
let encoded = mcp::contents(&reply, length)?;
|
||||
(reply, encoded)
|
||||
};
|
||||
@@ -274,19 +313,80 @@ fn download(target: &Locator, offset: u64) -> Result<Download, ErrorCode> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Writing is not offered yet, so no transfer is ever started.
|
||||
enum Upload {}
|
||||
/// One file being saved. `write_file` takes a file whole, so its bytes are
|
||||
/// held until `finish` and nothing reaches the drive before then: a save
|
||||
/// abandoned on the way leaves no partial file.
|
||||
struct Upload {
|
||||
path: String,
|
||||
/// What `write_file` compares before writing, as its `base_revision`.
|
||||
expected: Option<String>,
|
||||
bytes: RefCell<Vec<u8>>,
|
||||
finished: Cell<bool>,
|
||||
}
|
||||
|
||||
impl GuestWriter for Upload {
|
||||
fn push(&self, _data: Vec<u8>) -> Result<(), ErrorCode> {
|
||||
match *self {}
|
||||
fn push(&self, data: Vec<u8>) -> Result<(), ErrorCode> {
|
||||
if self.finished.get() || data.len() > MAX_CHUNK as usize {
|
||||
return Err(ErrorCode::InvalidInput);
|
||||
}
|
||||
let mut bytes = self.bytes.borrow_mut();
|
||||
if bytes.len() + data.len() > MAX_WRITE {
|
||||
return Err(ErrorCode::Unsupported);
|
||||
}
|
||||
// A save of one transfer is kept as it arrived rather than copied.
|
||||
if bytes.is_empty() {
|
||||
*bytes = data;
|
||||
} else {
|
||||
bytes.extend_from_slice(&data);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One call is enough: the file goes out whole, and aindrive compares the
|
||||
/// expected revision and writes under one lock, then answers with the new
|
||||
/// revision a later conditional save compares against.
|
||||
fn finish(&self) -> Result<Option<FileInfo>, ErrorCode> {
|
||||
match *self {}
|
||||
if self.finished.replace(true) {
|
||||
return Err(ErrorCode::InvalidInput);
|
||||
}
|
||||
let bytes = self.bytes.take();
|
||||
let body = mcp::write(&self.path, self.expected.as_deref(), &bytes);
|
||||
let written: mcp::Written = change(&body)?;
|
||||
Ok(Some(FileInfo {
|
||||
kind: EntryKind::File,
|
||||
size: bytes.len() as u64,
|
||||
revision: written.revision,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts saving one file under `condition`. aindrive spells create-only as
|
||||
/// the revision `none`, and an unconditional save as no revision at all.
|
||||
fn upload(
|
||||
target: &Locator,
|
||||
condition: WriteCondition,
|
||||
size_hint: Option<u64>,
|
||||
) -> Result<Upload, ErrorCode> {
|
||||
let path = route(target);
|
||||
if path.is_empty() {
|
||||
return Err(ErrorCode::InvalidInput);
|
||||
}
|
||||
if size_hint.is_some_and(|size| size > MAX_WRITE as u64) {
|
||||
return Err(ErrorCode::Unsupported);
|
||||
}
|
||||
let expected = match condition {
|
||||
WriteCondition::CreateOnly => Some("none".to_owned()),
|
||||
WriteCondition::IfMatch(revision) => Some(revision),
|
||||
WriteCondition::Overwrite => None,
|
||||
};
|
||||
Ok(Upload {
|
||||
path,
|
||||
expected,
|
||||
bytes: RefCell::new(Vec::new()),
|
||||
finished: Cell::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
impl Guest for Aindrive {
|
||||
type Reader = Download;
|
||||
type Writer = Upload;
|
||||
@@ -300,7 +400,8 @@ impl Guest for Aindrive {
|
||||
) -> Result<Capabilities, ErrorCode> {
|
||||
SESSION.set(None);
|
||||
SESSION.set(Some(Session::from_config(&config)?));
|
||||
let probe = post(mcp::TOOLS).and_then(|pieces| mcp::tools(&joined(pieces)));
|
||||
let probe = post(mcp::TOOLS, Effect::Read)
|
||||
.and_then(|pieces| mcp::tools(&joined(pieces)));
|
||||
let tools = match probe {
|
||||
Ok(tools) => tools,
|
||||
Err(error) => {
|
||||
@@ -308,19 +409,23 @@ impl Guest for Aindrive {
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if !REQUIRED_TOOLS
|
||||
.iter()
|
||||
.all(|required| tools.iter().any(|tool| tool == required))
|
||||
{
|
||||
let offers = |wanted: &[&str]| {
|
||||
wanted
|
||||
.iter()
|
||||
.all(|wanted| tools.iter().any(|tool| tool == wanted))
|
||||
};
|
||||
if !offers(&REQUIRED_TOOLS) {
|
||||
SESSION.set(None);
|
||||
return Err(ErrorCode::Denied);
|
||||
}
|
||||
// A read-only token is a read-only folder, not one whose saves fail.
|
||||
let writable = offers(&WRITE_TOOLS);
|
||||
Ok(Capabilities {
|
||||
write: false,
|
||||
write: writable,
|
||||
mkdir: false,
|
||||
rename: false,
|
||||
remove: false,
|
||||
conditional_write: false,
|
||||
remove: writable,
|
||||
conditional_write: writable,
|
||||
watch: false,
|
||||
virtual_directories: false,
|
||||
})
|
||||
@@ -380,11 +485,11 @@ impl Guest for Aindrive {
|
||||
}
|
||||
|
||||
fn open_write(
|
||||
_target: Locator,
|
||||
_condition: WriteCondition,
|
||||
_size_hint: Option<u64>,
|
||||
target: Locator,
|
||||
condition: WriteCondition,
|
||||
size_hint: Option<u64>,
|
||||
) -> Result<Writer, ErrorCode> {
|
||||
Err(ErrorCode::Unsupported)
|
||||
Ok(Writer::new(upload(&target, condition, size_hint)?))
|
||||
}
|
||||
|
||||
fn mkdir(_target: Locator) -> Result<FileInfo, ErrorCode> {
|
||||
@@ -398,8 +503,21 @@ impl Guest for Aindrive {
|
||||
Err(ErrorCode::Unsupported)
|
||||
}
|
||||
|
||||
fn remove(_target: Locator) -> Result<(), ErrorCode> {
|
||||
Err(ErrorCode::Unsupported)
|
||||
/// `delete_path` removes a folder with everything in it, and aindrive
|
||||
/// leaves some entries, such as `.git`, out of every listing, so a folder
|
||||
/// cannot be shown to be empty. Only files are removed.
|
||||
fn remove(target: Locator) -> Result<(), ErrorCode> {
|
||||
let path = route(&target);
|
||||
if path.is_empty() {
|
||||
return Err(ErrorCode::InvalidInput);
|
||||
}
|
||||
let entry: mcp::Entry = tool("stat", &json!({ "path": path }))?;
|
||||
if entry.is_dir {
|
||||
return Err(ErrorCode::Unsupported);
|
||||
}
|
||||
let _: mcp::Deleted =
|
||||
change(&mcp::call("delete_path", &json!({ "path": path })))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn poll(_cursor: Option<String>) -> Result<ChangePage, ErrorCode> {
|
||||
|
||||
Reference in New Issue
Block a user