Put and Read: content on the socket, for clients that share no files
The system daemon runs as its own user behind ProtectHome, so it can neither read a file a desktop app wrote for Add nor write one back for Materialize - redoal on the system varde failed every get with EACCES. Put imports base64 bytes carried in the request and Read returns a raw blob's bytes in the reply, both up to MAX_INLINE_BYTES (16 MiB). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c9dbc4a576
commit
687af8bd2a
@@ -246,6 +246,7 @@ fn print_human(data: Option<&ResponseData>) {
|
|||||||
match data {
|
match data {
|
||||||
ResponseData::Added { hash, bytes } => println!("added {hash} ({bytes} bytes)"),
|
ResponseData::Added { hash, bytes } => println!("added {hash} ({bytes} bytes)"),
|
||||||
ResponseData::Pinned { hash } => println!("pinned {hash}"),
|
ResponseData::Pinned { hash } => println!("pinned {hash}"),
|
||||||
|
ResponseData::Bytes { data } => println!("{data}"),
|
||||||
ResponseData::Done {} => println!("ok"),
|
ResponseData::Done {} => println!("ok"),
|
||||||
ResponseData::Materialized { bytes, reflinked } => {
|
ResponseData::Materialized { bytes, reflinked } => {
|
||||||
let how = if *reflinked { "reflinked" } else { "copied" };
|
let how = if *reflinked { "reflinked" } else { "copied" };
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ required-features = ["socket-api"]
|
|||||||
varde-proto = { workspace = true }
|
varde-proto = { workspace = true }
|
||||||
iroh = { workspace = true }
|
iroh = { workspace = true }
|
||||||
iroh-blobs = { workspace = true }
|
iroh-blobs = { workspace = true }
|
||||||
|
# Request::Put / Read carry content inline, base64.
|
||||||
|
base64 = "0.22"
|
||||||
# mDNS/local-network address lookup left iroh core in 1.0.
|
# mDNS/local-network address lookup left iroh core in 1.0.
|
||||||
iroh-mdns-address-lookup = "0.4"
|
iroh-mdns-address-lookup = "0.4"
|
||||||
# Interop with iroh-blobs streams and channels; both are already in the
|
# Interop with iroh-blobs streams and channels; both are already in the
|
||||||
|
|||||||
@@ -371,6 +371,39 @@ impl Daemon {
|
|||||||
bytes,
|
bytes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
Request::Put { data } => {
|
||||||
|
use base64::Engine as _;
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(data.as_bytes())
|
||||||
|
.map_err(|e| OpError::InvalidArgument(format!("put: data is not base64: {e}")))?;
|
||||||
|
if bytes.len() as u64 > varde_proto::MAX_INLINE_BYTES {
|
||||||
|
return Err(OpError::InvalidArgument(format!(
|
||||||
|
"put: {} bytes, more than {} inline; add it by path",
|
||||||
|
bytes.len(),
|
||||||
|
varde_proto::MAX_INLINE_BYTES
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let (hash, bytes) = self.store.add_bytes(bytes).await?;
|
||||||
|
self.meta.record_format(&hash.to_hex(), StoredFormat::Raw)?;
|
||||||
|
Ok(ResponseData::Added {
|
||||||
|
hash: hash.to_hex().to_string(),
|
||||||
|
bytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Request::Read { hash } => {
|
||||||
|
use base64::Engine as _;
|
||||||
|
let parsed = parse_hash(&hash)?;
|
||||||
|
if self.meta.format_of(&parsed.to_hex()) == StoredFormat::HashSeq {
|
||||||
|
return Err(OpError::InvalidArgument(format!(
|
||||||
|
"read: {} is a collection; materialize it",
|
||||||
|
parsed.to_hex()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let bytes = self.store.read_bytes(parsed, varde_proto::MAX_INLINE_BYTES).await?;
|
||||||
|
Ok(ResponseData::Bytes {
|
||||||
|
data: base64::engine::general_purpose::STANDARD.encode(bytes),
|
||||||
|
})
|
||||||
|
}
|
||||||
Request::Pin { hash, policy } => {
|
Request::Pin { hash, policy } => {
|
||||||
let parsed = parse_hash(&hash)?;
|
let parsed = parse_hash(&hash)?;
|
||||||
self.meta.pin(&parsed.to_hex(), policy)?;
|
self.meta.pin(&parsed.to_hex(), policy)?;
|
||||||
|
|||||||
@@ -228,6 +228,30 @@ impl BlobStore {
|
|||||||
|
|
||||||
/// Export `hash` to `dest`. Returns bytes written and whether at least
|
/// Export `hash` to `dest`. Returns bytes written and whether at least
|
||||||
/// one file was reflinked.
|
/// one file was reflinked.
|
||||||
|
/// Import `data` as one raw blob (`Request::Put`).
|
||||||
|
pub async fn add_bytes(&self, data: Vec<u8>) -> Result<(Hash, u64), OpError> {
|
||||||
|
let size = data.len() as u64;
|
||||||
|
let tag = self.store.add_bytes(data).temp_tag().await.map_err(internal)?;
|
||||||
|
info!(hash = %tag.hash().to_hex(), size, "imported bytes");
|
||||||
|
Ok((tag.hash(), size))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole content of a complete raw blob (`Request::Read`), when it
|
||||||
|
/// is no larger than `limit`.
|
||||||
|
pub async fn read_bytes(&self, hash: Hash, limit: u64) -> Result<Vec<u8>, OpError> {
|
||||||
|
match self.store.blobs().status(hash).await.map_err(internal)? {
|
||||||
|
BlobStatus::Complete { size } if size > limit => Err(OpError::InvalidArgument(format!(
|
||||||
|
"{} is {size} bytes, more than {limit} can be read inline; materialize it",
|
||||||
|
hash.to_hex()
|
||||||
|
))),
|
||||||
|
BlobStatus::Complete { .. } => {
|
||||||
|
let bytes = self.store.blobs().get_bytes(hash).await.map_err(internal)?;
|
||||||
|
Ok(bytes.to_vec())
|
||||||
|
}
|
||||||
|
_ => Err(OpError::NotFound(format!("{} is not complete in the store", hash.to_hex()))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn materialize(
|
pub async fn materialize(
|
||||||
&self,
|
&self,
|
||||||
hash: Hash,
|
hash: Hash,
|
||||||
|
|||||||
@@ -276,3 +276,45 @@ fn reflink_when_supported() {
|
|||||||
drop(daemon);
|
drop(daemon);
|
||||||
let _ = std::fs::remove_dir_all(&base);
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Put and Read carry the bytes on the socket: a client that shares no
|
||||||
|
/// filesystem with the daemon (a desktop app and the system daemon, which
|
||||||
|
/// runs as its own user behind ProtectHome) stores and reads back without
|
||||||
|
/// a path either side must reach. A Put blob reads back by Materialize too.
|
||||||
|
#[test]
|
||||||
|
fn bytes_round_trip_inline() {
|
||||||
|
use base64::Engine as _;
|
||||||
|
let b64 = base64::engine::general_purpose::STANDARD;
|
||||||
|
let daemon = support::spawn_daemon();
|
||||||
|
let mut client = support::Client::connect(&daemon.socket);
|
||||||
|
let payload: Vec<u8> = (0..50_000u32).flat_map(|i| i.to_le_bytes()).collect();
|
||||||
|
|
||||||
|
let reply = client.request(&Request::Put { data: b64.encode(&payload) });
|
||||||
|
assert!(reply.ok, "put failed: {:?}", reply.error);
|
||||||
|
let hash = match reply.data {
|
||||||
|
Some(ResponseData::Added { hash, bytes }) => {
|
||||||
|
assert_eq!(bytes, payload.len() as u64);
|
||||||
|
hash
|
||||||
|
}
|
||||||
|
other => panic!("unexpected put reply: {other:?}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let reply = client.request(&Request::Read { hash: hash.clone() });
|
||||||
|
assert!(reply.ok, "read failed: {:?}", reply.error);
|
||||||
|
match reply.data {
|
||||||
|
Some(ResponseData::Bytes { data }) => assert_eq!(b64.decode(data).unwrap(), payload),
|
||||||
|
other => panic!("unexpected read reply: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let dest = dir.path().join("out.bin");
|
||||||
|
materialize(&mut client, &hash, &dest, MaterializeMode::Copy).unwrap();
|
||||||
|
assert_eq!(std::fs::read(&dest).unwrap(), payload);
|
||||||
|
|
||||||
|
// Not base64: refused, not stored.
|
||||||
|
let reply = client.request(&Request::Put { data: "not base64!".into() });
|
||||||
|
assert_eq!(reply.error.expect("error body").code, ErrorCode::InvalidArgument);
|
||||||
|
// Unknown hash: not found.
|
||||||
|
let reply = client.request(&Request::Read { hash: "0".repeat(64) });
|
||||||
|
assert!(!reply.ok);
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ use serde::{Deserialize, Serialize};
|
|||||||
/// Bumped on incompatible changes; reported in [`GlobalStatus`].
|
/// Bumped on incompatible changes; reported in [`GlobalStatus`].
|
||||||
pub const PROTOCOL_VERSION: u32 = 2;
|
pub const PROTOCOL_VERSION: u32 = 2;
|
||||||
|
|
||||||
|
/// The most bytes a [`Request::Put`] or a [`Request::Read`] reply carries
|
||||||
|
/// inline. Larger content goes through `Add`/`Materialize` and a path.
|
||||||
|
pub const MAX_INLINE_BYTES: u64 = 16 * 1024 * 1024;
|
||||||
|
|
||||||
/// A request from a client to the daemon. One JSON object per line.
|
/// A request from a client to the daemon. One JSON object per line.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
@@ -61,6 +65,24 @@ pub enum Request {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
mode: MaterializeMode,
|
mode: MaterializeMode,
|
||||||
},
|
},
|
||||||
|
/// Import bytes carried in the request, base64 (standard alphabet),
|
||||||
|
/// at most [`MAX_INLINE_BYTES`]. Replies [`ResponseData::Added`].
|
||||||
|
///
|
||||||
|
/// For a client that shares no filesystem with the daemon: the system
|
||||||
|
/// daemon runs as its own user with `ProtectHome`, so it can neither
|
||||||
|
/// read a file a desktop app wrote for `Add` nor write one back for
|
||||||
|
/// `Materialize`. Added in varde 0.1.4.
|
||||||
|
Put {
|
||||||
|
/// The content, base64.
|
||||||
|
data: String,
|
||||||
|
},
|
||||||
|
/// Read a complete raw blob back in the reply, base64, at most
|
||||||
|
/// [`MAX_INLINE_BYTES`]. Replies [`ResponseData::Bytes`]. The
|
||||||
|
/// counterpart of [`Request::Put`]; added in varde 0.1.4.
|
||||||
|
Read {
|
||||||
|
/// Hash (hex) of the blob.
|
||||||
|
hash: String,
|
||||||
|
},
|
||||||
/// Query daemon status, globally or for one hash.
|
/// Query daemon status, globally or for one hash.
|
||||||
Status {
|
Status {
|
||||||
/// If set, report on this hash; otherwise report global status.
|
/// If set, report on this hash; otherwise report global status.
|
||||||
@@ -228,6 +250,11 @@ pub enum ResponseData {
|
|||||||
/// Whether at least one file was reflinked rather than copied.
|
/// Whether at least one file was reflinked rather than copied.
|
||||||
reflinked: bool,
|
reflinked: bool,
|
||||||
},
|
},
|
||||||
|
/// A blob's content, base64. Reply to [`Request::Read`].
|
||||||
|
Bytes {
|
||||||
|
/// The content, base64 (standard alphabet).
|
||||||
|
data: String,
|
||||||
|
},
|
||||||
/// Global daemon status. Reply to [`Request::Status`] without a hash.
|
/// Global daemon status. Reply to [`Request::Status`] without a hash.
|
||||||
Status(GlobalStatus),
|
Status(GlobalStatus),
|
||||||
/// Per-hash status. Reply to [`Request::Status`] with a hash.
|
/// Per-hash status. Reply to [`Request::Status`] with a hash.
|
||||||
|
|||||||
Reference in New Issue
Block a user