Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,16 +107,28 @@ shared link the browser shows a PIN form; `lsq pull` takes `--pin`.

## What works and what doesn't

It implements LocalSend protocol v2.1: multicast discovery, the upload API in
It implements LocalSend protocol v2.2: multicast discovery, the upload API in
both directions, the download API (`share`/`pull`, including the browser
page), PIN, and cancel. I've run the upload path against the desktop app
(v1.17.0) both ways, with single and multiple files and with a PIN set, and
files come across intact.
page), PIN, cancel, and the 422 answer to a checksum mismatch that 2.2 adds.

Checked against the LocalSend 1.18.2 core in both directions — lsq sending to
a real receiver in its normal (non-browser) receive mode, and a real client
sending to `lsq receive` — plus the discovery handshake, with the files
arriving byte-identical.

Note for anyone reading about "protocol v3": LocalSend 1.18.2 ships a `v3`
namespace, but only `POST /api/localsend/v3/nonce` and
`POST /api/localsend/v3/register` are routed, the register handler is a stub
marked *not wired up yet*, and the app builds its own HTTP client with the v2
version pinned. Devices still announce `2.2` and still transfer over the v2
endpoints, so there is nothing live to talk to on v3 yet.

Still missing:

- IPv6
- anything newer than protocol v2.1
- protocol v3: the nonce/signed-token handshake, pairing, and the WebRTC
transport. Dormant in the app as of 1.18.2; worth revisiting when the app
starts using it.

## Tests

Expand Down
31 changes: 18 additions & 13 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ async fn cmd_list(alias: String, port: u16, wait: u64, json: bool, eph: bool) ->
protocol: Protocol::Https,
download: false,
};
let peers = discovery::discover(&me, Duration::from_secs(wait)).await?;
let peers = discovery::discover(&me, Duration::from_secs(wait), Some(&identity)).await?;
if json {
let out: Vec<_> = peers
.iter()
Expand Down Expand Up @@ -341,11 +341,11 @@ async fn cmd_send(
if t.parse::<std::net::IpAddr>().is_ok() || t.parse::<SocketAddr>().is_ok() {
resolve_target(&[], Some(t))?
} else {
let peers = discovery::discover(&me, Duration::from_secs(wait)).await?;
let peers = discovery::discover(&me, Duration::from_secs(wait), Some(&identity)).await?;
resolve_target(&peers, Some(t))?
}
} else {
let peers = discovery::discover(&me, Duration::from_secs(wait)).await?;
let peers = discovery::discover(&me, Duration::from_secs(wait), Some(&identity)).await?;
resolve_target(&peers, None)?
};

Expand Down Expand Up @@ -461,7 +461,7 @@ async fn cmd_receive(
// one peer can't permanently block the receiver.
tokio::spawn(receiver::reap_stale_sessions(state.clone()));

announce_presence(&me, peers).await?;
announce_presence(&me, peers, Some(&identity)).await?;

if !quiet {
eprintln!(
Expand Down Expand Up @@ -508,9 +508,14 @@ async fn serve_until_interrupted(

/// Multicast presence for a long-running server (receive/share): initial
/// announcement burst, reply loop, and periodic re-announce.
async fn announce_presence(me: &SelfDevice, peers: discovery::PeerMap) -> Result<()> {
async fn announce_presence(
me: &SelfDevice,
peers: discovery::PeerMap,
identity: Option<&crate::certs::Identity>,
) -> Result<()> {
let udp = Arc::new(discovery::bind_multicast_socket(MULTICAST_PORT)?);
let http_client = crate::sender::insecure_client()?;
// Presents our certificate: see the note in `discovery::discover`.
let http_client = crate::sender::client_with_identity(identity)?;
tokio::spawn(discovery::listen_loop(
udp.clone(),
me.clone(),
Expand Down Expand Up @@ -579,7 +584,7 @@ async fn cmd_share(
axum_server::bind(addr).serve(app).await.map_err(anyhow::Error::from)
});

announce_presence(&me, peers).await?;
announce_presence(&me, peers, Some(&identity)).await?;

if !quiet {
eprintln!("sharing {count} file(s), {total} bytes as \"{alias}\"");
Expand Down Expand Up @@ -610,12 +615,13 @@ async fn cmd_pull(
tokio::fs::create_dir_all(&dest)
.await
.with_context(|| format!("cannot create dest dir {}", dest.display()))?;
let identity = identity(eph)?;

// Full URL target: use it as-is.
if let Some(t) = from.as_deref() {
if t.starts_with("http://") || t.starts_with("https://") {
let outcome =
crate::pull::pull_files(t, &dest, pin.as_deref(), max_size, quiet).await?;
crate::pull::pull_files(t, &dest, pin.as_deref(), max_size, quiet, Some(&identity)).await?;
return report_pull(outcome, &dest, quiet);
}
}
Expand All @@ -627,14 +633,14 @@ async fn cmd_pull(
let peer = resolve_target(&[], Some(t))?;
let host = format!("{}:{}", peer.addr, peer.info.port_or(DEFAULT_PORT));
let outcome = match crate::pull::pull_files(
&format!("https://{host}"), &dest, pin.as_deref(), max_size, quiet,
&format!("https://{host}"), &dest, pin.as_deref(), max_size, quiet, Some(&identity),
)
.await
{
Ok(o) => o,
Err(e) if e.unreachable => {
crate::pull::pull_files(
&format!("http://{host}"), &dest, pin.as_deref(), max_size, quiet,
&format!("http://{host}"), &dest, pin.as_deref(), max_size, quiet, Some(&identity),
)
.await
.map_err(|e2| anyhow::anyhow!("{e}; also failed over http: {e2}"))?
Expand All @@ -647,15 +653,14 @@ async fn cmd_pull(

// Alias/fingerprint target (or no target): discover, keep peers that
// announce the download flag.
let identity = identity(eph)?;
let me = SelfDevice {
alias,
fingerprint: identity.fingerprint.clone(),
port,
protocol: Protocol::Https,
download: false,
};
let peers = discovery::discover(&me, Duration::from_secs(wait)).await?;
let peers = discovery::discover(&me, Duration::from_secs(wait), Some(&identity)).await?;
let peer = if from.is_some() {
let p = resolve_target(&peers, from.as_deref())?;
if !p.info.download {
Expand All @@ -674,7 +679,7 @@ async fn cmd_pull(
}
let base = crate::sender::base_url(&peer);
let outcome =
crate::pull::pull_files(&base, &dest, pin.as_deref(), max_size, quiet).await?;
crate::pull::pull_files(&base, &dest, pin.as_deref(), max_size, quiet, Some(&identity)).await?;
report_pull(outcome, &dest, quiet)
}

Expand Down
12 changes: 10 additions & 2 deletions src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,10 +287,18 @@ async fn spawn_register_endpoint(
}

/// Active discovery: announce, listen for replies for `wait`, return peers.
pub async fn discover(me: &SelfDevice, wait: Duration) -> Result<Vec<Peer>> {
pub async fn discover(
me: &SelfDevice,
wait: Duration,
identity: Option<&crate::certs::Identity>,
) -> Result<Vec<Peer>> {
let sock = Arc::new(bind_multicast_socket(MULTICAST_PORT)?);
let peers: PeerMap = Arc::new(Mutex::new(HashMap::new()));
let http = crate::sender::insecure_client()?;
// The reply below is an HTTPS request to the peer. LocalSend 1.18+ makes
// the client certificate mandatory whenever it is not serving its web
// pages, so a certless client is dropped with a `CertificateRequired`
// TLS alert and the peer never learns we exist.
let http = crate::sender::client_with_identity(identity)?;

// Announce the ephemeral register port (plain http) so TCP replies
// reach us and not some other process on the default port.
Expand Down
6 changes: 3 additions & 3 deletions src/proto.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
//! LocalSend protocol v2.1 wire models.
//! Field shapes follow the LocalSend v2.1 protocol; unknown fields are ignored
//! LocalSend protocol v2.2 wire models.
//! Field shapes follow the LocalSend v2.2 protocol; unknown fields are ignored
//! and unknown enum values fall back gracefully (spec §7.1).

use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

pub const PROTOCOL_VERSION: &str = "2.1";
pub const PROTOCOL_VERSION: &str = "2.2";
pub const DEFAULT_PORT: u16 = 53317;
pub const MULTICAST_ADDR: &str = "224.0.0.167";
/// Discovery always happens on this fixed group port, even when the HTTP
Expand Down
5 changes: 4 additions & 1 deletion src/pull.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,11 @@ pub async fn pull_files(
pin: Option<&str>,
max_bytes: Option<u64>,
quiet: bool,
identity: Option<&crate::certs::Identity>,
) -> std::result::Result<PullOutcome, PullError> {
let client = crate::sender::insecure_client().map_err(PullError::rejected)?;
// Presents our certificate like the official client does on every
// request; a peer that requires one would otherwise refuse the handshake.
let client = crate::sender::client_with_identity(identity).map_err(PullError::rejected)?;
let base = base.trim_end_matches('/');

// 1. prepare-download
Expand Down
14 changes: 13 additions & 1 deletion src/receiver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,11 @@ async fn upload(
Ok(pair) => pair,
Err(e) => {
let _ = state.events.send(format!("upload failed: {e}"));
// Protocol 2.2: a checksum mismatch is the sender's problem to
// retry, not a receiver fault, and has its own status code.
if e.downcast_ref::<ChecksumMismatch>().is_some() {
return err(StatusCode::UNPROCESSABLE_ENTITY, "Checksum mismatch");
}
return err(
StatusCode::INTERNAL_SERVER_ERROR,
"Could not save file. Check receiving device for more information.",
Expand Down Expand Up @@ -499,6 +504,13 @@ async fn upload(
StatusCode::OK.into_response()
}

/// A received body did not match the `sha256` its sender declared.
/// Protocol 2.2 answers this with 422 so the sender can tell a corrupted
/// transfer apart from a fault on the receiving side.
#[derive(Debug, thiserror::Error)]
#[error("sha256 mismatch")]
pub(crate) struct ChecksumMismatch;

/// Stream a body to a unique `.lsq-*.part` temp file in the dest dir,
/// enforcing the declared size (and sha256 when provided) and an idle read
/// timeout. Returns the temp path plus an armed cleanup guard; on any error
Expand Down Expand Up @@ -546,7 +558,7 @@ where
use sha2::Digest;
let actual: String = h.finalize().iter().map(|b| format!("{b:02x}")).collect();
if !actual.eq_ignore_ascii_case(expected) {
anyhow::bail!("sha256 mismatch");
return Err(ChecksumMismatch.into());
}
}
// fsync the data before the caller makes the final name visible, so a crash
Expand Down
16 changes: 8 additions & 8 deletions tests/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ async fn pull_module_end_to_end() {
let b = b"second file".to_vec();
let s = start_share(&[("data.bin", &a), ("note.txt", &b)], None).await;
let dest = TempDir::new().unwrap();
let outcome = lsq::pull::pull_files(&base(&s), dest.path(), None, None, true)
let outcome = lsq::pull::pull_files(&base(&s), dest.path(), None, None, true, None)
.await
.unwrap();
assert_eq!(outcome.fetched, 2);
Expand All @@ -354,12 +354,12 @@ async fn pull_module_end_to_end() {
async fn pull_uses_pin_and_reports_wrong_pin() {
let s = start_share(&[("a.txt", b"x")], Some("9999")).await;
let dest = TempDir::new().unwrap();
let err = lsq::pull::pull_files(&base(&s), dest.path(), None, None, true)
let err = lsq::pull::pull_files(&base(&s), dest.path(), None, None, true, None)
.await
.unwrap_err();
assert!(err.to_string().contains("PIN"));
assert!(!err.unreachable);
let outcome = lsq::pull::pull_files(&base(&s), dest.path(), Some("9999"), None, true)
let outcome = lsq::pull::pull_files(&base(&s), dest.path(), Some("9999"), None, true, None)
.await
.unwrap();
assert_eq!(outcome.fetched, 1);
Expand All @@ -370,7 +370,7 @@ async fn pull_never_overwrites_existing_files() {
let s = start_share(&[("keep.txt", b"new")], None).await;
let dest = TempDir::new().unwrap();
std::fs::write(dest.path().join("keep.txt"), b"original").unwrap();
lsq::pull::pull_files(&base(&s), dest.path(), None, None, true)
lsq::pull::pull_files(&base(&s), dest.path(), None, None, true, None)
.await
.unwrap();
assert_eq!(std::fs::read(dest.path().join("keep.txt")).unwrap(), b"original");
Expand All @@ -381,7 +381,7 @@ async fn pull_never_overwrites_existing_files() {
async fn pull_sanitizes_hostile_names() {
let s = start_share(&[("../../../tmp/lsq-pull-escape.txt", b"x")], None).await;
let dest = TempDir::new().unwrap();
lsq::pull::pull_files(&base(&s), dest.path(), None, None, true)
lsq::pull::pull_files(&base(&s), dest.path(), None, None, true, None)
.await
.unwrap();
assert!(!std::path::Path::new("/tmp/lsq-pull-escape.txt").exists());
Expand All @@ -392,7 +392,7 @@ async fn pull_sanitizes_hostile_names() {
async fn pull_respects_max_size() {
let s = start_share(&[("big.bin", &vec![0u8; 5000])], None).await;
let dest = TempDir::new().unwrap();
let err = lsq::pull::pull_files(&base(&s), dest.path(), None, Some(1024), true)
let err = lsq::pull::pull_files(&base(&s), dest.path(), None, Some(1024), true, None)
.await
.unwrap_err();
assert!(err.to_string().contains("max-size"));
Expand All @@ -404,7 +404,7 @@ async fn pull_marks_dead_peer_unreachable() {
// unroutable TEST-NET address → connect timeout flagged as unreachable
let dest = TempDir::new().unwrap();
let start = std::time::Instant::now();
let err = lsq::pull::pull_files("http://192.0.2.1:53317", dest.path(), None, None, true)
let err = lsq::pull::pull_files("http://192.0.2.1:53317", dest.path(), None, None, true, None)
.await
.unwrap_err();
assert!(err.unreachable);
Expand Down Expand Up @@ -438,7 +438,7 @@ async fn pull_rejects_oversized_body_from_lying_server() {
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });

let dest = TempDir::new().unwrap();
let err = lsq::pull::pull_files(&format!("http://{addr}"), dest.path(), None, None, true)
let err = lsq::pull::pull_files(&format!("http://{addr}"), dest.path(), None, None, true, None)
.await
.unwrap_err();
assert!(err.to_string().contains("exceeds declared size"));
Expand Down
8 changes: 5 additions & 3 deletions tests/loopback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,13 +279,15 @@ async fn sha256_verified_when_provided() {
.json(&req).send().await.unwrap();
let sess: PrepareUploadResponse = resp.json().await.unwrap();
assert_eq!(do_upload(&c, &s, &sess, "f", b"payload").await, 200);
// wrong hash rejected, file absent, no partials
// Wrong hash rejected with 422 (protocol 2.2), file absent, no partials.
// 422 and not 500: the transfer is the sender's to retry, and the
// official server answers a checksum mismatch the same way.
let mut req = prepare_req(&[("g", "bad.txt", b"payload")]);
req.files.get_mut("g").unwrap().sha256 = Some("00".repeat(32));
let resp = c.post(format!("{}{API_BASE}/prepare-upload", base(&s)))
.json(&req).send().await.unwrap();
let sess: PrepareUploadResponse = resp.json().await.unwrap();
assert_eq!(do_upload(&c, &s, &sess, "g", b"payload").await, 500);
assert_eq!(do_upload(&c, &s, &sess, "g", b"payload").await, 422);
assert!(!dest_file(&s, "bad.txt").exists());
assert!(no_part_files(s.dest.path()));
}
Expand Down Expand Up @@ -672,7 +674,7 @@ async fn register_returns_own_info_and_records_peer() {
assert_eq!(r.status(), 200);
let body: RegisterResponse = r.json().await.unwrap();
assert_eq!(body.alias, "test-receiver");
assert_eq!(body.version, "2.1");
assert_eq!(body.version, PROTOCOL_VERSION);
assert!(!body.fingerprint.is_empty());
}

Expand Down
Loading
Loading