Add Google OAuth, and let feed URLs come from a secret command

Google requires OAuth for calendar access; app passwords stopped working for
CalDAV, CardDAV and IMAP in March 2025, so there is no simpler path to offer.

- Authorisation code flow over a loopback redirect, which is what Google
  supports for desktop clients now the copy-paste flow is gone, with PKCE so an
  intercepted code is useless without the verifier. Only the refresh token is
  persisted, 0600, in the state directory.
- An expired grant is reported as itself: a consent screen still in Testing has
  its refresh tokens expired after 7 days, and "run calcalist google login" is
  more use than Google's bare invalid_grant.
- doctor reports whether each Google endpoint is still authorised, since an
  installation that worked last week can stop with nothing having changed here.

A webcal URL may now come from a command instead of the config. Google's secret
iCal address grants read access to a whole calendar to anyone holding it, so
writing it into a file described as portable and secret-free was a contradiction.

Fixed a serious defect in the first draft of this module: random_token used
fs::read on /dev/urandom, which reads to end of file. /dev/urandom has no end,
so it allocated until the machine ran out of memory — it took the editor down
with it. It now reads exactly 32 bytes, and a randomness failure is fatal rather
than falling back to the clock, since a guessable state or PKCE verifier defeats
the point of having them.

Verified end to end against a live Posteo CalDAV calendar: pimsync validated the
generated config against the real server, 58 events from a public feed were
mirrored and pushed, and a second run was a no-op.

97 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-09-10 12:40:47 +03:00
parent 675d1a9397
commit 9f14c1f651
10 changed files with 1524 additions and 18 deletions

4
.gitignore vendored
View file

@ -1,3 +1,7 @@
/target /target
/.devbox /.devbox
/result /result
# OAuth client files and any other credentials must never be committed.
client_secret_*.json
*.credentials.json

755
Cargo.lock generated
View file

@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "anstream" name = "anstream"
version = "1.0.0" version = "1.0.0"
@ -38,7 +44,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [ dependencies = [
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -49,7 +55,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"once_cell_polyfill", "once_cell_polyfill",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -58,6 +64,12 @@ version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.2" version = "2.13.2"
@ -77,6 +89,21 @@ dependencies = [
"cpufeatures", "cpufeatures",
] ]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]] [[package]]
name = "calcalist" name = "calcalist"
version = "0.1.0" version = "0.1.0"
@ -85,9 +112,11 @@ dependencies = [
"clap", "clap",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"tempfile", "tempfile",
"thiserror", "thiserror",
"toml", "toml",
"ureq",
] ]
[[package]] [[package]]
@ -137,7 +166,7 @@ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 3.0.5",
] ]
[[package]] [[package]]
@ -152,12 +181,47 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]] [[package]]
name = "constant_time_eq" name = "constant_time_eq"
version = "0.4.2" version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]]
name = "cookie"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.3.1" version = "0.3.1"
@ -167,6 +231,61 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "crc32fast"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
dependencies = [
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
]
[[package]]
name = "displaydoc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@ -180,7 +299,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -195,6 +314,37 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide",
"zlib-rs",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.4.3" version = "0.4.3"
@ -218,6 +368,135 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "icu_collections"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
[[package]]
name = "icu_properties"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
dependencies = [
"displaydoc",
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.2" version = "2.14.2"
@ -252,12 +531,46 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.3" version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@ -270,6 +583,27 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "potential_utf"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.107" version = "1.0.107"
@ -294,6 +628,20 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.4" version = "1.1.4"
@ -304,7 +652,42 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
"windows-sys", "windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
] ]
[[package]] [[package]]
@ -334,7 +717,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 3.0.5",
] ]
[[package]] [[package]]
@ -359,18 +742,64 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "smallvec"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]] [[package]]
name = "syn" name = "syn"
version = "3.0.5" version = "3.0.5"
@ -382,6 +811,17 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.27.0" version = "3.27.0"
@ -389,10 +829,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -412,7 +852,47 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 3.0.5",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"zerovec",
] ]
[[package]] [[package]]
@ -454,24 +934,122 @@ version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b"
dependencies = [
"base64",
"cookie_store",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabc3e92916c89c95b20eef7b06b00b066bc217ef9ea3a4ac9bf1a7e35261e10"
dependencies = [
"base64",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "utf8parse" name = "utf8parse"
version = "0.2.2" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@ -481,12 +1059,171 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "1.0.4" version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
[[package]]
name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]]
name = "zerotrie"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "zlib-rs"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.23" version = "1.0.23"

