sproto 0.1.0

Rust client for the Synology Drive sync protocol
Documentation
use std::collections::BTreeMap;

use crate::{
	channel::Channel,
	client::Client,
	error::{Error, Result},
	frame,
	pstream::PObject,
};

/// Metadata for a shared folder (team folder / view).
#[derive(Debug, Clone)]
pub struct ShareInfo {
	/// Server-assigned numeric identifier for this view.
	pub view_id: u64,

	/// Display name of the shared folder.
	pub name: String,

	/// Unique file identifier on the server.
	pub file_id: String,

	/// Whether the current user can download files from this share.
	pub can_download: bool,

	/// Whether the current user can sync this share to a device.
	pub can_sync: bool,
}

/// Metadata for a file or directory within a share.
#[derive(Debug, Clone)]
pub struct NodeInfo {
	/// Server-assigned numeric node identifier.
	pub node_id: u64,

	/// Sync identifier used for change tracking.
	pub sync_id: u64,

	/// File or directory name (leaf name, not full path).
	pub name: String,

	/// Size in bytes (0 for directories).
	pub file_size: u64,

	/// Last-modified time as a Unix timestamp.
	pub mtime: u64,

	/// Content hash for integrity verification.
	pub hash: String,

	/// Unique file identifier on the server.
	pub file_id: String,

	/// Whether this node is a file, directory, or symlink.
	pub file_type: FileType,

	/// Whether this node has been deleted (tombstone).
	pub is_removed: bool,
}

/// The kind of filesystem entry a node represents.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileType {
	/// Regular file.
	File,
	/// Directory.
	Dir,
	/// Symbolic link.
	Symlink,
}

/// List all available shares/views.
pub(crate) async fn list_team_folder(ch: &mut Channel, client: &Client) -> Result<Vec<ShareInfo>> {
	let request = client.build_request(
		"list_team_folder",
		pmap! {
			"limit" => 0u64,
			"offset" => 0u64,
			"sort_by" => "name",
			"sort_direction" => "asc",
		},
	);
	let response = ch.request(frame::SCMD_REQUEST, &request).await?;

	let view_list = response
		.get("view_list")
		.and_then(|v| v.as_array())
		.ok_or_else(|| Error::Decode("missing view_list in response".into()))?;

	let mut shares = Vec::new();
	for view in view_list {
		shares.push(parse_share_info(view)?);
	}

	Ok(shares)
}

/// List directory contents using `list_sync_to_device`.
/// Returns all children (no pagination) + a cursor for change detection.
/// Only works with team folder view IDs from `list_team_folder`.
///
/// Pass a `cursor` from a previous call to enable the server's fast path:
/// if nothing changed, the server returns the same cursor with no `node_list`,
/// avoiding a full directory transfer.
pub(crate) async fn list_sync_to_device(
	ch: &mut Channel,
	client: &Client,
	view_id: u64,
	path: &str,
	cursor: Option<&str>,
) -> Result<(Vec<NodeInfo>, Option<String>)> {
	let mut fields = pmap! {
		"path" => path,
		"view_id" => view_id,
		"merge_local" => 0u64,
		"list_dir_only" => 0u64,
		"include_node_locking" => 0u64,
	};
	if let Some(c) = cursor {
		fields
			.as_map_mut()
			.expect("pmap! always produces a Map")
			.insert("cursor".to_string(), PObject::from(c));
	}
	let request = client.build_request("list_sync_to_device", fields);
	let resp = ch.request(frame::SCMD_LIST, &request).await?;

	let cursor = resp
		.get("cursor")
		.and_then(|v| v.as_str())
		.map(ToString::to_string);

	let mut nodes = Vec::new();
	if let Some(node_list) = resp.get("node_list").and_then(|v| v.as_array()) {
		for node in node_list {
			nodes.push(parse_node_info(node)?);
		}
	}

	Ok((nodes, cursor))
}

/// List directory contents using `list` action (paginated).
pub(crate) async fn list(
	ch: &mut Channel,
	client: &Client,
	view_id: u64,
	path: &str,
) -> Result<Vec<NodeInfo>> {
	let mut all_nodes = Vec::new();
	let mut offset = 0u64;
	let limit = 1000u64;

	loop {
		let request = client.build_request(
			"list",
			pmap! {
				"path" => path,
				"view_id" => view_id,
				"merge_local" => 1u64,
				"list_dir_only" => 0u64,
				"search_criteria" => pmap! {
					"limit" => limit,
					"offset" => offset,
					"sort_by" => "name",
					"sort_direction" => "asc",
				},
			},
		);
		let resp = ch.request(frame::SCMD_LIST, &request).await?;

		let node_list = resp
			.get("node_list")
			.and_then(|v| v.as_array())
			.unwrap_or_default();

		let total_count = resp
			.get("total_count")
			.and_then(PObject::as_int)
			.unwrap_or(0);

		if all_nodes.is_empty()
			&& total_count > 0
			&& let Some(count) = usize::try_from(total_count).ok()
		{
			all_nodes.reserve(count);
		}

		for node in node_list {
			all_nodes.push(parse_node_info(node)?);
		}

		offset += node_list.len() as u64;
		if offset >= total_count || node_list.is_empty() {
			break;
		}
	}

	Ok(all_nodes)
}

fn get_int(map: &BTreeMap<String, PObject>, key: &str) -> u64 {
	map.get(key).and_then(PObject::as_int).unwrap_or(0)
}

fn get_str(map: &BTreeMap<String, PObject>, key: &str) -> String {
	map.get(key)
		.and_then(|v| v.as_str())
		.unwrap_or("")
		.to_string()
}

fn parse_share_info(obj: &PObject) -> Result<ShareInfo> {
	let map = obj
		.as_map()
		.ok_or_else(|| Error::Decode("view is not a map".into()))?;

	let caps = |key: &str| -> bool {
		map.get("capabilities")
			.and_then(|c| c.get(key))
			.and_then(PObject::as_int)
			.is_some_and(|v| v != 0)
	};

	Ok(ShareInfo {
		name: get_str(map, "name"),
		can_sync: caps("can_sync"),
		view_id: get_int(map, "view_id"),
		file_id: get_str(map, "file_id"),
		can_download: caps("can_download") && get_int(map, "disable_download") == 0,
	})
}

fn parse_node_info(obj: &PObject) -> Result<NodeInfo> {
	let map = obj
		.as_map()
		.ok_or_else(|| Error::Decode("node is not a map".into()))?;

	Ok(NodeInfo {
		name: get_str(map, "name"),
		hash: get_str(map, "hash"),
		mtime: get_int(map, "mtime"),
		node_id: get_int(map, "node_id"),
		sync_id: get_int(map, "sync_id"),
		file_id: get_str(map, "file_id"),
		file_size: get_int(map, "file_size"),
		is_removed: get_int(map, "is_removed") != 0,
		file_type: match map.get("file_type").and_then(|v| v.as_str()) {
			Some("dir") => FileType::Dir,
			Some("symlink") => FileType::Symlink,
			_ => FileType::File,
		},
	})
}