The version work and the sync-output work landed in one change; they can be split apart if the history wants them separate.
872 lines
30 KiB
Rust
872 lines
30 KiB
Rust
//! Driving the `pimsync` binary.
|
|
//!
|
|
//! pimsync handles the CalDAV and WebCal legs, mirroring each remote into a local
|
|
//! vdir. It is invoked one-shot: its own daemon would race the reconciler over the
|
|
//! same files, so scheduling belongs to calcalist.
|
|
|
|
use std::fmt;
|
|
use std::fs;
|
|
use std::io;
|
|
use std::os::fd::AsFd;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Output, Stdio};
|
|
|
|
use thiserror::Error;
|
|
|
|
use crate::config::{Config, Endpoint, EndpointKind};
|
|
|
|
pub const BINARY: &str = "pimsync";
|
|
|
|
/// The oldest pimsync this build generates configuration for. `discover`'s
|
|
/// output format is undocumented and changed between 0.5.9 and 0.5.11, and
|
|
/// `parse_discovery` only understands the newer shape.
|
|
pub const MIN_VERSION: Version = Version {
|
|
major: 0,
|
|
minor: 5,
|
|
patch: 11,
|
|
};
|
|
|
|
/// The first release this build makes no claim about. pimsync is pre-1.0 and
|
|
/// its config syntax may change between minor releases, so a series is
|
|
/// admitted only once read: 0.6.0 renamed `auth` to `auth_method`, which costs
|
|
/// nothing here — generated storages carry `username` and `password`, never
|
|
/// `auth` — and left `discover`'s output word for word as it was.
|
|
pub const NEXT_UNKNOWN_VERSION: Version = Version {
|
|
major: 0,
|
|
minor: 7,
|
|
patch: 0,
|
|
};
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub struct Version {
|
|
pub major: u64,
|
|
pub minor: u64,
|
|
pub patch: u64,
|
|
}
|
|
|
|
impl fmt::Display for Version {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
|
|
}
|
|
}
|
|
|
|
impl Version {
|
|
pub fn is_supported(&self) -> bool {
|
|
*self >= MIN_VERSION && *self < NEXT_UNKNOWN_VERSION
|
|
}
|
|
|
|
/// Parses the `pimsync 0.5.11` line printed by `pimsync version`.
|
|
fn parse(output: &str) -> Option<Self> {
|
|
let field = output.split_whitespace().nth(1)?;
|
|
let mut parts = field.split('.');
|
|
Some(Version {
|
|
major: number(parts.next()?)?,
|
|
minor: number(parts.next()?)?,
|
|
patch: number(parts.next().unwrap_or("0"))?,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// The leading digits of one version component. pimsync takes its version from
|
|
/// `git describe`, so a build off a tag carries a suffix — `0.6.0-1-ga08ee3c`
|
|
/// — which belongs to no number.
|
|
fn number(part: &str) -> Option<u64> {
|
|
part.split(|c: char| !c.is_ascii_digit())
|
|
.next()?
|
|
.parse()
|
|
.ok()
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum PimsyncError {
|
|
#[error("`{BINARY}` was not found on PATH; install pimsync {MIN_VERSION} or newer")]
|
|
NotFound,
|
|
#[error("could not run `{BINARY}`: {0}")]
|
|
Spawn(#[source] io::Error),
|
|
#[error("`{BINARY} version` failed: {0}")]
|
|
Failed(String),
|
|
#[error("could not read a version out of `{0}`")]
|
|
Unparseable(String),
|
|
#[error("could not write {path}: {source}")]
|
|
Write {
|
|
path: std::path::PathBuf,
|
|
#[source]
|
|
source: io::Error,
|
|
},
|
|
#[error("`{BINARY} {command}` failed: {message}")]
|
|
Command { command: String, message: String },
|
|
#[error(
|
|
"endpoint `{endpoint}` has url `{url}`, which names no calendar collection; it must point at the calendar itself, not at the server root"
|
|
)]
|
|
UrlHasNoCollection { endpoint: String, url: String },
|
|
}
|
|
|
|
/// Asks the installed pimsync for its version.
|
|
pub fn probe() -> Result<Version, PimsyncError> {
|
|
let output = Command::new(BINARY)
|
|
.arg("version")
|
|
.output()
|
|
.map_err(spawn_error)?;
|
|
if !output.status.success() {
|
|
return Err(PimsyncError::Failed(
|
|
String::from_utf8_lossy(&output.stderr).trim().to_string(),
|
|
));
|
|
}
|
|
let text = String::from_utf8_lossy(&output.stdout);
|
|
let line = text.lines().next().unwrap_or_default().trim();
|
|
Version::parse(line).ok_or_else(|| PimsyncError::Unparseable(line.to_string()))
|
|
}
|
|
|
|
/// Name of the single local storage every pair mirrors into. pimsync allows one
|
|
/// storage to take part in several pairs, so one declaration serves all of them.
|
|
const LOCAL_STORAGE: &str = "local";
|
|
|
|
pub fn config_path(state_dir: &Path) -> PathBuf {
|
|
state_dir.join("pimsync.conf")
|
|
}
|
|
|
|
pub(crate) fn status_path(state_dir: &Path) -> PathBuf {
|
|
state_dir.join("pimsync-status")
|
|
}
|
|
|
|
/// Which way a generated configuration lets items travel.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Direction {
|
|
/// The ordinary cycle: remote and local converge on each other.
|
|
Both,
|
|
/// Remote to local only, for `--dry-run`. pimsync writes nothing to any
|
|
/// server in this mode — but it does delete local items the remote has
|
|
/// never seen, so it is only ever pointed at a throwaway copy of the vdirs.
|
|
PullOnly,
|
|
}
|
|
|
|
/// Builds a pimsync configuration covering every CalDAV and WebCal endpoint.
|
|
///
|
|
/// Google endpoints are absent by design: pimsync has no REST storage, and its
|
|
/// CalDAV storage speaks only HTTP Basic auth, which Google's endpoint has
|
|
/// rejected since March 2025. calcalist syncs those itself.
|
|
pub fn generate_for(
|
|
config: &Config,
|
|
state_dir: &Path,
|
|
direction: Direction,
|
|
) -> Result<String, PimsyncError> {
|
|
let mut out = String::new();
|
|
out.push_str("# Generated by calcalist. Edits here are overwritten on every sync.\n\n");
|
|
out.push_str(&format!(
|
|
"status_path {}\n\n",
|
|
quote(&status_path(state_dir).display().to_string())
|
|
));
|
|
out.push_str(&format!(
|
|
"storage {LOCAL_STORAGE} {{\n\ttype vdir/icalendar\n\tpath {}\n\tfileext ics\n}}\n",
|
|
quote(&crate::sync::vdir_root(state_dir).display().to_string())
|
|
));
|
|
|
|
for endpoint in &config.endpoints {
|
|
match &endpoint.kind {
|
|
EndpointKind::Caldav { .. } => out.push_str(&caldav_pair(endpoint, direction)?),
|
|
EndpointKind::Webcal { url, url_command } => {
|
|
out.push_str(&webcal_pair(
|
|
endpoint,
|
|
url.as_deref(),
|
|
url_command.as_deref(),
|
|
));
|
|
}
|
|
// Handled by calcalist's own Google module.
|
|
EndpointKind::Google { .. } => {}
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn caldav_pair(endpoint: &Endpoint, direction: Direction) -> Result<String, PimsyncError> {
|
|
let EndpointKind::Caldav {
|
|
url,
|
|
username,
|
|
secret_command,
|
|
} = &endpoint.kind
|
|
else {
|
|
unreachable!("caller matched on Caldav")
|
|
};
|
|
let (base, href) =
|
|
split_collection_url(url).ok_or_else(|| PimsyncError::UrlHasNoCollection {
|
|
endpoint: endpoint.id.clone(),
|
|
url: url.clone(),
|
|
})?;
|
|
|
|
let id = &endpoint.id;
|
|
let mut block = format!(
|
|
"\nstorage {id}_remote {{\n\ttype caldav\n\turl {}\n\tdiscovery collections\n\tusername {}\n",
|
|
quote(&base),
|
|
quote(username)
|
|
);
|
|
if let Some(command) = secret_command {
|
|
// Secrets never live in the portable config, so pimsync runs the same
|
|
// command calcalist would to fetch them.
|
|
block.push_str(&format!("\tpassword {{\n\t\tshell {command}\n\t}}\n"));
|
|
}
|
|
block.push_str("}\n");
|
|
|
|
// The remote is storage_a, so a concurrent server-side change wins over a
|
|
// local one calcalist has not yet reconciled. Nothing is lost: the next
|
|
// cycle sees the server's version and decides properly.
|
|
let resolution = match direction {
|
|
// `one_way` never produces a conflict, so there is nothing to resolve.
|
|
Direction::PullOnly => "\tone_way\n",
|
|
Direction::Both => "\tconflict_resolution keep a\n",
|
|
};
|
|
block.push_str(&format!(
|
|
"\npair {id} {{\n\tstorage_a {id}_remote\n\tstorage_b {LOCAL_STORAGE}\n\
|
|
\tcollection {{\n\t\talias {id}\n\t\thref_a {}\n\t\tid_b {id}\n\t}}\n\
|
|
{resolution}\ton_empty skip\n\ton_delete skip\n}}\n",
|
|
quote(&href)
|
|
));
|
|
Ok(block)
|
|
}
|
|
|
|
fn webcal_pair(endpoint: &Endpoint, url: Option<&str>, url_command: Option<&str>) -> String {
|
|
let id = &endpoint.id;
|
|
// A feed URL that is itself a credential is fetched by command, the same way
|
|
// passwords are, so it never appears in either configuration file.
|
|
let url_directive = match (url, url_command) {
|
|
(_, Some(command)) => format!("\turl {{\n\t\tshell {command}\n\t}}\n"),
|
|
(Some(url), None) => format!("\turl {}\n", quote(url)),
|
|
(None, None) => String::new(),
|
|
};
|
|
// No `read_only` directive here: pimsync 0.5.11 documents it as applying to
|
|
// every storage type, but rejects it on a webcal storage with a bare "Could
|
|
// not parse file". It is redundant regardless — webcal is read-only by type,
|
|
// and `one_way` already fixes the direction.
|
|
format!(
|
|
"\nstorage {id}_remote {{\n\ttype webcal\n{url_directive}\tcollection_id {id}\n}}\n\
|
|
\npair {id} {{\n\tstorage_a {id}_remote\n\tstorage_b {LOCAL_STORAGE}\n\
|
|
\tcollection {{\n\t\talias {id}\n\t\tid_a {id}\n\t\tid_b {id}\n\t}}\n\
|
|
\tone_way\n\ton_empty skip\n\ton_delete skip\n}}\n"
|
|
)
|
|
}
|
|
|
|
/// Splits a calendar URL into the container to enumerate and the collection's
|
|
/// own path.
|
|
///
|
|
/// pimsync identifies a collection either by an id taken from a URL segment or
|
|
/// by its exact path. The path is used here, so a calendar whose last segment
|
|
/// happens to match another's cannot be confused for it.
|
|
fn split_collection_url(url: &str) -> Option<(String, String)> {
|
|
let authority_start = url.find("://")? + 3;
|
|
let path_start = authority_start + url[authority_start..].find('/')?;
|
|
let segments: Vec<&str> = url[path_start..]
|
|
.split('/')
|
|
.filter(|segment| !segment.is_empty())
|
|
.collect();
|
|
let (_, parents) = segments.split_last()?;
|
|
|
|
let href = format!("/{}/", segments.join("/"));
|
|
let base = match parents {
|
|
[] => format!("{}/", &url[..path_start]),
|
|
parents => format!("{}/{}/", &url[..path_start], parents.join("/")),
|
|
};
|
|
Some((base, href))
|
|
}
|
|
|
|
/// pimsync accepts quoted values, which keeps paths with spaces intact.
|
|
fn quote(value: &str) -> String {
|
|
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
|
|
}
|
|
|
|
/// Writes the generated configuration, returning its path.
|
|
pub fn write_config(config: &Config, state_dir: &Path) -> Result<PathBuf, PimsyncError> {
|
|
write_config_for(config, state_dir, Direction::Both)
|
|
}
|
|
|
|
pub fn write_config_for(
|
|
config: &Config,
|
|
state_dir: &Path,
|
|
direction: Direction,
|
|
) -> Result<PathBuf, PimsyncError> {
|
|
let path = config_path(state_dir);
|
|
let text = generate_for(config, state_dir, direction)?;
|
|
fs::create_dir_all(status_path(state_dir)).map_err(|source| PimsyncError::Write {
|
|
path: status_path(state_dir),
|
|
source,
|
|
})?;
|
|
fs::write(&path, text).map_err(|source| PimsyncError::Write {
|
|
path: path.clone(),
|
|
source,
|
|
})?;
|
|
Ok(path)
|
|
}
|
|
|
|
/// The storage name the probe config gives the server being examined. Parsing
|
|
/// anchors on it, because pimsync discovers *both* storages in a pair and prints
|
|
/// their collections under one heading each.
|
|
const PROBE_REMOTE: &str = "probe_remote";
|
|
|
|
/// One collection found on a server.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Collection {
|
|
/// The path pimsync reported, e.g. `/calendars/me/work/`.
|
|
pub href: String,
|
|
/// A usable endpoint id, taken from the last meaningful path segment.
|
|
pub id: String,
|
|
}
|
|
|
|
/// Asks pimsync which calendars a server offers.
|
|
///
|
|
/// `url` is a container to enumerate — an account or principal path — rather
|
|
/// than a single calendar, so `split_collection_url` does not apply here.
|
|
///
|
|
/// `pimsync discover` is documented as informational and as not altering
|
|
/// pimsync's state, so pointing it at a server the user has not configured yet
|
|
/// is safe. The probe configuration it needs is written under a name of its own
|
|
/// so it can never be mistaken for, or overwrite, the generated `pimsync.conf`.
|
|
pub fn discover(
|
|
state_dir: &Path,
|
|
url: &str,
|
|
username: &str,
|
|
secret_command: Option<&str>,
|
|
) -> Result<Vec<Collection>, PimsyncError> {
|
|
let config_path = state_dir.join("discover.conf");
|
|
let scratch = state_dir.join("discover-vdir");
|
|
fs::create_dir_all(&scratch).map_err(|source| PimsyncError::Write {
|
|
path: scratch.clone(),
|
|
source,
|
|
})?;
|
|
fs::write(
|
|
&config_path,
|
|
probe_config(state_dir, &scratch, url, username, secret_command),
|
|
)
|
|
.map_err(|source| PimsyncError::Write {
|
|
path: config_path.clone(),
|
|
source,
|
|
})?;
|
|
|
|
let output = Command::new(BINARY)
|
|
.arg("-c")
|
|
.arg(&config_path)
|
|
.arg("discover")
|
|
.output()
|
|
.map_err(spawn_error)?;
|
|
|
|
// Everything, including the failures, comes back on stdout.
|
|
let text = String::from_utf8_lossy(&output.stdout).into_owned();
|
|
let _ = fs::remove_dir_all(&scratch);
|
|
if !output.status.success() {
|
|
return Err(PimsyncError::Command {
|
|
command: "discover".to_string(),
|
|
message: strip_ansi(&text).trim().to_string(),
|
|
});
|
|
}
|
|
Ok(parse_discovery(&text))
|
|
}
|
|
|
|
fn probe_config(
|
|
state_dir: &Path,
|
|
scratch: &Path,
|
|
url: &str,
|
|
username: &str,
|
|
secret_command: Option<&str>,
|
|
) -> String {
|
|
let password = match secret_command {
|
|
Some(command) => format!("\tpassword {{\n\t\tshell {command}\n\t}}\n"),
|
|
None => String::new(),
|
|
};
|
|
format!(
|
|
"# Generated by `calcalist discover`. Rewritten on every run.\n\
|
|
status_path {}\n\n\
|
|
storage local {{\n\ttype vdir/icalendar\n\tpath {}\n\tfileext ics\n}}\n\n\
|
|
storage {PROBE_REMOTE} {{\n\ttype caldav\n\turl {}\n\tdiscovery collections\n\
|
|
\tusername {}\n{password}}}\n\n\
|
|
pair probe {{\n\tstorage_a {PROBE_REMOTE}\n\tstorage_b local\n\
|
|
\tcollections from a\n}}\n",
|
|
quote(&state_dir.join("discover-status").display().to_string()),
|
|
quote(&scratch.display().to_string()),
|
|
quote(url),
|
|
quote(username),
|
|
)
|
|
}
|
|
|
|
/// Reads the collections out of `pimsync discover` output.
|
|
///
|
|
/// The format is not documented, so this was established against a real server
|
|
/// and is deliberately forgiving. The one thing it must get right: a pair has
|
|
/// two storages and pimsync reports both, so `Found` lines are only taken after
|
|
/// the heading naming the server. Otherwise the scratch directory's own
|
|
/// contents would be offered as if they were the user's calendars.
|
|
fn parse_discovery(output: &str) -> Vec<Collection> {
|
|
let mut collections = Vec::new();
|
|
let mut in_remote = false;
|
|
for line in output.lines() {
|
|
let line = strip_ansi(line);
|
|
let line = line.trim();
|
|
if line.starts_with("==>") {
|
|
in_remote = line.contains(PROBE_REMOTE);
|
|
continue;
|
|
}
|
|
let Some(href) = line.strip_prefix("Found ") else {
|
|
continue;
|
|
};
|
|
let href = href.trim();
|
|
if !in_remote || href.is_empty() {
|
|
continue;
|
|
}
|
|
collections.push(Collection {
|
|
id: collection_id(href),
|
|
href: href.to_string(),
|
|
});
|
|
}
|
|
collections
|
|
}
|
|
|
|
/// A plausible endpoint id: the last non-empty path segment, restricted to
|
|
/// characters that are unambiguous in a configuration file.
|
|
fn collection_id(href: &str) -> String {
|
|
let segment = href
|
|
.rsplit('/')
|
|
.find(|segment| !segment.is_empty())
|
|
.unwrap_or(href);
|
|
let id: String = segment
|
|
.chars()
|
|
.map(|ch| match ch {
|
|
'a'..='z' | '0'..='9' | '-' | '_' => ch,
|
|
'A'..='Z' => ch.to_ascii_lowercase(),
|
|
_ => '-',
|
|
})
|
|
.collect();
|
|
let id = id.trim_matches('-').to_string();
|
|
if id.is_empty() { "calendar".into() } else { id }
|
|
}
|
|
|
|
/// Removes ANSI colour, which pimsync emits even when its output is a pipe and
|
|
/// even under NO_COLOR.
|
|
fn strip_ansi(text: &str) -> String {
|
|
let mut out = String::with_capacity(text.len());
|
|
let mut chars = text.chars();
|
|
while let Some(ch) = chars.next() {
|
|
if ch != '\u{1b}' {
|
|
out.push(ch);
|
|
continue;
|
|
}
|
|
// Skip up to and including the terminating letter of the escape.
|
|
for next in chars.by_ref() {
|
|
if next.is_ascii_alphabetic() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Runs one pimsync cycle.
|
|
///
|
|
/// Deliberately one-shot: `pimsync daemon` would write the same vdirs the
|
|
/// reconciler is reading, so calcalist owns the scheduling instead.
|
|
/// A cycle is minutes of network work, and pimsync's per-item progress is the
|
|
/// only sign it is alive. Capturing that output left `sync` looking wedged for
|
|
/// the whole run and then printing nothing, so it goes straight to the terminal.
|
|
pub fn sync(config_path: &Path) -> Result<(), PimsyncError> {
|
|
let status = invocation(config_path, "sync")
|
|
.stdout(progress_stream()?)
|
|
.stderr(Stdio::inherit())
|
|
.status()
|
|
.map_err(spawn_error)?;
|
|
if status.success() {
|
|
return Ok(());
|
|
}
|
|
// The detail has already been printed as it happened; repeating it here
|
|
// would only put a second copy under the report.
|
|
Err(PimsyncError::Command {
|
|
command: "sync".to_string(),
|
|
message: format!("exited with {status}"),
|
|
})
|
|
}
|
|
|
|
/// Validates the generated configuration and the storages it names.
|
|
///
|
|
/// Captured rather than streamed: `doctor` reads the message to tell a config
|
|
/// it must fix from a server it cannot reach, and prints its own summary.
|
|
pub fn check(config_path: &Path) -> Result<(), PimsyncError> {
|
|
let output = invocation(config_path, "check")
|
|
.output()
|
|
.map_err(spawn_error)?;
|
|
if output.status.success() {
|
|
return Ok(());
|
|
}
|
|
let message = diagnostics(&output);
|
|
Err(PimsyncError::Command {
|
|
command: "check".to_string(),
|
|
message: if message.is_empty() {
|
|
format!("exited with {}", output.status)
|
|
} else {
|
|
message
|
|
},
|
|
})
|
|
}
|
|
|
|
/// `pimsync -c <config> <command>`, with stdin closed so that a secret helper
|
|
/// reaching for a terminal fails outright rather than waiting on one forever.
|
|
fn invocation(config_path: &Path, command: &str) -> Command {
|
|
let mut process = Command::new(BINARY);
|
|
process
|
|
.arg("-c")
|
|
.arg(config_path)
|
|
.arg(command)
|
|
.stdin(Stdio::null());
|
|
process
|
|
}
|
|
|
|
/// pimsync's progress, routed to our stderr so that stdout carries only the
|
|
/// report and stays worth piping.
|
|
fn progress_stream() -> Result<Stdio, PimsyncError> {
|
|
io::stderr()
|
|
.as_fd()
|
|
.try_clone_to_owned()
|
|
.map(Stdio::from)
|
|
.map_err(PimsyncError::Spawn)
|
|
}
|
|
|
|
fn spawn_error(error: io::Error) -> PimsyncError {
|
|
if error.kind() == io::ErrorKind::NotFound {
|
|
PimsyncError::NotFound
|
|
} else {
|
|
PimsyncError::Spawn(error)
|
|
}
|
|
}
|
|
|
|
/// What a failed run had to say for itself.
|
|
///
|
|
/// Both streams are read: pimsync reports execution errors on stderr but puts
|
|
/// parse failures on stdout, and which one carries the reason is not knowable
|
|
/// from here.
|
|
fn diagnostics(output: &Output) -> String {
|
|
let streams = [&output.stderr, &output.stdout];
|
|
let said: Vec<String> = streams
|
|
.iter()
|
|
.map(|stream| String::from_utf8_lossy(stream).trim().to_string())
|
|
.filter(|text| !text.is_empty())
|
|
.collect();
|
|
said.join("\n")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
use crate::config::Config;
|
|
use std::path::Path;
|
|
|
|
const CONFIG: &str = r#"
|
|
version = 1
|
|
|
|
[[endpoint]]
|
|
id = "work"
|
|
type = "caldav"
|
|
url = "https://dav.example.com/dav/calendars/user/me/work/"
|
|
username = "me@example.com"
|
|
secret_command = "pass show example/caldav"
|
|
|
|
[[endpoint]]
|
|
id = "gcal"
|
|
type = "google"
|
|
calendar_id = "me@example.com"
|
|
client_id = "abc.apps.googleusercontent.com"
|
|
|
|
[[endpoint]]
|
|
id = "holidays"
|
|
type = "webcal"
|
|
url = "https://example.org/holidays.ics"
|
|
"#;
|
|
|
|
/// The text of one `storage`/`pair` block, so assertions do not accidentally
|
|
/// match a directive belonging to a different block.
|
|
fn block<'a>(text: &'a str, opening: &str) -> &'a str {
|
|
let start = text.find(opening).expect("block should be present");
|
|
let rest = &text[start..];
|
|
rest.find("\n}\n").map_or(rest, |end| &rest[..end])
|
|
}
|
|
|
|
fn generated() -> String {
|
|
let config: Config = toml::from_str(CONFIG).expect("config should parse");
|
|
generate_for(&config, Path::new("/var/state/calcalist"), Direction::Both)
|
|
.expect("generation should succeed")
|
|
}
|
|
|
|
#[test]
|
|
fn declares_one_local_storage_for_every_pair() {
|
|
let text = generated();
|
|
assert_eq!(text.matches("storage local {").count(), 1);
|
|
assert!(text.contains("path \"/var/state/calcalist/vdir\""));
|
|
assert!(text.contains("status_path \"/var/state/calcalist/pimsync-status\""));
|
|
}
|
|
|
|
/// pimsync cannot authenticate to Google at all, so it must not be asked to.
|
|
#[test]
|
|
fn google_endpoints_are_left_out_entirely() {
|
|
let text = generated();
|
|
assert!(!text.contains("gcal"), "google endpoint leaked into {text}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_caldav_endpoint_becomes_a_bidirectional_pair() {
|
|
let text = generated();
|
|
assert!(text.contains("storage work_remote {"));
|
|
assert!(text.contains("type caldav"));
|
|
// The container is enumerated, and the calendar picked out by exact path.
|
|
assert!(text.contains("url \"https://dav.example.com/dav/calendars/user/me/\""));
|
|
assert!(text.contains("href_a \"/dav/calendars/user/me/work/\""));
|
|
assert!(text.contains("id_b work"));
|
|
assert!(
|
|
!block(&text, "pair work {").contains("one_way"),
|
|
"caldav pairs must stay bidirectional"
|
|
);
|
|
}
|
|
|
|
/// Secrets stay out of the portable config; pimsync fetches them the same way.
|
|
#[test]
|
|
fn secrets_are_fetched_by_command_not_embedded() {
|
|
let text = generated();
|
|
assert!(text.contains("password {"));
|
|
assert!(text.contains("shell pass show example/caldav"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_feed_becomes_a_one_way_read_only_pair() {
|
|
let text = generated();
|
|
assert!(text.contains("type webcal"));
|
|
assert!(text.contains("one_way"));
|
|
// pimsync 0.5.11 rejects `read_only` on a webcal storage despite
|
|
// documenting it for all storage types.
|
|
assert!(!block(&text, "storage holidays_remote {").contains("read_only"));
|
|
assert!(text.contains("collection_id holidays"));
|
|
}
|
|
|
|
/// Both guards against a failed pull being mirrored as a mass deletion.
|
|
#[test]
|
|
fn every_pair_carries_the_deletion_guards() {
|
|
let text = generated();
|
|
let pairs = text.matches("\npair ").count();
|
|
assert_eq!(pairs, 2);
|
|
assert_eq!(text.matches("on_empty skip").count(), pairs);
|
|
assert_eq!(text.matches("on_delete skip").count(), pairs);
|
|
}
|
|
|
|
/// A feed whose URL is itself a credential — Google's secret iCal address —
|
|
/// must be fetched by command, never written into either config file.
|
|
#[test]
|
|
fn a_secret_feed_url_is_fetched_by_command() {
|
|
let config: Config = toml::from_str(
|
|
r#"
|
|
version = 1
|
|
|
|
[[endpoint]]
|
|
id = "gcal-feed"
|
|
type = "webcal"
|
|
url_command = "secret-tool lookup service calcalist account gcal-ics"
|
|
"#,
|
|
)
|
|
.expect("config should parse");
|
|
let text = generate_for(&config, Path::new("/var/state/calcalist"), Direction::Both)
|
|
.expect("generate");
|
|
|
|
assert!(text.contains("url {"));
|
|
assert!(text.contains("shell secret-tool lookup service calcalist account gcal-ics"));
|
|
assert!(!text.contains("https://"), "no literal URL should appear");
|
|
}
|
|
|
|
#[test]
|
|
fn splits_a_calendar_url_into_container_and_path() {
|
|
assert_eq!(
|
|
split_collection_url("https://dav.example.com/dav/calendars/user/me/work/"),
|
|
Some((
|
|
"https://dav.example.com/dav/calendars/user/me/".into(),
|
|
"/dav/calendars/user/me/work/".into()
|
|
))
|
|
);
|
|
// A trailing slash is optional.
|
|
assert_eq!(
|
|
split_collection_url("https://dav.example.com/calendars/work"),
|
|
Some((
|
|
"https://dav.example.com/calendars/".into(),
|
|
"/calendars/work/".into()
|
|
))
|
|
);
|
|
// A single segment leaves the root as the container.
|
|
assert_eq!(
|
|
split_collection_url("https://dav.example.com/work/"),
|
|
Some(("https://dav.example.com/".into(), "/work/".into()))
|
|
);
|
|
}
|
|
|
|
/// A bare server root names no calendar, so it must be rejected rather than
|
|
/// guessed at.
|
|
#[test]
|
|
fn a_url_without_a_collection_is_refused() {
|
|
assert_eq!(split_collection_url("https://dav.example.com/"), None);
|
|
assert_eq!(split_collection_url("https://dav.example.com"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_the_version_line() {
|
|
assert_eq!(
|
|
Version::parse("pimsync 0.5.11"),
|
|
Some(Version {
|
|
major: 0,
|
|
minor: 5,
|
|
patch: 11
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tolerates_a_two_component_version() {
|
|
assert_eq!(
|
|
Version::parse("pimsync 1.2"),
|
|
Some(Version {
|
|
major: 1,
|
|
minor: 2,
|
|
patch: 0
|
|
})
|
|
);
|
|
}
|
|
|
|
/// pimsync built from a checkout rather than a tag reports `git describe`'s
|
|
/// suffix, which must not swallow the patch number.
|
|
#[test]
|
|
fn tolerates_a_describe_suffix() {
|
|
assert_eq!(
|
|
Version::parse("pimsync 0.6.0-1-ga08ee3c"),
|
|
Some(Version {
|
|
major: 0,
|
|
minor: 6,
|
|
patch: 0
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unparseable_output() {
|
|
assert_eq!(Version::parse("pimsync"), None);
|
|
assert_eq!(Version::parse("pimsync unreleased"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn the_read_series_are_accepted() {
|
|
let floor = Version {
|
|
major: 0,
|
|
minor: 5,
|
|
patch: 11,
|
|
};
|
|
let read = Version {
|
|
major: 0,
|
|
minor: 6,
|
|
patch: 0,
|
|
};
|
|
let unread = Version {
|
|
major: 0,
|
|
minor: 7,
|
|
patch: 0,
|
|
};
|
|
assert!(floor.is_supported());
|
|
assert!(read.is_supported());
|
|
assert!(!unread.is_supported());
|
|
}
|
|
|
|
#[test]
|
|
fn a_patch_older_than_the_floor_is_rejected() {
|
|
// discover's output format changed between 0.5.9 and 0.5.11; older
|
|
// patches within the otherwise-supported minor series are refused.
|
|
let too_old = Version {
|
|
major: 0,
|
|
minor: 5,
|
|
patch: 9,
|
|
};
|
|
assert!(!too_old.is_supported());
|
|
}
|
|
|
|
/// Established against a real server: everything arrives on stdout, the
|
|
/// headings are colour-coded even through a pipe, and both storages of the
|
|
/// pair are reported.
|
|
const DISCOVERY: &str = "\u{1b}[1m\u{1b}[32m==>\u{1b}[0m Discovering collections in local\u{2026}\n\
|
|
Found leftover-one\n\
|
|
Found leftover-two\n\
|
|
\u{1b}[1m\u{1b}[32m==>\u{1b}[0m Discovering collections in probe_remote\u{2026}\n\
|
|
Found /calendars/me/work/\n\
|
|
Found /calendars/me/Private Stuff/\n";
|
|
|
|
/// The trap: pimsync reports the scratch directory's contents too, and
|
|
/// offering those as the user's calendars would be nonsense.
|
|
#[test]
|
|
fn only_the_servers_collections_are_taken() {
|
|
let found = parse_discovery(DISCOVERY);
|
|
|
|
assert_eq!(
|
|
found.iter().map(|c| c.href.as_str()).collect::<Vec<_>>(),
|
|
vec!["/calendars/me/work/", "/calendars/me/Private Stuff/"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_id_is_taken_from_the_last_segment() {
|
|
let found = parse_discovery(DISCOVERY);
|
|
assert_eq!(found[0].id, "work");
|
|
// Spaces and capitals cannot appear in an id used as a routing marker.
|
|
assert_eq!(found[1].id, "private-stuff");
|
|
}
|
|
|
|
#[test]
|
|
fn a_server_offering_nothing_yields_nothing() {
|
|
let empty = "\u{1b}[1m==>\u{1b}[0m Discovering collections in probe_remote\u{2026}\n";
|
|
assert!(parse_discovery(empty).is_empty());
|
|
}
|
|
|
|
/// A failure is reported in place of the collections, so nothing is found.
|
|
#[test]
|
|
fn an_unreachable_server_yields_nothing() {
|
|
let failed = "\u{1b}[1m==>\u{1b}[0m Discovering collections in probe_remote\u{2026}\n\
|
|
input/output error: client error executing request: client error (Connect)\n";
|
|
assert!(parse_discovery(failed).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn colour_is_stripped_from_headings() {
|
|
assert_eq!(
|
|
strip_ansi("\u{1b}[1m\u{1b}[32m==>\u{1b}[0m Discovering"),
|
|
"==> Discovering"
|
|
);
|
|
}
|
|
|
|
/// The probe must never be mistaken for the real configuration, and must
|
|
/// name the storage the parser anchors on.
|
|
#[test]
|
|
fn the_probe_config_is_separate_and_parseable() {
|
|
let text = probe_config(
|
|
Path::new("/var/state/calcalist"),
|
|
Path::new("/var/state/calcalist/discover-vdir"),
|
|
"https://dav.example.com/calendars/me/",
|
|
"me@example.com",
|
|
Some("secret-tool lookup service dav"),
|
|
);
|
|
assert!(
|
|
text.contains(&format!("storage {PROBE_REMOTE} {{")),
|
|
"{text}"
|
|
);
|
|
assert!(text.contains("collections from a"), "{text}");
|
|
assert!(text.contains("discovery collections"), "{text}");
|
|
assert!(
|
|
text.contains("shell secret-tool lookup service dav"),
|
|
"{text}"
|
|
);
|
|
// Its status path is its own, so a probe cannot disturb a real sync.
|
|
assert!(text.contains("discover-status"), "{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_server_needing_no_password_gets_no_password_directive() {
|
|
let text = probe_config(
|
|
Path::new("/var/state/calcalist"),
|
|
Path::new("/tmp/scratch"),
|
|
"https://dav.example.com/calendars/me/",
|
|
"me",
|
|
None,
|
|
);
|
|
assert!(!text.contains("password"), "{text}");
|
|
}
|
|
}
|