View file

@ -11,8 +11,10 @@ blake3 = "1.8"
clap = { version = "4.6", features = ["derive"] } clap = { version = "4.6", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
sha2 = "0.11"
thiserror = "2.0" thiserror = "2.0"
toml = "1.1" toml = "1.1"
ureq = { version = "3.4", default-features = false, features = ["json", "rustls", "gzip"] }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"

View file

@ -30,7 +30,8 @@ Core modules:
drive one-shot `pimsync sync` bracketing the reconcile step drive one-shot `pimsync sync` bracketing the reconcile step
- [x] `doctor` asks `pimsync check` to validate the generated config, since pimsync's - [x] `doctor` asks `pimsync check` to validate the generated config, since pimsync's
parser does not always match its documentation parser does not always match its documentation
- [ ] `google/auth.rs`, `google/api.rs`, `google/convert.rs` - [x] `google/auth.rs` — OAuth loopback flow with PKCE, refresh, keyring-sourced secrets
- [ ] `google/api.rs`, `google/convert.rs`
- [x] Reintroduce `SchedulingSuppression` in `config.rs` (removed in M0 as dead code) - [x] Reintroduce `SchedulingSuppression` in `config.rs` (removed in M0 as dead code)
Safety-critical behaviour: Safety-critical behaviour:

View file

@ -45,7 +45,12 @@ pub enum EndpointKind {
client_secret_command: Option<String>, client_secret_command: Option<String>,
}, },
Webcal { Webcal {
url: String, /// The feed URL, when it is not sensitive.
url: Option<String>,
/// A command printing the feed URL, for feeds whose address is itself a
/// credential — Google's "secret address in iCal format" grants read
/// access to the whole calendar to anyone holding it.
url_command: Option<String>,
}, },
} }
@ -183,6 +188,26 @@ pub enum Problem {
SinkNotWritable { aggregate: String, endpoint: String }, SinkNotWritable { aggregate: String, endpoint: String },
#[error("aggregate `{aggregate}` has max_delete_fraction {value}, which is outside 0.0..=1.0")] #[error("aggregate `{aggregate}` has max_delete_fraction {value}, which is outside 0.0..=1.0")]
DeleteFractionOutOfRange { aggregate: String, value: f64 }, DeleteFractionOutOfRange { aggregate: String, value: f64 },
#[error("endpoint `{endpoint}` has neither `url` nor `url_command`")]
NoUrlSource { endpoint: String },
#[error("endpoint `{endpoint}` has both `url` and `url_command`; use one or the other")]
AmbiguousUrlSource { endpoint: String },
}
/// A webcal feed must name exactly one source for its URL.
fn check_endpoint(endpoint: &Endpoint, problems: &mut Vec<Problem>) {
let EndpointKind::Webcal { url, url_command } = &endpoint.kind else {
return;
};
match (url, url_command) {
(None, None) => problems.push(Problem::NoUrlSource {
endpoint: endpoint.id.clone(),
}),
(Some(_), Some(_)) => problems.push(Problem::AmbiguousUrlSource {
endpoint: endpoint.id.clone(),
}),
_ => {}
}
} }
impl Config { impl Config {
@ -217,6 +242,9 @@ impl Config {
pub fn validate(&self) -> Result<(), ConfigError> { pub fn validate(&self) -> Result<(), ConfigError> {
let mut problems = Vec::new(); let mut problems = Vec::new();
self.check_unique_ids(&mut problems); self.check_unique_ids(&mut problems);
for endpoint in &self.endpoints {
check_endpoint(endpoint, &mut problems);
}
for aggregate in &self.aggregates { for aggregate in &self.aggregates {
self.check_aggregate(aggregate, &mut problems); self.check_aggregate(aggregate, &mut problems);
} }
@ -493,6 +521,28 @@ sources = ["missing"]
); );
} }
#[test]
fn a_feed_must_name_exactly_one_url_source() {
let neither: Config =
toml::from_str("version = 1\n\n[[endpoint]]\nid = \"f\"\ntype = \"webcal\"\n")
.expect("parse");
assert!(matches!(
neither.validate(),
Err(ConfigError::Invalid(problems))
if problems == vec![Problem::NoUrlSource { endpoint: "f".into() }]
));
let both: Config = toml::from_str(
"version = 1\n\n[[endpoint]]\nid = \"f\"\ntype = \"webcal\"\nurl = \"https://e/f.ics\"\nurl_command = \"cmd\"\n",
)
.expect("parse");
assert!(matches!(
both.validate(),
Err(ConfigError::Invalid(problems))
if problems == vec![Problem::AmbiguousUrlSource { endpoint: "f".into() }]
));
}
#[test] #[test]
fn rejects_an_out_of_range_delete_fraction() { fn rejects_an_out_of_range_delete_fraction() {
let found = problems( let found = problems(

View file

@ -4,7 +4,8 @@ use std::fmt;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use crate::config::{Config, ConfigError}; use crate::config::{Config, ConfigError, EndpointKind};
use crate::google::auth::{self, AuthError};
use crate::paths; use crate::paths;
use crate::pimsync::{self, PimsyncError}; use crate::pimsync::{self, PimsyncError};
@ -55,6 +56,7 @@ pub fn run(config_path: Option<&Path>) -> Vec<Check> {
let mut checks = vec![check_pimsync(), check_state_dir()]; let mut checks = vec![check_pimsync(), check_state_dir()];
if let Ok((config, _)) = &loaded { if let Ok((config, _)) = &loaded {
checks.push(check_generated_config(config)); checks.push(check_generated_config(config));
checks.extend(check_google_authorisation(config));
} }
checks.push(check_config(loaded)); checks.push(check_config(loaded));
checks checks
@ -124,6 +126,40 @@ fn check_state_dir() -> Check {
} }
} }
/// Reports whether each Google endpoint still has a usable authorisation.
///
/// Worth a check of its own because an unpublished consent screen has its
/// refresh tokens expired after seven days, so an installation that worked last
/// week can stop without anything having changed locally.
fn check_google_authorisation(config: &Config) -> Vec<Check> {
let Ok(state_dir) = paths::state_dir() else {
return Vec::new();
};
config
.endpoints
.iter()
.filter(|endpoint| matches!(endpoint.kind, EndpointKind::Google { .. }))
.map(|endpoint| {
let outcome = match auth::credentials_for(config, &endpoint.id) {
Err(error) => Outcome::Fail(error.to_string()),
Ok(credentials) => {
match auth::access_token(&state_dir, &endpoint.id, &credentials) {
Ok(_) => Outcome::Ok(format!("`{}` is authorised", endpoint.id)),
Err(error @ (AuthError::NotAuthorised(_) | AuthError::Expired { .. })) => {
Outcome::Warn(error.to_string())
}
Err(error) => Outcome::Warn(format!("could not be checked: {error}")),
}
}
};
Check {
name: "google auth",
outcome,
}
})
.collect()
}
fn check_config(loaded: Result<(Config, std::path::PathBuf), ConfigError>) -> Check { fn check_config(loaded: Result<(Config, std::path::PathBuf), ConfigError>) -> Check {
let outcome = match loaded { let outcome = match loaded {
Ok((config, path)) => Outcome::Ok(format!( Ok((config, path)) => Outcome::Ok(format!(

605
src/google/auth.rs Normal file
View file

@ -0,0 +1,605 @@
//! OAuth 2.0 for an installed application.
//!
//! Google's Calendar API requires OAuth; there is no password to hand over. The
//! authorisation code is collected over a loopback redirect, which is the flow
//! Google supports for desktop clients since the copy-paste flow was withdrawn.
//! PKCE is used as well, so a code intercepted on the loopback interface is
//! useless without the verifier.
//!
//! Only the refresh token is persisted, in the state directory with owner-only
//! permissions. It never enters the portable configuration.
use std::fs;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
const AUTH_ENDPOINT: &str = "https://accounts.google.com/o/oauth2/v2/auth";
const TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token";
/// Read and write access to calendars. Google classes this as sensitive, which
/// is why the consent screen needs publishing before refresh tokens stop
/// expiring after seven days.
pub const SCOPE: &str = "https://www.googleapis.com/auth/calendar";
/// Refresh a little early, so a token cannot expire mid-cycle.
const EXPIRY_MARGIN: Duration = Duration::from_secs(60);
#[derive(Debug, Error)]
pub enum AuthError {
#[error("could not run the secret command `{command}`: {source}")]
SecretCommand {
command: String,
#[source]
source: std::io::Error,
},
#[error("the secret command `{command}` failed: {message}")]
SecretFailed { command: String, message: String },
#[error("endpoint `{0}` has no client_secret_command, which Google's OAuth flow requires")]
NoClientSecret(String),
#[error("no endpoint named `{0}` in the configuration")]
UnknownEndpoint(String),
#[error("endpoint `{endpoint}` is a {kind} endpoint, not a Google one")]
NotGoogle { endpoint: String, kind: String },
#[error("the client_secret_command for `{0}` produced nothing")]
EmptyClientSecret(String),
#[error("could not listen on a loopback port: {0}")]
Listen(#[source] std::io::Error),
#[error("could not read randomness from /dev/urandom: {0}")]
Randomness(#[source] std::io::Error),
#[error("the browser did not complete authorisation: {0}")]
Redirect(String),
#[error("authorisation was refused: {0}")]
Denied(String),
#[error("the redirect did not match the request; authorisation was abandoned")]
StateMismatch,
#[error("token request failed: {0}")]
Token(String),
#[error(
"the stored authorisation for `{endpoint}` is no longer accepted by Google. \
An app whose consent screen is still in Testing has its refresh tokens expired \
after 7 days. Run `calcalist google login {endpoint}` to authorise again."
)]
Expired { endpoint: String },
#[error(
"Google returned no refresh token; re-run after removing calcalist from your account's third-party access, so consent is asked for again"
)]
NoRefreshToken,
#[error("endpoint `{0}` has not been authorised; run `calcalist google login {0}`")]
NotAuthorised(String),
#[error("could not access {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("could not parse the stored token at {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: serde_json::Error,
},
}
/// What is persisted between runs. The access token is cached only to avoid a
/// refresh on every command; the refresh token is the durable credential.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredToken {
pub refresh_token: String,
#[serde(default)]
pub access_token: Option<String>,
/// Unix seconds at which `access_token` stops being usable.
#[serde(default)]
pub expires_at: u64,
}
impl StoredToken {
fn usable_access_token(&self) -> Option<&str> {
let now = unix_now() + EXPIRY_MARGIN.as_secs();
self.access_token
.as_deref()
.filter(|_| self.expires_at > now)
}
}
/// Where an endpoint's refresh token lives.
pub fn token_path(state_dir: &Path, endpoint_id: &str) -> PathBuf {
state_dir.join("google").join(format!("{endpoint_id}.json"))
}
/// Runs an external command and returns its first line of output.
///
/// Secrets are fetched this way rather than stored, so the configuration file
/// stays portable and free of credentials.
pub fn run_secret_command(command: &str) -> Result<String, AuthError> {
let output = Command::new("sh")
.arg("-c")
.arg(command)
.output()
.map_err(|source| AuthError::SecretCommand {
command: command.to_string(),
source,
})?;
if !output.status.success() {
return Err(AuthError::SecretFailed {
command: command.to_string(),
message: String::from_utf8_lossy(&output.stderr).trim().to_string(),
});
}
let text = String::from_utf8_lossy(&output.stdout);
Ok(text.lines().next().unwrap_or_default().trim().to_string())
}
/// Everything needed to talk to Google for one endpoint.
#[derive(Debug, Clone)]
pub struct Credentials {
pub client_id: String,
pub client_secret: String,
}
/// Resolves an endpoint's OAuth client, running its secret command to fetch the
/// client secret. The secret is never written to the configuration.
pub fn credentials_for(
config: &crate::config::Config,
endpoint_id: &str,
) -> Result<Credentials, AuthError> {
let endpoint = config
.endpoint(endpoint_id)
.ok_or_else(|| AuthError::UnknownEndpoint(endpoint_id.to_string()))?;
let crate::config::EndpointKind::Google {
client_id,
client_secret_command,
..
} = &endpoint.kind
else {
return Err(AuthError::NotGoogle {
endpoint: endpoint_id.to_string(),
kind: endpoint.kind.kind_name().to_string(),
});
};
let command = client_secret_command
.as_deref()
.ok_or_else(|| AuthError::NoClientSecret(endpoint_id.to_string()))?;
let client_secret = run_secret_command(command)?;
if client_secret.is_empty() {
return Err(AuthError::EmptyClientSecret(endpoint_id.to_string()));
}
Ok(Credentials {
client_id: client_id.clone(),
client_secret,
})
}
/// Obtains an access token, refreshing the stored one when it has expired.
pub fn access_token(
state_dir: &Path,
endpoint_id: &str,
credentials: &Credentials,
) -> Result<String, AuthError> {
let path = token_path(state_dir, endpoint_id);
let mut stored =
load_token(&path)?.ok_or_else(|| AuthError::NotAuthorised(endpoint_id.to_string()))?;
if let Some(token) = stored.usable_access_token() {
return Ok(token.to_string());
}
let refreshed = post_token(&[
("client_id", &credentials.client_id),
("client_secret", &credentials.client_secret),
("refresh_token", &stored.refresh_token),
("grant_type", "refresh_token"),
])
.map_err(|error| match error {
AuthError::Token(message) if message.contains("invalid_grant") => AuthError::Expired {
endpoint: endpoint_id.to_string(),
},
other => other,
})?;
stored.access_token = Some(refreshed.access_token.clone());
stored.expires_at = unix_now() + refreshed.expires_in;
save_token(&path, &stored)?;
Ok(refreshed.access_token)
}
/// Walks the user through authorisation and stores the resulting refresh token.
pub fn login(
state_dir: &Path,
endpoint_id: &str,
credentials: &Credentials,
) -> Result<(), AuthError> {
let listener =
TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).map_err(AuthError::Listen)?;
let port = listener.local_addr().map_err(AuthError::Listen)?.port();
let redirect_uri = format!("http://127.0.0.1:{port}");
let state = random_token()?;
let verifier = random_token()?;
let challenge = pkce_challenge(&verifier);
let url = format!(
"{AUTH_ENDPOINT}?client_id={}&redirect_uri={}&response_type=code&scope={}\
&access_type=offline&prompt=consent&state={}&code_challenge={}&code_challenge_method=S256",
encode(&credentials.client_id),
encode(&redirect_uri),
encode(SCOPE),
encode(&state),
encode(&challenge),
);
println!("Open this URL to authorise calcalist:\n\n{url}\n");
open_in_browser(&url);
println!("Waiting for the redirect on {redirect_uri} ...");
let code = wait_for_code(&listener, &state)?;
let issued = post_token(&[
("code", &code),
("client_id", &credentials.client_id),
("client_secret", &credentials.client_secret),
("redirect_uri", &redirect_uri),
("grant_type", "authorization_code"),
("code_verifier", &verifier),
])?;
let refresh_token = issued.refresh_token.ok_or(AuthError::NoRefreshToken)?;
let path = token_path(state_dir, endpoint_id);
save_token(
&path,
&StoredToken {
refresh_token,
access_token: Some(issued.access_token),
expires_at: unix_now() + issued.expires_in,
},
)?;
println!("Authorised. Refresh token stored at {}", path.display());
Ok(())
}
/// Accepts one loopback request and extracts the authorisation code from it.
fn wait_for_code(listener: &TcpListener, expected_state: &str) -> Result<String, AuthError> {
let (mut stream, _) = listener.accept().map_err(AuthError::Listen)?;
let target = read_request_target(&stream)?;
let query = target.split_once('?').map(|(_, query)| query).unwrap_or("");
let params = parse_query(query);
let outcome = match (params.get("code"), params.get("error"), params.get("state")) {
(_, Some(error), _) => Err(AuthError::Denied(error.clone())),
(Some(_), _, state) if state.map(String::as_str) != Some(expected_state) => {
Err(AuthError::StateMismatch)
}
(Some(code), _, _) => Ok(code.clone()),
(None, None, _) => Err(AuthError::Redirect(
"the redirect carried neither a code nor an error".into(),
)),
};
respond(&mut stream, outcome.is_ok());
outcome
}
fn read_request_target(stream: &TcpStream) -> Result<String, AuthError> {
let mut line = String::new();
BufReader::new(stream)
.read_line(&mut line)
.map_err(|error| AuthError::Redirect(error.to_string()))?;
line.split_whitespace()
.nth(1)
.map(str::to_string)
.ok_or_else(|| AuthError::Redirect(format!("could not parse request line {line:?}")))
}
fn respond(stream: &mut TcpStream, success: bool) {
let body = if success {
"<h1>calcalist is authorised</h1><p>You can close this tab.</p>"
} else {
"<h1>Authorisation failed</h1><p>Check the terminal for details.</p>"
};
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
// The browser tab is a courtesy; failing to write to it changes nothing.
let _ = stream.write_all(response.as_bytes());
}
#[derive(Debug, Deserialize)]
struct IssuedToken {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default = "default_expiry")]
expires_in: u64,
}
fn default_expiry() -> u64 {
3600
}
fn post_token(form: &[(&str, &str)]) -> Result<IssuedToken, AuthError> {
let body = form
.iter()
.map(|(key, value)| format!("{}={}", encode(key), encode(value)))
.collect::<Vec<_>>()
.join("&");
let agent: ureq::Agent = ureq::Agent::config_builder()
.http_status_as_error(false)
.build()
.into();
let mut response = agent
.post(TOKEN_ENDPOINT)
.content_type("application/x-www-form-urlencoded")
.send(&body)
.map_err(|error| AuthError::Token(error.to_string()))?;
let status = response.status();
let text = response
.body_mut()
.read_to_string()
.map_err(|error| AuthError::Token(error.to_string()))?;
if !status.is_success() {
return Err(AuthError::Token(format!("HTTP {status}: {text}")));
}
serde_json::from_str(&text).map_err(|error| AuthError::Token(error.to_string()))
}
fn load_token(path: &Path) -> Result<Option<StoredToken>, AuthError> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => {
return Err(AuthError::Io {
path: path.to_path_buf(),
source,
});
}
};
serde_json::from_str(&text)
.map(Some)
.map_err(|source| AuthError::Parse {
path: path.to_path_buf(),
source,
})
}
/// Writes the token readable only by its owner. It is a durable credential, and
/// the state directory may not be private on every system.
fn save_token(path: &Path, token: &StoredToken) -> Result<(), AuthError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|source| AuthError::Io {
path: parent.to_path_buf(),
source,
})?;
let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
}
let text = serde_json::to_string_pretty(token).map_err(|source| AuthError::Parse {
path: path.to_path_buf(),
source,
})?;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|source| AuthError::Io {
path: path.to_path_buf(),
source,
})?;
file.write_all(text.as_bytes())
.map_err(|source| AuthError::Io {
path: path.to_path_buf(),
source,
})
}
fn open_in_browser(url: &str) {
// Best effort: on a headless machine the printed URL is the real interface.
let _ = Command::new("xdg-open").arg(url).status();
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// 32 bytes of kernel randomness, base64url encoded.
///
/// Reads exactly 32 bytes. `/dev/urandom` is an endless stream, so anything that
/// reads it to completion — `fs::read`, for one — allocates until the machine
/// runs out of memory.
///
/// A failure here is fatal rather than papered over: both callers need
/// unpredictable values, and a guessable `state` or PKCE verifier would defeat
/// the protection they exist to provide.
fn random_token() -> Result<String, AuthError> {
let mut bytes = [0u8; 32];
fs::File::open("/dev/urandom")
.and_then(|mut file| file.read_exact(&mut bytes))
.map_err(AuthError::Randomness)?;
Ok(base64url(&bytes))
}
fn pkce_challenge(verifier: &str) -> String {
base64url(&Sha256::digest(verifier.as_bytes()))
}
/// Base64 with the URL alphabet and no padding, as RFC 7636 requires.
fn base64url(bytes: &[u8]) -> String {
const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let bits = chunk.iter().enumerate().fold(0u32, |acc, (index, byte)| {
acc | (u32::from(*byte) << (16 - 8 * index))
});
// Three bytes make four characters; a short chunk makes proportionally fewer.
for index in 0..=chunk.len() {
let shift = 18 - 6 * index;
out.push(ALPHABET[((bits >> shift) & 0b11_1111) as usize] as char);
}
}
out
}
fn encode(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
fn parse_query(query: &str) -> std::collections::HashMap<String, String> {
query
.split('&')
.filter_map(|pair| pair.split_once('='))
.map(|(key, value)| (decode(key), decode(value)))
.collect()
}
fn decode(value: &str) -> String {
let mut out = Vec::with_capacity(value.len());
let bytes = value.as_bytes();
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'%' if index + 2 < bytes.len() => {
let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).unwrap_or("");
match u8::from_str_radix(hex, 16) {
Ok(byte) => {
out.push(byte);
index += 3;
}
Err(_) => {
out.push(bytes[index]);
index += 1;
}
}
}
b'+' => {
out.push(b' ');
index += 1;
}
byte => {
out.push(byte);
index += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
/// RFC 4648 section 10 test vectors, minus the padding RFC 7636 forbids.
#[test]
fn base64url_matches_the_specification() {
assert_eq!(base64url(b""), "");
assert_eq!(base64url(b"f"), "Zg");
assert_eq!(base64url(b"fo"), "Zm8");
assert_eq!(base64url(b"foo"), "Zm9v");
assert_eq!(base64url(b"foob"), "Zm9vYg");
assert_eq!(base64url(b"fooba"), "Zm9vYmE");
assert_eq!(base64url(b"foobar"), "Zm9vYmFy");
}
/// The URL alphabet must use - and _ rather than + and /, or Google rejects
/// the challenge.
#[test]
fn base64url_uses_the_url_alphabet() {
let encoded = base64url(&[0xfb, 0xff, 0xfe]);
assert!(!encoded.contains('+'), "{encoded}");
assert!(!encoded.contains('/'), "{encoded}");
assert!(!encoded.contains('='), "{encoded}");
}
/// RFC 7636 appendix B. Getting this wrong fails authorisation with an
/// unhelpful error, so it is pinned to the published vector.
#[test]
fn pkce_challenge_matches_rfc_7636() {
assert_eq!(
pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
);
}
/// Regression: this previously read /dev/urandom to EOF, which allocates
/// without bound and takes the machine down with it.
#[test]
fn a_verifier_is_long_enough_and_url_safe() {
let verifier = random_token().expect("randomness should be available");
assert!(verifier.len() >= 43, "too short for RFC 7636: {verifier}");
assert!(
verifier
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')),
"{verifier}"
);
assert_ne!(
verifier,
random_token().expect("randomness should be available"),
"tokens must not repeat"
);
}
#[test]
fn percent_encoding_round_trips() {
for value in ["plain", "with space", "a/b?c=d&e", "sömething", "100%"] {
assert_eq!(decode(&encode(value)), value, "{value}");
}
}
#[test]
fn reserved_characters_are_escaped() {
assert_eq!(encode("a/b?c=d&e"), "a%2Fb%3Fc%3Dd%26e");
assert_eq!(encode("a-b_c.d~e"), "a-b_c.d~e");
}
#[test]
fn a_redirect_query_is_parsed() {
let params = parse_query("code=4%2F0Ab&state=xyz&scope=https%3A%2F%2Fexample");
assert_eq!(params.get("code").map(String::as_str), Some("4/0Ab"));
assert_eq!(params.get("state").map(String::as_str), Some("xyz"));
assert_eq!(
params.get("scope").map(String::as_str),
Some("https://example")
);
}
#[test]
fn a_cached_access_token_is_reused_only_while_valid() {
let fresh = StoredToken {
refresh_token: "r".into(),
access_token: Some("a".into()),
expires_at: unix_now() + 3600,
};
assert_eq!(fresh.usable_access_token(), Some("a"));
let expired = StoredToken {
expires_at: unix_now(),
..fresh.clone()
};
assert_eq!(expired.usable_access_token(), None);
// Within the margin it is treated as expired, so it cannot lapse mid-cycle.
let nearly = StoredToken {
expires_at: unix_now() + 5,
..fresh
};
assert_eq!(nearly.usable_access_token(), None);
}
}

8
src/google/mod.rs Normal file
View file

@ -0,0 +1,8 @@
//! The Google Calendar leg.
//!
//! pimsync cannot reach Google at all — it has no REST storage, and its CalDAV
//! storage speaks only HTTP Basic auth, which Google's endpoint has rejected
//! since March 2025. So calcalist keeps a Google calendar in step with its local
//! vdir itself, doing for Google what pimsync does for CalDAV.
pub mod auth;

View file

@ -3,6 +3,7 @@
mod cli; mod cli;
mod config; mod config;
mod doctor; mod doctor;
mod google;
mod ical; mod ical;
mod mirror; mod mirror;
mod paths; mod paths;
@ -17,8 +18,9 @@ use std::process::ExitCode;
use clap::Parser; use clap::Parser;
use crate::cli::{Cli, Command}; use crate::cli::{Cli, Command, GoogleCommand};
use crate::config::Config; use crate::config::Config;
use crate::google::auth;
use crate::sync::Report; use crate::sync::Report;
fn main() -> ExitCode { fn main() -> ExitCode {
@ -31,6 +33,13 @@ fn main() -> ExitCode {
command: Command::Sync { dry_run, force }, command: Command::Sync { dry_run, force },
.. ..
} => run_sync(cli, *dry_run, *force), } => run_sync(cli, *dry_run, *force),
cli @ Cli {
command:
Command::Google {
command: GoogleCommand::Login { endpoint },
},
..
} => run_google_login(cli, endpoint),
Cli { command, .. } => unimplemented(command), Cli { command, .. } => unimplemented(command),
} }
} }
@ -65,6 +74,26 @@ fn run_sync(cli: &Cli, dry_run: bool, force: bool) -> ExitCode {
} }
} }
/// Authorises one Google endpoint, storing a refresh token for later cycles.
fn run_google_login(cli: &Cli, endpoint_id: &str) -> ExitCode {
let (config, _) = match Config::load(cli.config.as_deref()) {
Ok(loaded) => loaded,
Err(error) => return fail(&error),
};
let state_dir = match paths::state_dir() {
Ok(dir) => dir,
Err(error) => return fail(&error),
};
let credentials = match auth::credentials_for(&config, endpoint_id) {
Ok(credentials) => credentials,
Err(error) => return fail(&error),
};
match auth::login(&state_dir, endpoint_id, &credentials) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => fail(&error),
}
}
fn print_report(report: &Report) { fn print_report(report: &Report) {
if report.aggregates.is_empty() { if report.aggregates.is_empty() {
println!("no aggregates configured"); println!("no aggregates configured");

View file

@ -130,7 +130,13 @@ pub fn generate(config: &Config, state_dir: &Path) -> Result<String, PimsyncErro
for endpoint in &config.endpoints { for endpoint in &config.endpoints {
match &endpoint.kind { match &endpoint.kind {
EndpointKind::Caldav { .. } => out.push_str(&caldav_pair(endpoint)?), EndpointKind::Caldav { .. } => out.push_str(&caldav_pair(endpoint)?),
EndpointKind::Webcal { url } => out.push_str(&webcal_pair(endpoint, url)), 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. // Handled by calcalist's own Google module.
EndpointKind::Google { .. } => {} EndpointKind::Google { .. } => {}
} }
@ -178,18 +184,24 @@ fn caldav_pair(endpoint: &Endpoint) -> Result<String, PimsyncError> {
Ok(block) Ok(block)
} }
fn webcal_pair(endpoint: &Endpoint, url: &str) -> String { fn webcal_pair(endpoint: &Endpoint, url: Option<&str>, url_command: Option<&str>) -> String {
let id = &endpoint.id; 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 // 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 // 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, // not parse file". It is redundant regardless — webcal is read-only by type,
// and `one_way` already fixes the direction. // and `one_way` already fixes the direction.
format!( format!(
"\nstorage {id}_remote {{\n\ttype webcal\n\turl {}\n\tcollection_id {id}\n}}\n\ "\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\ \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\ \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", \tone_way\n\ton_empty skip\n\ton_delete skip\n}}\n"
quote(url)
) )
} }
@ -377,6 +389,28 @@ url = "https://example.org/holidays.ics"
assert_eq!(text.matches("on_delete 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(&config, Path::new("/var/state/calcalist")).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] #[test]
fn splits_a_calendar_url_into_container_and_path() { fn splits_a_calendar_url_into_container_and_path() {
assert_eq!( assert_eq!(