Finish M2: run lock, systemd units, discover; drop the web UI
Designing the configuration UI in full made the case against building it. Its audience would be people who find TOML hard, but with bring-your-own OAuth client settled, every user must first create a Google Cloud project, configure a consent screen and put a secret in a keyring — a far higher bar than editing thirty lines of config. Anyone who clears it can edit the file; anyone who cannot never reaches the file. Against that stood three dependencies, five modules, an auth.rs refactor and a security surface guarding something that reads the config and touches the keyring. `doctor` and `status` had already absorbed most of what it was for. The reasoning is recorded in TODO.md and SPECS.md rather than left as an apparent oversight. The run lock is not a UI feature and closes a gap that already existed: nothing stopped a timer firing into a hand-run cycle, and two cycles interleaving writes over the same vdirs is what the design otherwise avoids. flock is used rather than a pid file because the kernel releases it however the process ends, so a crash cannot leave a lock to clear by hand — which also means a lock we failed to take is held by a live process, so the pid in it is worth reporting. The one idea worth keeping from the UI design was collection discovery, which needed no web layer. `calcalist discover` prints a ready-to-paste endpoint block per calendar a server offers, removing the most error-prone field in the config. pimsync's discovery output is undocumented, so the format was established against a real server first. Two things it teaches: everything arrives on stdout including failures, and a pair has two storages, so pimsync reports the scratch vdir's contents too — parsing anchors on the heading naming the server, or a probe directory's leftovers would be offered as the user's calendars. Verified against Posteo as well as Radicale: all four calendars found, the first matching the URL already configured. Also fixes a real defect in the test harness rather than its symptom. Ports were chosen by binding one and letting go, so two tests could pick the same number — and the loser's readiness check then succeeded against the winner's server, silently sharing it. Startup now confirms the child we spawned is the one alive, retries on another port if not, and waits for a real HTTP response rather than an open socket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
47ad8b4c47
commit
ef6482ed62
13 changed files with 808 additions and 92 deletions
255
src/pimsync.rs
255
src/pimsync.rs
|
|
@ -276,6 +276,172 @@ pub fn write_config_for(
|
|||
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(|error| {
|
||||
if error.kind() == io::ErrorKind::NotFound {
|
||||
PimsyncError::NotFound
|
||||
} else {
|
||||
PimsyncError::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
|
||||
|
|
@ -518,4 +684,93 @@ url_command = "secret-tool lookup service calcalist account gcal-ics"
|
|||
assert!(supported.is_supported());
|
||||
assert!(!next_series.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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue