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:
parent
675d1a9397
commit
9f14c1f651
10 changed files with 1524 additions and 18 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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
755
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
3
TODO.md
3
TODO.md
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
605
src/google/auth.rs
Normal 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
8
src/google/mod.rs
Normal 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;
|
||||||
31
src/main.rs
31
src/main.rs
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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!(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue