From 687af8bd2a2a25bd236fdb20c00734023b3194d0 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Sat, 3 Oct 2026 13:08:50 +0200 Subject: [PATCH] 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 --- varde-ctl/src/main.rs | 1 + varde-daemon/Cargo.toml | 2 ++ varde-daemon/src/daemon.rs | 33 ++++++++++++++++++++++++++ varde-daemon/src/store.rs | 24 +++++++++++++++++++ varde-daemon/tests/store_ops.rs | 42 +++++++++++++++++++++++++++++++++ varde-proto/src/lib.rs | 27 +++++++++++++++++++++ 6 files changed, 129 insertions(+) diff --git a/varde-ctl/src/main.rs b/varde-ctl/src/main.rs index a58a77d..812c8c3 100644 --- a/varde-ctl/src/main.rs +++ b/varde-ctl/src/main.rs @@ -246,6 +246,7 @@ fn print_human(data: Option<&ResponseData>) { match data { ResponseData::Added { hash, bytes } => println!("added {hash} ({bytes} bytes)"), ResponseData::Pinned { hash } => println!("pinned {hash}"), + ResponseData::Bytes { data } => println!("{data}"), ResponseData::Done {} => println!("ok"), ResponseData::Materialized { bytes, reflinked } => { let how = if *reflinked { "reflinked" } else { "copied" }; diff --git a/varde-daemon/Cargo.toml b/varde-daemon/Cargo.toml index 62ece7c..4ae1f0e 100644 --- a/varde-daemon/Cargo.toml +++ b/varde-daemon/Cargo.toml @@ -16,6 +16,8 @@ required-features = ["socket-api"] varde-proto = { workspace = true } iroh = { 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. iroh-mdns-address-lookup = "0.4" # Interop with iroh-blobs streams and channels; both are already in the diff --git a/varde-daemon/src/daemon.rs b/varde-daemon/src/daemon.rs index d617241..7bd0a3e 100644 --- a/varde-daemon/src/daemon.rs +++ b/varde-daemon/src/daemon.rs @@ -371,6 +371,39 @@ impl Daemon { 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 } => { let parsed = parse_hash(&hash)?; self.meta.pin(&parsed.to_hex(), policy)?; diff --git a/varde-daemon/src/store.rs b/varde-daemon/src/store.rs index 76f0902..6d5b304 100644 --- a/varde-daemon/src/store.rs +++ b/varde-daemon/src/store.rs @@ -228,6 +228,30 @@ impl BlobStore { /// Export `hash` to `dest`. Returns bytes written and whether at least /// one file was reflinked. + /// Import `data` as one raw blob (`Request::Put`). + pub async fn add_bytes(&self, data: Vec) -> 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, 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( &self, hash: Hash, diff --git a/varde-daemon/tests/store_ops.rs b/varde-daemon/tests/store_ops.rs index aaae39a..bb71063 100644 --- a/varde-daemon/tests/store_ops.rs +++ b/varde-daemon/tests/store_ops.rs @@ -276,3 +276,45 @@ fn reflink_when_supported() { drop(daemon); 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 = (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); +} diff --git a/varde-proto/src/lib.rs b/varde-proto/src/lib.rs index 25c6573..fd5a09e 100644 --- a/varde-proto/src/lib.rs +++ b/varde-proto/src/lib.rs @@ -20,6 +20,10 @@ use serde::{Deserialize, Serialize}; /// Bumped on incompatible changes; reported in [`GlobalStatus`]. 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. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -61,6 +65,24 @@ pub enum Request { #[serde(default)] 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. 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. 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. Status(GlobalStatus), /// Per-hash status. Reply to [`Request::Status`] with a hash.