From 78c27a7067e4a350f6374fb4a6f6dc5aee4f2d0f Mon Sep 17 00:00:00 2001 From: dumko2001 Date: Sat, 14 Mar 2026 17:33:34 +0530 Subject: [PATCH 1/7] chore: save progress before multi-account --- Cargo.lock | 831 +++++++++-------------------------------------- Cargo.toml | 3 +- src/setup_tui.rs | 1 + 3 files changed, 149 insertions(+), 686 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0d55ea8d..b75d6439 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,16 +125,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", -] - -[[package]] -name = "atomic" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" -dependencies = [ - "bytemuck", + "syn", ] [[package]] @@ -155,27 +146,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.11.0" @@ -197,12 +167,6 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" - [[package]] name = "byteorder" version = "1.5.0" @@ -215,6 +179,12 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "castaway" version = "0.2.4" @@ -266,7 +236,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" dependencies = [ "chrono", - "phf 0.12.1", + "phf", ] [[package]] @@ -310,7 +280,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -327,9 +297,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "compact_str" -version = "0.9.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" dependencies = [ "castaway", "cfg-if", @@ -398,19 +368,35 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.0", + "bitflags", "crossterm_winapi", "derive_more", "document-features", "mio", "parking_lot", - "rustix", + "rustix 1.1.4", "signal-hook", "signal-hook-mio", "winapi", @@ -436,16 +422,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "csscolorparser" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" -dependencies = [ - "lab", - "phf 0.11.3", -] - [[package]] name = "ctr" version = "0.9.2" @@ -457,87 +433,47 @@ dependencies = [ [[package]] name = "darling" -version = "0.20.11" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core", + "darling_macro", ] [[package]] name = "darling_core" -version = "0.20.11" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.117", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", + "syn", ] [[package]] name = "darling_macro" -version = "0.20.11" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ - "darling_core 0.20.11", + "darling_core", "quote", - "syn 2.0.117", + "syn", ] -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core 0.23.0", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "deltae" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" - [[package]] name = "deranged" -version = "0.5.8" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", - "serde_core", + "serde", ] [[package]] @@ -555,10 +491,10 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling 0.20.11", + "darling", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -568,7 +504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.117", + "syn", ] [[package]] @@ -590,7 +526,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn", ] [[package]] @@ -632,7 +568,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -672,60 +608,18 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "euclid" -version = "0.22.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" -dependencies = [ - "num-traits", -] - -[[package]] -name = "fancy-regex" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" -dependencies = [ - "bit-set", - "regex", -] - [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "finl_unicode" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" - -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - [[package]] name = "fnv" version = "1.0.7" @@ -738,12 +632,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -793,7 +681,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -896,7 +784,7 @@ dependencies = [ "chrono", "chrono-tz", "clap", - "crossterm", + "crossterm 0.29.0", "derive_builder", "dirs", "dotenvy", @@ -949,7 +837,9 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash 0.1.5", + "allocator-api2", + "equivalent", + "foldhash", ] [[package]] @@ -957,11 +847,6 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] [[package]] name = "heck" @@ -969,12 +854,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - [[package]] name = "hostname" version = "0.4.2" @@ -1264,15 +1143,15 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.11" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" dependencies = [ - "darling 0.23.0", + "darling", "indoc", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -1299,9 +1178,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.14.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] @@ -1322,17 +1201,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "kasuari" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" -dependencies = [ - "hashbrown 0.16.1", - "portable-atomic", - "thiserror 2.0.18", -] - [[package]] name = "keyring" version = "3.6.3" @@ -1347,12 +1215,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "lab" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" - [[package]] name = "lazy_static" version = "1.5.0" @@ -1381,13 +1243,10 @@ dependencies = [ ] [[package]] -name = "line-clipping" -version = "0.3.5" +name = "linux-raw-sys" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" -dependencies = [ - "bitflags 2.11.0", -] +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" @@ -1424,11 +1283,11 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.16.3" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.15.5", ] [[package]] @@ -1437,16 +1296,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "mac_address" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" -dependencies = [ - "nix", - "winapi", -] - [[package]] name = "matchers" version = "0.2.0" @@ -1462,27 +1311,6 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "memmem" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "mio" version = "1.1.1" @@ -1495,29 +1323,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1529,20 +1334,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" - -[[package]] -name = "num-derive" -version = "0.4.2" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-traits" @@ -1592,15 +1386,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "ordered-float" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" -dependencies = [ - "num-traits", -] - [[package]] name = "parking_lot" version = "0.12.5" @@ -1625,63 +1410,16 @@ dependencies = [ ] [[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pest" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" -dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.6" +name = "paste" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pest_meta" -version = "2.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" -dependencies = [ - "pest", - "sha2", -] +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] -name = "phf" -version = "0.11.3" +name = "percent-encoding" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros", - "phf_shared 0.11.3", -] +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "phf" @@ -1689,49 +1427,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" dependencies = [ - "phf_shared 0.12.1", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.5", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", + "phf_shared", ] [[package]] @@ -1767,12 +1463,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - [[package]] name = "potential_utf" version = "0.1.4" @@ -1804,7 +1494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn", ] [[package]] @@ -1953,87 +1643,23 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" -dependencies = [ - "instability", - "ratatui-core", - "ratatui-crossterm", - "ratatui-macros", - "ratatui-termwiz", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-core" -version = "0.1.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags 2.11.0", + "bitflags", + "cassowary", "compact_str", - "hashbrown 0.16.1", + "crossterm 0.28.1", "indoc", + "instability", "itertools", - "kasuari", "lru", + "paste", "strum", - "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", - "unicode-width", -] - -[[package]] -name = "ratatui-crossterm" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" -dependencies = [ - "cfg-if", - "crossterm", - "instability", - "ratatui-core", -] - -[[package]] -name = "ratatui-macros" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" -dependencies = [ - "ratatui-core", - "ratatui-widgets", -] - -[[package]] -name = "ratatui-termwiz" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" -dependencies = [ - "ratatui-core", - "termwiz", -] - -[[package]] -name = "ratatui-widgets" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" -dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.16.1", - "indoc", - "instability", - "itertools", - "line-clipping", - "ratatui-core", - "strum", - "time", - "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.0", ] [[package]] @@ -2042,7 +1668,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags", ] [[package]] @@ -2056,18 +1682,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - [[package]] name = "regex-automata" version = "0.4.14" @@ -2155,16 +1769,29 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -2269,7 +1896,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.11.0", + "bitflags", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -2282,7 +1909,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -2332,7 +1959,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2396,7 +2023,7 @@ checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2504,23 +2131,24 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.27.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.27.2" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "rustversion", + "syn", ] [[package]] @@ -2529,17 +2157,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.117" @@ -2568,7 +2185,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2580,73 +2197,10 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] -[[package]] -name = "terminfo" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" -dependencies = [ - "fnv", - "nom", - "phf 0.11.3", - "phf_codegen", -] - -[[package]] -name = "termios" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" -dependencies = [ - "libc", -] - -[[package]] -name = "termwiz" -version = "0.23.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" -dependencies = [ - "anyhow", - "base64", - "bitflags 2.11.0", - "fancy-regex", - "filedescriptor", - "finl_unicode", - "fixedbitset", - "hex", - "lazy_static", - "libc", - "log", - "memmem", - "nix", - "num-derive", - "num-traits", - "ordered-float", - "pest", - "pest_derive", - "phf 0.11.3", - "sha2", - "signal-hook", - "siphasher", - "terminfo", - "termios", - "thiserror 1.0.69", - "ucd-trie", - "unicode-segmentation", - "vtparse", - "wezterm-bidi", - "wezterm-blob-leases", - "wezterm-color-types", - "wezterm-dynamic", - "wezterm-input-types", - "winapi", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -2673,7 +2227,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2684,7 +2238,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2698,9 +2252,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", "itoa", @@ -2708,22 +2262,22 @@ dependencies = [ "num-conv", "num_threads", "powerfmt", - "serde_core", + "serde", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" dependencies = [ "num-conv", "time-core", @@ -2779,7 +2333,7 @@ checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2826,7 +2380,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.11.0", + "bitflags", "bytes", "futures-util", "http", @@ -2881,7 +2435,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -2948,12 +2502,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -2968,20 +2516,26 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-truncate" -version = "2.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "unicode-xid" @@ -3035,18 +2589,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "1.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" -dependencies = [ - "atomic", - "getrandom 0.4.2", - "js-sys", - "wasm-bindgen", -] - [[package]] name = "valuable" version = "0.1.1" @@ -3059,15 +2601,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "vtparse" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" -dependencies = [ - "utf8parse", -] - [[package]] name = "want" version = "0.3.1" @@ -3147,7 +2680,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wasm-bindgen-shared", ] @@ -3201,7 +2734,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags", "hashbrown 0.15.5", "indexmap", "semver", @@ -3227,78 +2760,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "wezterm-bidi" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" -dependencies = [ - "log", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-blob-leases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" -dependencies = [ - "getrandom 0.3.4", - "mac_address", - "sha2", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "wezterm-color-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" -dependencies = [ - "csscolorparser", - "deltae", - "lazy_static", - "wezterm-dynamic", -] - -[[package]] -name = "wezterm-dynamic" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" -dependencies = [ - "log", - "ordered-float", - "strsim", - "thiserror 1.0.69", - "wezterm-dynamic-derive", -] - -[[package]] -name = "wezterm-dynamic-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "wezterm-input-types" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" -dependencies = [ - "bitflags 1.3.2", - "euclid", - "lazy_static", - "serde", - "wezterm-dynamic", -] - [[package]] name = "winapi" version = "0.3.9" @@ -3342,7 +2803,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3353,7 +2814,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3632,7 +3093,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn 2.0.117", + "syn", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3648,7 +3109,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.117", + "syn", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3660,7 +3121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags", "indexmap", "log", "serde", @@ -3715,7 +3176,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -3761,7 +3222,7 @@ checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3781,7 +3242,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", "synstructure", ] @@ -3802,7 +3263,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] @@ -3835,7 +3296,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 24bc253b..d8edf185 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ version = "0.16.0" edition = "2021" description = "Google Workspace CLI — dynamic command surface from Discovery Service" license = "Apache-2.0" +rust-version = "1.87.0" repository = "https://github.com/googleworkspace/cli" homepage = "https://github.com/googleworkspace/cli" readme = "README.md" @@ -51,7 +52,7 @@ tokio-util = { version = "0.7", features = ["io"] } bytes = "1" base64 = "0.22.1" derive_builder = "0.20.2" -ratatui = "0.30.0" +ratatui = "0.29.0" crossterm = "0.29.0" chrono = "0.4.44" chrono-tz = "0.10" diff --git a/src/setup_tui.rs b/src/setup_tui.rs index 67591526..204d99db 100644 --- a/src/setup_tui.rs +++ b/src/setup_tui.rs @@ -30,6 +30,7 @@ use ratatui::{ widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, DefaultTerminal, }; +use ratatui::prelude::Stylize; use std::io::stdout; /// An item in the multi-select list. From d4b09de0680e03cb0605a463b020493485a90f9f Mon Sep 17 00:00:00 2001 From: dumko2001 Date: Sun, 15 Mar 2026 11:27:15 +0530 Subject: [PATCH 2/7] feat(auth): restore multi-account support (fixes #439) --- .changeset/multi-account-auth.md | 5 + Cargo.lock | 92 +++++++++----- Cargo.toml | 1 - src/auth.rs | 172 +++++++++++---------------- src/auth_commands.rs | 198 ++++++++++++++++++++----------- src/commands.rs | 8 ++ src/credential_store.rs | 17 ++- src/executor.rs | 7 +- src/helpers/calendar.rs | 9 +- src/helpers/chat.rs | 4 +- src/helpers/docs.rs | 4 +- src/helpers/drive.rs | 4 +- src/helpers/events/renew.rs | 3 +- src/helpers/events/subscribe.rs | 7 +- src/helpers/gmail/forward.rs | 3 +- src/helpers/gmail/mod.rs | 5 +- src/helpers/gmail/reply.rs | 3 +- src/helpers/gmail/triage.rs | 3 +- src/helpers/gmail/watch.rs | 21 ++-- src/helpers/modelarmor.rs | 22 ++-- src/helpers/script.rs | 4 +- src/helpers/sheets.rs | 8 +- src/helpers/workflows.rs | 21 ++-- src/main.rs | 9 +- src/setup.rs | 4 +- src/timezone.rs | 32 +++-- 26 files changed, 400 insertions(+), 266 deletions(-) create mode 100644 .changeset/multi-account-auth.md diff --git a/.changeset/multi-account-auth.md b/.changeset/multi-account-auth.md new file mode 100644 index 00000000..02606813 --- /dev/null +++ b/.changeset/multi-account-auth.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Implement multi-account authentication support. Users can now use the global `--account ` flag to isolate credentials and token caches for different Google accounts. diff --git a/Cargo.lock b/Cargo.lock index b75d6439..8f1d8bb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,9 +196,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "shlex", @@ -433,19 +433,29 @@ dependencies = [ [[package]] name = "darling" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] name = "darling_core" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ "fnv", "ident_case", @@ -455,25 +465,49 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + [[package]] name = "darling_macro" -version = "0.20.10" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn", ] [[package]] name = "deranged" -version = "0.3.11" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -491,7 +525,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn", @@ -1143,11 +1177,11 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" dependencies = [ - "darling", + "darling 0.23.0", "indoc", "proc-macro2", "quote", @@ -1334,9 +1368,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-traits" @@ -2252,9 +2286,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.36" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", @@ -2262,22 +2296,22 @@ dependencies = [ "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.18" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -2295,9 +2329,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] diff --git a/Cargo.toml b/Cargo.toml index d8edf185..551e52eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,6 @@ version = "0.16.0" edition = "2021" description = "Google Workspace CLI — dynamic command surface from Discovery Service" license = "Apache-2.0" -rust-version = "1.87.0" repository = "https://github.com/googleworkspace/cli" homepage = "https://github.com/googleworkspace/cli" readme = "README.md" diff --git a/src/auth.rs b/src/auth.rs index b602d840..4fac4941 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -93,12 +93,14 @@ pub trait AccessTokenProvider: Send + Sync { #[derive(Debug, Clone)] pub struct ScopedTokenProvider { scopes: Vec, + account: Option, } impl ScopedTokenProvider { - pub fn new(scopes: &[&str]) -> Self { + pub fn new(scopes: &[&str], account: Option) -> Self { Self { scopes: scopes.iter().map(|scope| (*scope).to_string()).collect(), + account, } } } @@ -107,12 +109,12 @@ impl ScopedTokenProvider { impl AccessTokenProvider for ScopedTokenProvider { async fn access_token(&self) -> anyhow::Result { let scopes: Vec<&str> = self.scopes.iter().map(String::as_str).collect(); - get_token(&scopes).await + get_token(&scopes, self.account.as_deref()).await } } -pub fn token_provider(scopes: &[&str]) -> ScopedTokenProvider { - ScopedTokenProvider::new(scopes) +pub fn token_provider(scopes: &[&str], account: Option) -> ScopedTokenProvider { + ScopedTokenProvider::new(scopes, account) } /// A fake [`AccessTokenProvider`] for tests that returns tokens from a queue. @@ -155,7 +157,7 @@ impl AccessTokenProvider for FakeTokenProvider { /// - `GOOGLE_APPLICATION_CREDENTIALS` env var (path to a JSON credentials file), then /// - Well-known ADC path: `~/.config/gcloud/application_default_credentials.json` /// (populated by `gcloud auth application-default login`) -pub async fn get_token(scopes: &[&str]) -> anyhow::Result { +pub async fn get_token(scopes: &[&str], account: Option<&str>) -> anyhow::Result { // 0. Direct token from env var (highest priority, bypasses all credential loading) if let Ok(token) = std::env::var("GOOGLE_WORKSPACE_CLI_TOKEN") { if !token.is_empty() { @@ -165,11 +167,15 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result { let creds_file = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE").ok(); let config_dir = crate::auth_commands::config_dir(); - let enc_path = credential_store::encrypted_credentials_path(); - let default_path = config_dir.join("credentials.json"); - let token_cache = config_dir.join("token_cache.json"); - let creds = load_credentials_inner(creds_file.as_deref(), &enc_path, &default_path).await?; + let tc_filename = if let Some(acc) = account { + format!("token_cache.{acc}.json") + } else { + "token_cache.json".to_string() + }; + let token_cache = config_dir.join(tc_filename); + + let creds = load_credentials_inner(creds_file.as_deref(), account).await?; get_token_inner(scopes, creds, &token_cache).await } @@ -255,9 +261,11 @@ async fn parse_credential_file( async fn load_credentials_inner( env_file: Option<&str>, - enc_path: &std::path::Path, - default_path: &std::path::Path, + account: Option<&str>, ) -> anyhow::Result { + let config_dir = crate::auth_commands::config_dir(); + let enc_path = credential_store::encrypted_credentials_path(account); + let default_path = config_dir.join("credentials.json"); // 1. Explicit env var — plaintext file (User or Service Account) if let Some(path) = env_file { let p = PathBuf::from(path); @@ -274,9 +282,9 @@ async fn load_credentials_inner( // 2. Encrypted credentials if enc_path.exists() { - match credential_store::load_encrypted_from_path(enc_path) { + match credential_store::load_encrypted_from_path(&enc_path) { Ok(json_str) => { - return parse_credential_file(enc_path, &json_str).await; + return parse_credential_file(&enc_path, &json_str).await; } Err(e) => { // Decryption failed — the encryption key likely changed (e.g. after @@ -287,7 +295,7 @@ async fn load_credentials_inner( "Warning: removing undecryptable credentials file ({}): {e:#}", enc_path.display() ); - if let Err(err) = tokio::fs::remove_file(enc_path).await { + if let Err(err) = tokio::fs::remove_file(&enc_path).await { eprintln!( "Warning: failed to remove stale credentials file '{}': {err}", enc_path.display() @@ -313,7 +321,7 @@ async fn load_credentials_inner( // 3. Plaintext credentials at default path (AuthorizedUser) if default_path.exists() { return Ok(Credential::AuthorizedUser( - yup_oauth2::read_authorized_user_secret(default_path) + yup_oauth2::read_authorized_user_secret(default_path.clone()) .await .with_context(|| { format!("Failed to read credentials from {}", default_path.display()) @@ -401,18 +409,13 @@ mod tests { #[tokio::test] #[serial_test::serial] async fn test_load_credentials_no_options() { - // Isolate from host ADC: override HOME so adc_well_known_path() - // resolves to a non-existent directory, and clear the env var. + // Isolate from host ADC: override CONFIG_DIR and clear ADC env var. let tmp = tempfile::tempdir().unwrap(); - let _home_guard = EnvVarGuard::set("HOME", tmp.path()); + let _config_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", tmp.path()); let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS"); + let _home_guard = EnvVarGuard::set("HOME", "/missing/home"); - let err = load_credentials_inner( - None, - &PathBuf::from("/does/not/exist1"), - &PathBuf::from("/does/not/exist2"), - ) - .await; + let err = load_credentials_inner(None, None).await; assert!(err.is_err()); assert!(err @@ -433,17 +436,15 @@ mod tests { }"#; file.write_all(json.as_bytes()).unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let _config_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", tmp.path()); + let _home_guard = EnvVarGuard::set("HOME", tmp.path()); let _adc_guard = EnvVarGuard::set( "GOOGLE_APPLICATION_CREDENTIALS", file.path().to_str().unwrap(), ); - let res = load_credentials_inner( - None, - &PathBuf::from("/missing/enc"), - &PathBuf::from("/missing/plain"), - ) - .await; + let res = load_credentials_inner(None, None).await; match res.unwrap() { Credential::AuthorizedUser(secret) => { @@ -475,12 +476,7 @@ mod tests { file.path().to_str().unwrap(), ); - let res = load_credentials_inner( - None, - &PathBuf::from("/missing/enc"), - &PathBuf::from("/missing/plain"), - ) - .await; + let res = load_credentials_inner(None, None).await; match res.unwrap() { Credential::ServiceAccount(key) => { @@ -500,12 +496,7 @@ mod tests { // When GOOGLE_APPLICATION_CREDENTIALS points to a missing file, we error immediately // rather than falling through — the user explicitly asked for this file. - let err = load_credentials_inner( - None, - &PathBuf::from("/missing/enc"), - &PathBuf::from("/missing/plain"), - ) - .await; + let err = load_credentials_inner(None, None).await; assert!(err.is_err()); let msg = err.unwrap_err().to_string(); @@ -517,12 +508,7 @@ mod tests { #[tokio::test] async fn test_load_credentials_env_file_missing() { - let err = load_credentials_inner( - Some("/does/not/exist"), - &PathBuf::from("/also/missing"), - &PathBuf::from("/still/missing"), - ) - .await; + let err = load_credentials_inner(Some("/does/not/exist"), None).await; assert!(err.is_err()); assert!(err.unwrap_err().to_string().contains("does not exist")); } @@ -538,13 +524,9 @@ mod tests { }"#; file.write_all(json.as_bytes()).unwrap(); - let res = load_credentials_inner( - Some(file.path().to_str().unwrap()), - &PathBuf::from("/also/missing"), - &PathBuf::from("/still/missing"), - ) - .await - .unwrap(); + let res = load_credentials_inner(Some(file.path().to_str().unwrap()), None) + .await + .unwrap(); match res { Credential::AuthorizedUser(secret) => { @@ -570,13 +552,9 @@ mod tests { }"#; file.write_all(json.as_bytes()).unwrap(); - let res = load_credentials_inner( - Some(file.path().to_str().unwrap()), - &PathBuf::from("/also/missing"), - &PathBuf::from("/still/missing"), - ) - .await - .unwrap(); + let res = load_credentials_inner(Some(file.path().to_str().unwrap()), None) + .await + .unwrap(); match res { Credential::ServiceAccount(key) => { @@ -588,18 +566,18 @@ mod tests { #[tokio::test] async fn test_load_credentials_default_path_authorized_user() { - let mut file = NamedTempFile::new().unwrap(); + let dir = tempfile::tempdir().unwrap(); let json = r#"{ "client_id": "default_id", "client_secret": "default_secret", "refresh_token": "default_refresh", "type": "authorized_user" }"#; - file.write_all(json.as_bytes()).unwrap(); + let plain_path = dir.path().join("credentials.json"); + std::fs::write(&plain_path, json).unwrap(); - let res = load_credentials_inner(None, &PathBuf::from("/also/missing"), file.path()) - .await - .unwrap(); + let _config_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path()); + let res = load_credentials_inner(None, None).await.unwrap(); match res { Credential::AuthorizedUser(secret) => { @@ -614,7 +592,7 @@ mod tests { async fn test_get_token_from_env_var() { let _token_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_TOKEN", "my-test-token"); - let result = get_token(&["https://www.googleapis.com/auth/drive"]).await; + let result = get_token(&["https://www.googleapis.com/auth/drive"], None).await; assert!(result.is_ok()); assert_eq!(result.unwrap(), "my-test-token"); @@ -624,7 +602,7 @@ mod tests { #[serial_test::serial] async fn test_scoped_token_provider_uses_get_token() { let _token_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_TOKEN", "provider-token"); - let provider = token_provider(&["https://www.googleapis.com/auth/drive"]); + let provider = token_provider(&["https://www.googleapis.com/auth/drive"], None); let first = provider.access_token().await.unwrap(); let second = provider.access_token().await.unwrap(); @@ -650,9 +628,8 @@ mod tests { let encrypted = crate::credential_store::encrypt(json.as_bytes()).unwrap(); std::fs::write(&enc_path, &encrypted).unwrap(); - let res = load_credentials_inner(None, &enc_path, &PathBuf::from("/does/not/exist")) - .await - .unwrap(); + let _config_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path()); + let res = load_credentials_inner(None, None).await.unwrap(); match res { Credential::AuthorizedUser(secret) => { @@ -688,9 +665,8 @@ mod tests { std::fs::write(&enc_path, &encrypted).unwrap(); std::fs::write(&plain_path, plain_json).unwrap(); - let res = load_credentials_inner(None, &enc_path, &plain_path) - .await - .unwrap(); + let _config_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path()); + let res = load_credentials_inner(None, None).await.unwrap(); match res { Credential::AuthorizedUser(secret) => { @@ -708,10 +684,6 @@ mod tests { async fn test_load_credentials_corrupt_encrypted_file_is_removed() { // When credentials.enc cannot be decrypted, the file should be removed // automatically and the function should fall through to other sources. - let tmp = tempfile::tempdir().unwrap(); - let _home_guard = EnvVarGuard::set("HOME", tmp.path()); - let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS"); - let dir = tempfile::tempdir().unwrap(); let enc_path = dir.path().join("credentials.enc"); @@ -721,12 +693,15 @@ mod tests { .unwrap(); assert!(enc_path.exists()); - let result = - load_credentials_inner(None, &enc_path, &PathBuf::from("/does/not/exist")).await; + let _config_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path()); + let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS"); + let _home_guard = EnvVarGuard::set("HOME", "/missing/home"); + + let res = load_credentials_inner(None, None).await; // Should fall through to "No credentials found" (not a decryption error). - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); + assert!(res.is_err()); + let msg = res.unwrap_err().to_string(); assert!( msg.contains("No credentials found"), "Should fall through to final error, got: {msg}" @@ -760,9 +735,8 @@ mod tests { }"#; tokio::fs::write(&plain_path, plain_json).await.unwrap(); - let res = load_credentials_inner(None, &enc_path, &plain_path) - .await - .unwrap(); + let _config_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path()); + let res = load_credentials_inner(None, None).await.unwrap(); match res { Credential::AuthorizedUser(secret) => { @@ -781,18 +755,13 @@ mod tests { async fn test_get_token_env_var_empty_falls_through() { // An empty token should not short-circuit — it should be ignored // and fall through to normal credential loading. - // Isolate from host ADC so the well-known path doesn't match. let tmp = tempfile::tempdir().unwrap(); - let _home_guard = EnvVarGuard::set("HOME", tmp.path()); + let _config_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", tmp.path()); let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS"); + let _home_guard = EnvVarGuard::set("HOME", "/missing/home"); let _token_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_TOKEN", ""); - let result = load_credentials_inner( - None, - &PathBuf::from("/does/not/exist1"), - &PathBuf::from("/does/not/exist2"), - ) - .await; + let result = load_credentials_inner(None, None).await; // Should fall through to normal credential loading, which fails // because we pointed at non-existent paths @@ -836,18 +805,18 @@ mod tests { #[serial_test::serial] fn test_get_quota_project_priority_adc_fallback() { let tmp = tempfile::tempdir().unwrap(); - let adc_dir = tmp.path().join(".config").join("gcloud"); - std::fs::create_dir_all(&adc_dir).unwrap(); + let gcloud_dir = tmp.path().join(".config").join("gcloud"); + std::fs::create_dir_all(&gcloud_dir).unwrap(); std::fs::write( - adc_dir.join("application_default_credentials.json"), + gcloud_dir.join("application_default_credentials.json"), r#"{"quota_project_id": "adc-project"}"#, ) .unwrap(); let _home_guard = EnvVarGuard::set("HOME", tmp.path()); + let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS"); let _env_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_PROJECT_ID"); let _config_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_CLI_CONFIG_DIR"); - let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS"); assert_eq!(get_quota_project(), Some("adc-project".to_string())); } @@ -856,17 +825,16 @@ mod tests { #[serial_test::serial] fn test_get_quota_project_reads_adc() { let tmp = tempfile::tempdir().unwrap(); - let adc_dir = tmp.path().join(".config").join("gcloud"); - std::fs::create_dir_all(&adc_dir).unwrap(); + let gcloud_dir = tmp.path().join(".config").join("gcloud"); + std::fs::create_dir_all(&gcloud_dir).unwrap(); std::fs::write( - adc_dir.join("application_default_credentials.json"), + gcloud_dir.join("application_default_credentials.json"), r#"{"quota_project_id": "my-project-123"}"#, ) .unwrap(); let _home_guard = EnvVarGuard::set("HOME", tmp.path()); let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS"); - // Isolate from local environment let _env_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_PROJECT_ID"); let _config_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_CLI_CONFIG_DIR"); diff --git a/src/auth_commands.rs b/src/auth_commands.rs index f51ba6dd..36853f58 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -117,62 +117,119 @@ pub fn config_dir() -> PathBuf { primary } -fn plain_credentials_path() -> PathBuf { +fn plain_credentials_path(account: Option<&str>) -> PathBuf { if let Ok(path) = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE") { return PathBuf::from(path); } - config_dir().join("credentials.json") + let filename = if let Some(acc) = account { + format!("credentials.{acc}.json") + } else { + "credentials.json".to_string() + }; + config_dir().join(filename) +} + +fn token_cache_path(account: Option<&str>) -> PathBuf { + let filename = if let Some(acc) = account { + format!("token_cache.{acc}.json") + } else { + "token_cache.json".to_string() + }; + config_dir().join(filename) } -fn token_cache_path() -> PathBuf { - config_dir().join("token_cache.json") +fn sa_token_cache_path(account: Option<&str>) -> PathBuf { + let filename = if let Some(acc) = account { + format!("sa_token_cache.{acc}.json") + } else { + "sa_token_cache.json".to_string() + }; + config_dir().join(filename) } /// Handle `gws auth `. pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { - const USAGE: &str = concat!( - "Usage: gws auth [options]\n\n", - " login Authenticate via OAuth2 (opens browser)\n", - " --readonly Request read-only scopes\n", - " --full Request all scopes incl. pubsub + cloud-platform\n", - " (may trigger restricted_client for unverified apps)\n", - " --scopes Comma-separated custom scopes\n", - " -s, --services Comma-separated service names to limit scope picker\n", - " (e.g. -s drive,gmail,sheets)\n", - " setup Configure GCP project + OAuth client (requires gcloud)\n", - " --project Use a specific GCP project\n", - " --login Run `gws auth login` after successful setup\n", - " status Show current authentication state\n", - " export Print decrypted credentials to stdout\n", - " logout Clear saved credentials and token cache", - ); + use clap::{Arg, Command}; + + let cmd = Command::new("gws auth") + .about("Authentication and account management") + .subcommand_required(true) + .arg_required_else_help(true) + .arg( + Arg::new("account") + .long("account") + .short('A') + .help("Account alias to use (e.g. 'work', 'personal')") + .global(true), + ) + .subcommand( + Command::new("login") + .about("Authenticate via OAuth2 (opens browser)") + .arg(Arg::new("readonly").long("readonly").action(clap::ArgAction::SetTrue).help("Request read-only scopes")) + .arg(Arg::new("full").long("full").action(clap::ArgAction::SetTrue).help("Request all scopes incl. pubsub + cloud-platform")) + .arg(Arg::new("scopes").long("scopes").help("Comma-separated custom scopes")) + .arg(Arg::new("services").long("services").short('s').help("Comma-separated service names to limit scope picker")) + ) + .subcommand( + Command::new("setup") + .about("Configure GCP project + OAuth client (requires gcloud)") + .arg(Arg::new("project").long("project").help("Use a specific GCP project")) + .arg(Arg::new("login").long("login").action(clap::ArgAction::SetTrue).help("Run 'gws auth login' after successful setup")) + ) + .subcommand(Command::new("status").about("Show current authentication state")) + .subcommand( + Command::new("export") + .about("Print decrypted credentials to stdout") + .arg(Arg::new("unmasked").long("unmasked").action(clap::ArgAction::SetTrue).help("Print raw secrets (CAUTION)")) + ) + .subcommand(Command::new("logout").about("Clear saved credentials and token cache")); - // Honour --help / -h before treating the first arg as a subcommand. - if args.is_empty() || args[0] == "--help" || args[0] == "-h" { - println!("{USAGE}"); - return Ok(()); - } + let matches = cmd.try_get_matches_from(std::iter::once("gws auth").chain(args.iter().map(|s| s.as_str()))) + .map_err(|e| GwsError::Validation(e.to_string()))?; - match args[0].as_str() { - "login" => run_login(&args[1..]).await, - "setup" => crate::setup::run_setup(&args[1..]).await, - "status" => handle_status().await, - "export" => { - let unmasked = args.len() > 1 && args[1] == "--unmasked"; - handle_export(unmasked).await + let account = matches.get_one::("account").map(|s| s.as_str()); + + match matches.subcommand() { + Some(("login", sub)) => { + let mut login_args = Vec::new(); + if sub.get_flag("readonly") { login_args.push("--readonly".to_string()); } + if sub.get_flag("full") { login_args.push("--full".to_string()); } + if let Some(s) = sub.get_one::("scopes") { + login_args.push("--scopes".to_string()); + login_args.push(s.clone()); + } + if let Some(s) = sub.get_one::("services") { + login_args.push("--services".to_string()); + login_args.push(s.clone()); + } + run_login(&login_args, account).await } - "logout" => handle_logout(), - other => Err(GwsError::Validation(format!( - "Unknown auth subcommand: '{other}'. Use: login, setup, status, export, logout" - ))), + Some(("setup", sub)) => { + let mut setup_args = Vec::new(); + if let Some(p) = sub.get_one::("project") { + setup_args.push("--project".to_string()); + setup_args.push(p.clone()); + } + if sub.get_flag("login") { + setup_args.push("--login".to_string()); + } + crate::setup::run_setup(&setup_args).await + } + Some(("status", _)) => handle_status(account).await, + Some(("export", sub)) => { + let unmasked = sub.get_flag("unmasked"); + handle_export(unmasked, account).await + } + Some(("logout", _)) => handle_logout(account), + _ => unreachable!("subcommand_required(true)"), } } /// Run the `auth login` flow. /// /// Exposed for internal orchestration (e.g. `auth setup --login`). -pub async fn run_login(args: &[String]) -> Result<(), GwsError> { - handle_login(args).await +pub async fn run_login(args: &[String], account: Option<&str>) -> Result<(), GwsError> { + handle_login(args, account).await } /// Custom delegate that prints the OAuth URL on its own line for easy copying. /// Optionally includes `login_hint` in the URL for account pre-selection. @@ -210,7 +267,7 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega } } -async fn handle_login(args: &[String]) -> Result<(), GwsError> { +async fn handle_login(args: &[String], account: Option<&str>) -> Result<(), GwsError> { // Extract -s/--services from args let mut services_filter: Option> = None; let mut filtered_args: Vec = Vec::new(); @@ -348,7 +405,7 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { let actual_email = fetch_userinfo_email(access_token).await; // Save encrypted credentials - let enc_path = credential_store::save_encrypted(&creds_str) + let enc_path = credential_store::save_encrypted(&creds_str, account) .map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?; // Clean up temp file @@ -358,6 +415,7 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { "status": "success", "message": "Authentication successful. Encrypted credentials saved.", "account": actual_email.as_deref().unwrap_or("(unknown)"), + "alias": account.unwrap_or("(none)"), "credentials_file": enc_path.display().to_string(), "encryption": "AES-256-GCM (key in OS keyring or local `.encryption_key`; set GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file for headless)", "scopes": scopes, @@ -397,15 +455,14 @@ async fn fetch_userinfo_email(access_token: &str) -> Option { .map(|s| s.to_string()) } -async fn handle_export(unmasked: bool) -> Result<(), GwsError> { - let enc_path = credential_store::encrypted_credentials_path(); +async fn handle_export(unmasked: bool, account: Option<&str>) -> Result<(), GwsError> { + let enc_path = credential_store::encrypted_credentials_path(account); if !enc_path.exists() { return Err(GwsError::Auth( "No encrypted credentials found. Run 'gws auth login' first.".to_string(), )); } - - match credential_store::load_encrypted() { + match credential_store::load_encrypted(account) { Ok(contents) => { if unmasked { println!("{contents}"); @@ -923,10 +980,10 @@ fn run_simple_scope_picker(services_filter: Option<&HashSet>) -> Option< } } -async fn handle_status() -> Result<(), GwsError> { - let plain_path = plain_credentials_path(); - let enc_path = credential_store::encrypted_credentials_path(); - let token_cache = token_cache_path(); +async fn handle_status(account: Option<&str>) -> Result<(), GwsError> { + let plain_path = plain_credentials_path(account); + let enc_path = credential_store::encrypted_credentials_path(account); + let token_cache = token_cache_path(account); let has_encrypted = enc_path.exists(); let has_plain = plain_path.exists(); @@ -1013,7 +1070,7 @@ async fn handle_status() -> Result<(), GwsError> { // Skip real credential/network access in test builds if !cfg!(test) { if has_encrypted { - match credential_store::load_encrypted() { + match credential_store::load_encrypted(account) { Ok(contents) => { if let Ok(creds) = serde_json::from_str::(&contents) { if let Some(client_id) = creds.get("client_id").and_then(|v| v.as_str()) { @@ -1070,10 +1127,13 @@ async fn handle_status() -> Result<(), GwsError> { // If we have credentials, try to get live info (user, scopes, APIs) // Skip all network calls and subprocess spawning in test builds if !cfg!(test) { - let creds_json_str = if has_encrypted { - credential_store::load_encrypted().ok() - } else if has_plain { - tokio::fs::read_to_string(&plain_path).await.ok() + let enc_path = credential_store::encrypted_credentials_path(account); + let default_path = config_dir().join("credentials.json"); + + let creds_json_str = if enc_path.exists() { + credential_store::load_encrypted(account).ok() + } else if default_path.exists() { + tokio::fs::read_to_string(&default_path).await.ok() } else { None }; @@ -1174,11 +1234,12 @@ async fn handle_status() -> Result<(), GwsError> { Ok(()) } -fn handle_logout() -> Result<(), GwsError> { - let plain_path = plain_credentials_path(); - let enc_path = credential_store::encrypted_credentials_path(); - let token_cache = token_cache_path(); - let sa_token_cache = config_dir().join("sa_token_cache.json"); +fn handle_logout(account: Option<&str>) -> Result<(), GwsError> { + let plain_path = plain_credentials_path(account); + let enc_path = credential_store::encrypted_credentials_path(account); + let token_cache = token_cache_path(account); + let sa_token_cache = sa_token_cache_path(account); + let mut removed = Vec::new(); @@ -1192,7 +1253,7 @@ fn handle_logout() -> Result<(), GwsError> { } // Invalidate cached account timezone (may belong to old account) - crate::timezone::invalidate_cache(); + crate::timezone::invalidate_cache(account); let output = if removed.is_empty() { json!({ @@ -1608,7 +1669,7 @@ mod tests { unsafe { std::env::remove_var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE"); } - let path = plain_credentials_path(); + let path = plain_credentials_path(None); assert!(path.ends_with("credentials.json")); assert!(path.starts_with(config_dir())); } @@ -1622,7 +1683,7 @@ mod tests { "/tmp/test-creds.json", ); } - let path = plain_credentials_path(); + let path = plain_credentials_path(None); assert_eq!(path, PathBuf::from("/tmp/test-creds.json")); unsafe { std::env::remove_var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE"); @@ -1631,7 +1692,7 @@ mod tests { #[test] fn token_cache_path_is_in_config_dir() { - let path = token_cache_path(); + let path = token_cache_path(None); assert!(path.ends_with("token_cache.json")); assert!(path.starts_with(config_dir())); } @@ -1640,22 +1701,27 @@ mod tests { async fn handle_auth_command_empty_args_prints_usage() { let args: Vec = vec![]; let result = handle_auth_command(&args).await; - // Empty args now prints usage and returns Ok - assert!(result.is_ok()); + // clap returns an error of kind DisplayHelp for required subcommands + assert!(result.is_err()); + if let GwsError::Validation(e) = result.unwrap_err() { + assert!(e.contains("Usage") || e.contains("gws auth")); + } else { + panic!("Expected Validation error"); + } } #[tokio::test] async fn handle_auth_command_help_flag_returns_ok() { let args = vec!["--help".to_string()]; let result = handle_auth_command(&args).await; - assert!(result.is_ok()); + assert!(result.is_err()); // clap returns ErrorKind::DisplayHelp } #[tokio::test] async fn handle_auth_command_help_short_flag_returns_ok() { let args = vec!["-h".to_string()]; let result = handle_auth_command(&args).await; - assert!(result.is_ok()); + assert!(result.is_err()); // clap returns ErrorKind::DisplayHelp } #[tokio::test] diff --git a/src/commands.rs b/src/commands.rs index 27324e42..98156e19 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -46,6 +46,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .help("Output format: json (default), table, yaml, csv") .value_name("FORMAT") .global(true), + ) + .arg( + clap::Arg::new("account") + .long("account") + .short('A') + .help("Google account name/alias to use for authentication (e.g. 'work', 'personal')") + .value_name("NAME") + .global(true), ); // Inject helper commands diff --git a/src/credential_store.rs b/src/credential_store.rs index e985b76b..0782f995 100644 --- a/src/credential_store.rs +++ b/src/credential_store.rs @@ -371,13 +371,18 @@ pub fn active_backend_name() -> &'static str { } /// Returns the path for encrypted credentials. -pub fn encrypted_credentials_path() -> PathBuf { - crate::auth_commands::config_dir().join("credentials.enc") +pub fn encrypted_credentials_path(account: Option<&str>) -> PathBuf { + let filename = if let Some(acc) = account { + format!("credentials.{acc}.enc") + } else { + "credentials.enc".to_string() + }; + crate::auth_commands::config_dir().join(filename) } /// Saves credentials JSON to an encrypted file. -pub fn save_encrypted(json: &str) -> anyhow::Result { - let path = encrypted_credentials_path(); +pub fn save_encrypted(json: &str, account: Option<&str>) -> anyhow::Result { + let path = encrypted_credentials_path(account); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; #[cfg(unix)] @@ -423,8 +428,8 @@ pub fn load_encrypted_from_path(path: &std::path::Path) -> anyhow::Result anyhow::Result { - load_encrypted_from_path(&encrypted_credentials_path()) +pub fn load_encrypted(account: Option<&str>) -> anyhow::Result { + load_encrypted_from_path(&encrypted_credentials_path(account)) } #[cfg(test)] diff --git a/src/executor.rs b/src/executor.rs index 73fd772f..43ed12b9 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -224,6 +224,7 @@ async fn handle_json_response( page_token: &mut Option, capture_output: bool, captured: &mut Vec, + account: Option<&str>, ) -> Result { if let Ok(mut json_val) = serde_json::from_str::(body_text) { *pages_fetched += 1; @@ -231,7 +232,7 @@ async fn handle_json_response( // Run Model Armor sanitization if --sanitize is enabled if let Some(template) = sanitize_template { let text_to_check = serde_json::to_string(&json_val).unwrap_or_default(); - match crate::helpers::modelarmor::sanitize_text(template, &text_to_check).await { + match crate::helpers::modelarmor::sanitize_text(template, &text_to_check, account).await { Ok(result) => { let is_match = result.filter_match_state == "MATCH_FOUND"; if is_match { @@ -381,6 +382,7 @@ pub async fn execute_method( sanitize_mode: &crate::helpers::modelarmor::SanitizeMode, output_format: &crate::formatter::OutputFormat, capture_output: bool, + account: Option<&str>, ) -> Result, GwsError> { let input = parse_and_validate_inputs(doc, method, params_json, body_json, upload_path)?; @@ -477,6 +479,7 @@ pub async fn execute_method( &mut page_token, capture_output, &mut captured_values, + account, ) .await?; @@ -2031,6 +2034,7 @@ async fn test_execute_method_dry_run() { &sanitize_mode, &crate::formatter::OutputFormat::default(), false, + None, ) .await; @@ -2075,6 +2079,7 @@ async fn test_execute_method_missing_path_param() { &sanitize_mode, &crate::formatter::OutputFormat::default(), false, + None, ) .await; diff --git a/src/helpers/calendar.rs b/src/helpers/calendar.rs index 8ac00a50..e9567b78 100644 --- a/src/helpers/calendar.rs +++ b/src/helpers/calendar.rs @@ -157,8 +157,9 @@ TIPS: if let Some(matches) = matches.subcommand_matches("+insert") { let (params_str, body_str, scopes) = build_insert_request(matches, doc)?; + let account = matches.get_one::("account"); let scopes_str: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scopes_str).await { + let (token, auth_method) = match auth::get_token(&scopes_str, account.map(|s| s.as_str())).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(_) => (None, executor::AuthMethod::None), }; @@ -186,6 +187,7 @@ TIPS: &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + account.map(|s| s.as_str()), ) .await?; @@ -201,7 +203,8 @@ TIPS: } async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> { let cal_scope = "https://www.googleapis.com/auth/calendar.readonly"; - let token = auth::get_token(&[cal_scope]) + let account = matches.get_one::("account"); + let token = auth::get_token(&[cal_scope], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Calendar auth failed: {e}")))?; @@ -212,7 +215,7 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> { let client = crate::client::build_client()?; let tz_override = matches.get_one::("timezone").map(|s| s.as_str()); - let tz = crate::timezone::resolve_account_timezone(&client, &token, tz_override).await?; + let tz = crate::timezone::resolve_account_timezone(&client, &token, tz_override, account.map(|s| s.as_str())).await?; // Determine time range using the account timezone so that --today and // --tomorrow align with the user's Google account day, not the machine. diff --git a/src/helpers/chat.rs b/src/helpers/chat.rs index 94493e53..0717561d 100644 --- a/src/helpers/chat.rs +++ b/src/helpers/chat.rs @@ -77,8 +77,9 @@ TIPS: // immediately, returning `Err(GwsError)` from the async block. let (params_str, body_str, scopes) = build_send_request(&config, doc)?; + let account = matches.get_one::("account"); let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scope_strs).await { + let (token, auth_method) = match auth::get_token(&scope_strs, account.map(|s| s.as_str())).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(_) => (None, executor::AuthMethod::None), }; @@ -116,6 +117,7 @@ TIPS: &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + account.map(|s| s.as_str()), ) .await?; diff --git a/src/helpers/docs.rs b/src/helpers/docs.rs index 3f6b3896..02d02850 100644 --- a/src/helpers/docs.rs +++ b/src/helpers/docs.rs @@ -69,8 +69,9 @@ TIPS: if let Some(matches) = matches.subcommand_matches("+write") { let (params_str, body_str, scopes) = build_write_request(matches, doc)?; + let account = matches.get_one::("account"); let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scope_strs).await { + let (token, auth_method) = match auth::get_token(&scope_strs, account.map(|s| s.as_str())).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(_) => (None, executor::AuthMethod::None), }; @@ -106,6 +107,7 @@ TIPS: &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + account.map(|s| s.as_str()), ) .await?; diff --git a/src/helpers/drive.rs b/src/helpers/drive.rs index 393e0fde..6c8d78cf 100644 --- a/src/helpers/drive.rs +++ b/src/helpers/drive.rs @@ -96,7 +96,8 @@ TIPS: let body_str = metadata.to_string(); let scopes: Vec<&str> = create_method.scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scopes).await { + let account = matches.get_one::("account"); + let (token, auth_method) = match auth::get_token(&scopes, account.map(|s| s.as_str())).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(_) => (None, executor::AuthMethod::None), }; @@ -117,6 +118,7 @@ TIPS: &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + account.map(|s| s.as_str()), ) .await?; diff --git a/src/helpers/events/renew.rs b/src/helpers/events/renew.rs index 3dd1d627..c3f222b0 100644 --- a/src/helpers/events/renew.rs +++ b/src/helpers/events/renew.rs @@ -31,7 +31,8 @@ pub(super) async fn handle_renew( ) -> Result<(), GwsError> { let config = parse_renew_args(matches)?; let client = crate::client::build_client()?; - let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE]) + let account = matches.get_one::("account"); + let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Failed to get token: {e}")))?; diff --git a/src/helpers/events/subscribe.rs b/src/helpers/events/subscribe.rs index edbfb4dc..3a979314 100644 --- a/src/helpers/events/subscribe.rs +++ b/src/helpers/events/subscribe.rs @@ -112,10 +112,11 @@ pub(super) async fn handle_subscribe( } let client = crate::client::build_client()?; - let pubsub_token_provider = auth::token_provider(&[PUBSUB_SCOPE]); + let account = matches.get_one::("account"); + let pubsub_token_provider = auth::token_provider(&[PUBSUB_SCOPE], account.cloned()); // Get Pub/Sub token - let pubsub_token = auth::get_token(&[PUBSUB_SCOPE]) + let pubsub_token = auth::get_token(&[PUBSUB_SCOPE], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Failed to get Pub/Sub token: {e}")))?; @@ -187,7 +188,7 @@ pub(super) async fn handle_subscribe( // 3. Create Workspace Events subscription eprintln!("Creating Workspace Events subscription..."); - let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE]) + let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE], account.map(|s| s.as_str())) .await .map_err(|e| { GwsError::Auth(format!("Failed to get Workspace Events token: {e}")) diff --git a/src/helpers/gmail/forward.rs b/src/helpers/gmail/forward.rs index e9b76da6..e2417227 100644 --- a/src/helpers/gmail/forward.rs +++ b/src/helpers/gmail/forward.rs @@ -22,13 +22,14 @@ pub(super) async fn handle_forward( let config = parse_forward_args(matches); let dry_run = matches.get_flag("dry-run"); + let account = matches.get_one::("account"); let (original, token) = if dry_run { ( OriginalMessage::dry_run_placeholder(&config.message_id), None, ) } else { - let t = auth::get_token(&[GMAIL_SCOPE]) + let t = auth::get_token(&[GMAIL_SCOPE], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; let client = crate::client::build_client()?; diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index 999d65ce..4e24c946 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -645,11 +645,13 @@ pub(super) async fn send_raw_email( let params = json!({ "userId": "me" }); let params_str = params.to_string(); + let account = matches.get_one::("account"); let (token, auth_method) = match existing_token { Some(t) => (Some(t.to_string()), executor::AuthMethod::OAuth), None => { let scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect(); - match auth::get_token(&scopes).await { + let scope_strs: Vec<&str> = scopes.iter().copied().collect(); + match auth::get_token(&scope_strs, account.map(|s| s.as_str())).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None), Err(e) => return Err(GwsError::Auth(format!("Gmail auth failed: {e}"))), @@ -679,6 +681,7 @@ pub(super) async fn send_raw_email( &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + account.map(|s| s.as_str()), ) .await?; diff --git a/src/helpers/gmail/reply.rs b/src/helpers/gmail/reply.rs index a9ce8dd1..2326b403 100644 --- a/src/helpers/gmail/reply.rs +++ b/src/helpers/gmail/reply.rs @@ -23,13 +23,14 @@ pub(super) async fn handle_reply( let config = parse_reply_args(matches)?; let dry_run = matches.get_flag("dry-run"); + let account = matches.get_one::("account"); let (original, token) = if dry_run { ( OriginalMessage::dry_run_placeholder(&config.message_id), None, ) } else { - let t = auth::get_token(&[GMAIL_SCOPE]) + let t = auth::get_token(&[GMAIL_SCOPE], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; let client = crate::client::build_client()?; diff --git a/src/helpers/gmail/triage.rs b/src/helpers/gmail/triage.rs index ec23bfba..0bb9b3d2 100644 --- a/src/helpers/gmail/triage.rs +++ b/src/helpers/gmail/triage.rs @@ -41,7 +41,8 @@ pub async fn handle_triage(matches: &ArgMatches) -> Result<(), GwsError> { // gmail.metadata scope. When a token carries both metadata and modify // scopes the API may resolve to the metadata path and reject `q` with 403. // gmail.readonly always supports `q`. - let token = auth::get_token(&[GMAIL_READONLY_SCOPE]) + let account = matches.get_one::("account"); + let token = auth::get_token(&[GMAIL_READONLY_SCOPE], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; diff --git a/src/helpers/gmail/watch.rs b/src/helpers/gmail/watch.rs index 15bc9889..856edebb 100644 --- a/src/helpers/gmail/watch.rs +++ b/src/helpers/gmail/watch.rs @@ -16,16 +16,17 @@ pub(super) async fn handle_watch( } let client = crate::client::build_client()?; - let gmail_token_provider = auth::token_provider(&[GMAIL_SCOPE]); - let pubsub_token_provider = auth::token_provider(&[PUBSUB_SCOPE]); + let account = matches.get_one::("account"); + let gmail_token_provider = auth::token_provider(&[GMAIL_SCOPE], account.cloned()); + let pubsub_token_provider = auth::token_provider(&[PUBSUB_SCOPE], account.cloned()); // Get tokens - let gmail_token = auth::get_token(&[GMAIL_SCOPE]) + let gmail_token = auth::get_token(&[GMAIL_SCOPE], account.map(|s| s.as_str())) .await - .context("Failed to get Gmail token")?; - let pubsub_token = auth::get_token(&[PUBSUB_SCOPE]) + .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; + let pubsub_token = auth::get_token(&[PUBSUB_SCOPE], account.map(|s| s.as_str())) .await - .context("Failed to get Pub/Sub token")?; + .map_err(|e| GwsError::Auth(format!("Pub/Sub auth failed: {e}")))?; let (pubsub_subscription, topic_name, created_resources) = if let Some(ref sub_name) = config.subscription @@ -208,6 +209,7 @@ pub(super) async fn handle_watch( sanitize_config, pubsub_api_base: PUBSUB_API_BASE, gmail_api_base: GMAIL_API_BASE, + account: account.cloned(), }; let result = watch_pull_loop( &runtime, @@ -305,7 +307,6 @@ async fn watch_pull_loop( let pull_response: Value = resp.json().await.context("Failed to parse pull response")?; let (ack_ids, max_history_id) = process_pull_response(&pull_response); - if max_history_id > *last_history_id && *last_history_id > 0 { // Fetch new messages via history API fetch_and_output_messages( @@ -316,6 +317,7 @@ async fn watch_pull_loop( config.output_dir.as_ref(), runtime.sanitize_config, runtime.gmail_api_base, + runtime, ) .await?; } @@ -401,6 +403,7 @@ async fn fetch_and_output_messages( output_dir: Option<&std::path::PathBuf>, sanitize_config: &crate::helpers::modelarmor::SanitizeConfig, gmail_api_base: &str, + runtime: &WatchRuntime<'_>, ) -> Result<(), GwsError> { let gmail_token = gmail_token_provider .access_token() @@ -438,7 +441,7 @@ async fn fetch_and_output_messages( // Apply sanitization if configured if let Some(ref template) = sanitize_config.template { let text_to_check = serde_json::to_string(&full_msg).unwrap_or_default(); - match crate::helpers::modelarmor::sanitize_text(template, &text_to_check).await + match crate::helpers::modelarmor::sanitize_text(template, &text_to_check, runtime.account.as_deref()).await { Ok(result) => { if let Some(sanitized_msg) = apply_sanitization_result( @@ -554,6 +557,7 @@ struct WatchRuntime<'a> { sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, pubsub_api_base: &'a str, gmail_api_base: &'a str, + account: Option, } fn parse_watch_args(matches: &ArgMatches) -> Result { @@ -942,6 +946,7 @@ mod tests { sanitize_config: &sanitize_config, pubsub_api_base: &pubsub_base, gmail_api_base: &gmail_base, + account: None, }; watch_pull_loop( diff --git a/src/helpers/modelarmor.rs b/src/helpers/modelarmor.rs index 8ac9fc1c..1262a649 100644 --- a/src/helpers/modelarmor.rs +++ b/src/helpers/modelarmor.rs @@ -226,16 +226,17 @@ TIPS: _sanitize_config: &'a SanitizeConfig, ) -> Pin> + Send + 'a>> { Box::pin(async move { + let account = matches.get_one::("account"); if let Some(sub) = matches.subcommand_matches("+sanitize-prompt") { - handle_sanitize(sub, "sanitizeUserPrompt", "userPromptData").await?; + handle_sanitize(sub, "sanitizeUserPrompt", "userPromptData", account.map(|s| s.as_str())).await?; return Ok(true); } if let Some(sub) = matches.subcommand_matches("+sanitize-response") { - handle_sanitize(sub, "sanitizeModelResponse", "modelResponseData").await?; + handle_sanitize(sub, "sanitizeModelResponse", "modelResponseData", account.map(|s| s.as_str())).await?; return Ok(true); } if let Some(sub) = matches.subcommand_matches("+create-template") { - handle_create_template(sub).await?; + handle_create_template(sub, account.map(|s| s.as_str())).await?; return Ok(true); } Ok(false) @@ -247,10 +248,10 @@ pub const CLOUD_PLATFORM_SCOPE: &str = "https://www.googleapis.com/auth/cloud-pl /// Sanitize text through a Model Armor template and return the result. /// Template format: projects/PROJECT/locations/LOCATION/templates/TEMPLATE -pub async fn sanitize_text(template: &str, text: &str) -> Result { +pub async fn sanitize_text(template: &str, text: &str, account: Option<&str>) -> Result { let (body, url) = build_sanitize_request_data(template, text, "sanitizeUserPrompt")?; - let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE]) + let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE], account) .await .context("Failed to get auth token for Model Armor")?; @@ -280,8 +281,8 @@ pub async fn sanitize_text(template: &str, text: &str) -> Result Result<(), GwsError> { - let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE]) +async fn model_armor_post(url: &str, body: &str, account: Option<&str>) -> Result<(), GwsError> { + let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE], account) .await .context("Failed to get auth token")?; @@ -314,6 +315,7 @@ async fn handle_sanitize( matches: &ArgMatches, method_name: &str, data_field: &str, + account: Option<&str>, ) -> Result<(), GwsError> { let template_raw = matches.get_one::("template").unwrap(); let template = crate::validate::validate_resource_name(template_raw)?; @@ -329,7 +331,7 @@ async fn handle_sanitize( let base = regional_base_url(location); let url = format!("{base}/{template}:{method_name}"); - model_armor_post(&url, &body).await + model_armor_post(&url, &body, account).await } #[derive(Debug, PartialEq)] @@ -378,7 +380,7 @@ pub fn build_create_template_url(config: &CreateTemplateConfig) -> String { } /// Handle +create-template -async fn handle_create_template(matches: &ArgMatches) -> Result<(), GwsError> { +async fn handle_create_template(matches: &ArgMatches, account: Option<&str>) -> Result<(), GwsError> { let config = parse_create_template_args(matches)?; let url = build_create_template_url(&config); @@ -391,7 +393,7 @@ async fn handle_create_template(matches: &ArgMatches) -> Result<(), GwsError> { .unwrap_or("jailbreak") ); - model_armor_post(&url, &config.body).await + model_armor_post(&url, &config.body, account).await } /// Loads a preset template JSON file from the templates/modelarmor/ directory. diff --git a/src/helpers/script.rs b/src/helpers/script.rs index b0ad3497..53ea1566 100644 --- a/src/helpers/script.rs +++ b/src/helpers/script.rs @@ -103,7 +103,8 @@ TIPS: let body_str = body.to_string(); let scopes: Vec<&str> = update_method.scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scopes).await { + let account = matches.get_one::("account"); + let (token, auth_method) = match auth::get_token(&scopes, account.map(|s| s.as_str())).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(_) => (None, executor::AuthMethod::None), }; @@ -129,6 +130,7 @@ TIPS: &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + account.map(|s| s.as_str()), ) .await?; diff --git a/src/helpers/sheets.rs b/src/helpers/sheets.rs index 76f36ab2..7d71b0cd 100644 --- a/src/helpers/sheets.rs +++ b/src/helpers/sheets.rs @@ -106,7 +106,8 @@ TIPS: let (params_str, body_str, scopes) = build_append_request(&config, doc)?; let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scope_strs).await { + let account = matches.get_one::("account"); + let (token, auth_method) = match auth::get_token(&scope_strs, account.map(|s| s.as_str())).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(_) => (None, executor::AuthMethod::None), }; @@ -143,6 +144,7 @@ TIPS: &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + account.map(|s| s.as_str()), ) .await?; @@ -165,7 +167,8 @@ TIPS: })?; let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scope_strs).await { + let account = matches.get_one::("account"); + let (token, auth_method) = match auth::get_token(&scope_strs, account.map(|s| s.as_str())).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(_) => (None, executor::AuthMethod::None), }; @@ -186,6 +189,7 @@ TIPS: &crate::helpers::modelarmor::SanitizeMode::Warn, &crate::formatter::OutputFormat::default(), false, + account.map(|s| s.as_str()), ) .await?; diff --git a/src/helpers/workflows.rs b/src/helpers/workflows.rs index 48d21784..413fd024 100644 --- a/src/helpers/workflows.rs +++ b/src/helpers/workflows.rs @@ -267,16 +267,17 @@ fn format_and_print(value: &Value, matches: &ArgMatches) { } async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> { + let account = matches.get_one::("account"); let cal_scope = "https://www.googleapis.com/auth/calendar.readonly"; let tasks_scope = "https://www.googleapis.com/auth/tasks.readonly"; - let token = auth::get_token(&[cal_scope, tasks_scope]) + let token = auth::get_token(&[cal_scope, tasks_scope], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?; let client = crate::client::build_client()?; // Resolve account timezone for day boundaries - let tz = crate::timezone::resolve_account_timezone(&client, &token, None).await?; + let tz = crate::timezone::resolve_account_timezone(&client, &token, None, account.map(|s| s.as_str())).await?; let now_in_tz = chrono::Utc::now().with_timezone(&tz); let today_start_tz = crate::timezone::start_of_today(tz)?; let today_end_tz = today_start_tz + chrono::Duration::days(1); @@ -361,8 +362,9 @@ async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> { } async fn handle_meeting_prep(matches: &ArgMatches) -> Result<(), GwsError> { + let account = matches.get_one::("account"); let cal_scope = "https://www.googleapis.com/auth/calendar.readonly"; - let token = auth::get_token(&[cal_scope]) + let token = auth::get_token(&[cal_scope], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?; @@ -373,7 +375,7 @@ async fn handle_meeting_prep(matches: &ArgMatches) -> Result<(), GwsError> { .unwrap_or("primary"); // Use account timezone for current time - let tz = crate::timezone::resolve_account_timezone(&client, &token, None).await?; + let tz = crate::timezone::resolve_account_timezone(&client, &token, None, account.map(|s| s.as_str())).await?; let now_rfc = chrono::Utc::now().with_timezone(&tz).to_rfc3339(); let events_url = format!( @@ -438,9 +440,10 @@ async fn handle_meeting_prep(matches: &ArgMatches) -> Result<(), GwsError> { } async fn handle_email_to_task(matches: &ArgMatches) -> Result<(), GwsError> { + let account = matches.get_one::("account"); let gmail_scope = "https://www.googleapis.com/auth/gmail.readonly"; let tasks_scope = "https://www.googleapis.com/auth/tasks"; - let token = auth::get_token(&[gmail_scope, tasks_scope]) + let token = auth::get_token(&[gmail_scope, tasks_scope], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?; @@ -528,16 +531,17 @@ async fn handle_email_to_task(matches: &ArgMatches) -> Result<(), GwsError> { } async fn handle_weekly_digest(matches: &ArgMatches) -> Result<(), GwsError> { + let account = matches.get_one::("account"); let cal_scope = "https://www.googleapis.com/auth/calendar.readonly"; let gmail_scope = "https://www.googleapis.com/auth/gmail.readonly"; - let token = auth::get_token(&[cal_scope, gmail_scope]) + let token = auth::get_token(&[cal_scope, gmail_scope], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?; let client = crate::client::build_client()?; // Resolve account timezone for week boundaries - let tz = crate::timezone::resolve_account_timezone(&client, &token, None).await?; + let tz = crate::timezone::resolve_account_timezone(&client, &token, None, account.map(|s| s.as_str())).await?; let now_in_tz = chrono::Utc::now().with_timezone(&tz); let week_end = now_in_tz + chrono::Duration::days(7); let time_min = now_in_tz.to_rfc3339(); @@ -609,9 +613,10 @@ async fn handle_weekly_digest(matches: &ArgMatches) -> Result<(), GwsError> { } async fn handle_file_announce(matches: &ArgMatches) -> Result<(), GwsError> { + let account = matches.get_one::("account"); let drive_scope = "https://www.googleapis.com/auth/drive.readonly"; let chat_scope = "https://www.googleapis.com/auth/chat.messages.create"; - let token = auth::get_token(&[drive_scope, chat_scope]) + let token = auth::get_token(&[drive_scope, chat_scope], account.map(|s| s.as_str())) .await .map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?; diff --git a/src/main.rs b/src/main.rs index bd72c642..f61bd8f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -237,8 +237,10 @@ async fn run() -> Result<(), GwsError> { // to avoid restrictive scopes like gmail.metadata that block query parameters. let scopes: Vec<&str> = select_scope(&method.scopes).into_iter().collect(); + let account = matches.get_one::("account"); + // Authenticate: try OAuth, fail with error if credentials exist but are broken - let (token, auth_method) = match auth::get_token(&scopes).await { + let (token, auth_method) = match auth::get_token(&scopes, account.map(|s| s.as_str())).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(e) => { // If credentials were found but failed (e.g. decryption error, invalid token), @@ -271,9 +273,10 @@ async fn run() -> Result<(), GwsError> { &sanitize_config.mode, &output_format, false, + account.map(|s| s.as_str()), ) - .await - .map(|_| ()) + .await?; + Ok(()) } /// Select the best scope from a method's scope list. diff --git a/src/setup.rs b/src/setup.rs index 10aebe1b..14d257e7 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1471,7 +1471,7 @@ async fn stage_configure_oauth(ctx: &mut SetupContext) -> Result = crate::credential_store::load_encrypted() + let current_creds: Option = crate::credential_store::load_encrypted(None) .ok() .and_then(|s| serde_json::from_str(&s).ok()); @@ -1682,7 +1682,7 @@ pub async fn run_setup(args: &[String]) -> Result<(), GwsError> { eprintln!("\n✅ {message}"); if run_login { - crate::auth_commands::run_login(&[]).await?; + crate::auth_commands::run_login(&[], None).await?; } Ok(()) diff --git a/src/timezone.rs b/src/timezone.rs index b7cd6577..98a66ce7 100644 --- a/src/timezone.rs +++ b/src/timezone.rs @@ -30,15 +30,20 @@ const CACHE_FILENAME: &str = "account_timezone"; /// Cache TTL in seconds (24 hours). const CACHE_TTL_SECS: u64 = 86400; -/// Returns the path to the timezone cache file. -fn cache_path() -> PathBuf { - crate::auth_commands::config_dir().join(CACHE_FILENAME) +/// Returns the path to the timezone cache file for a specific account. +fn cache_path(account: Option<&str>) -> PathBuf { + let filename = if let Some(acc) = account { + format!("{}.{}", CACHE_FILENAME, acc) + } else { + CACHE_FILENAME.to_string() + }; + crate::auth_commands::config_dir().join(filename) } /// Remove the cached timezone file. Called on auth login/logout to /// invalidate stale values when the account changes. -pub fn invalidate_cache() { - let path = cache_path(); +pub fn invalidate_cache(account: Option<&str>) { + let path = cache_path(account); if let Err(e) = std::fs::remove_file(&path) { if e.kind() != std::io::ErrorKind::NotFound { tracing::warn!(path = %path.display(), error = %e, "failed to invalidate timezone cache"); @@ -47,8 +52,8 @@ pub fn invalidate_cache() { } /// Read the cached timezone if it exists and is fresh (< 24h old). -fn read_cache() -> Option { - let path = cache_path(); +fn read_cache(account: Option<&str>) -> Option { + let path = cache_path(account); let metadata = std::fs::metadata(&path).ok()?; let modified = metadata.modified().ok()?; let age = std::time::SystemTime::now().duration_since(modified).ok()?; @@ -61,8 +66,8 @@ fn read_cache() -> Option { } /// Write a timezone name to the cache file. -fn write_cache(tz_name: &str) { - let path = cache_path(); +fn write_cache(tz_name: &str, account: Option<&str>) { + let path = cache_path(account); if let Some(parent) = path.parent() { if let Err(e) = std::fs::create_dir_all(parent) { tracing::warn!(path = %parent.display(), error = %e, "failed to create timezone cache directory"); @@ -75,7 +80,7 @@ fn write_cache(tz_name: &str) { } /// Fetch the account timezone from the Google Calendar Settings API. -async fn fetch_account_timezone(client: &reqwest::Client, token: &str) -> Result { +async fn fetch_account_timezone(client: &reqwest::Client, token: &str, account: Option<&str>) -> Result { let url = "https://www.googleapis.com/calendar/v3/users/me/settings/timezone"; let resp = client .get(url) @@ -117,7 +122,7 @@ async fn fetch_account_timezone(client: &reqwest::Client, token: &str) -> Result })?; // Cache for future use - write_cache(tz_name); + write_cache(tz_name, account); tracing::info!( timezone = tz_name, source = "calendar_api", @@ -145,6 +150,7 @@ pub async fn resolve_account_timezone( client: &reqwest::Client, token: &str, tz_override: Option<&str>, + account: Option<&str>, ) -> Result { // 1. Explicit override — fail if invalid if let Some(tz_str) = tz_override { @@ -158,13 +164,13 @@ pub async fn resolve_account_timezone( } // 2. Check cache - if let Some(tz) = read_cache() { + if let Some(tz) = read_cache(account) { tracing::debug!(timezone = %tz, source = "cache", "using cached timezone"); return Ok(tz); } // 3. Fetch from Calendar Settings API - match fetch_account_timezone(client, token).await { + match fetch_account_timezone(client, token, account).await { Ok(tz) => return Ok(tz), Err(e) => { tracing::warn!(error = %e, "failed to fetch account timezone, falling back to local"); From fdd4010144c864938319dfab48b1a0e49e36fd79 Mon Sep 17 00:00:00 2001 From: dumko2001 Date: Sun, 15 Mar 2026 13:32:22 +0530 Subject: [PATCH 3/7] fix(auth): resolve account isolation for plaintext credentials and timezone cache - Ensure plaintext credentials use account-specific paths (src/auth.rs, src/auth_commands.rs) - Refactor path helpers into src/credential_store.rs for consistency - Update timezone::invalidate_cache to clear both account and default caches - Fix clippy warnings and format code - Address review comments on PR #496 --- src/auth.rs | 3 +- src/auth_commands.rs | 102 ++++++++++++++++++++----------------- src/credential_store.rs | 33 ++++++++++++ src/executor.rs | 3 +- src/helpers/calendar.rs | 17 +++++-- src/helpers/chat.rs | 9 ++-- src/helpers/docs.rs | 9 ++-- src/helpers/drive.rs | 9 ++-- src/helpers/gmail/mod.rs | 2 +- src/helpers/gmail/watch.rs | 8 ++- src/helpers/modelarmor.rs | 27 ++++++++-- src/helpers/script.rs | 9 ++-- src/helpers/sheets.rs | 18 ++++--- src/helpers/workflows.rs | 24 +++++++-- src/setup.rs | 7 +-- src/setup_tui.rs | 2 +- src/timezone.rs | 20 +++++++- 17 files changed, 207 insertions(+), 95 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 4fac4941..4b07cb66 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -263,9 +263,8 @@ async fn load_credentials_inner( env_file: Option<&str>, account: Option<&str>, ) -> anyhow::Result { - let config_dir = crate::auth_commands::config_dir(); let enc_path = credential_store::encrypted_credentials_path(account); - let default_path = config_dir.join("credentials.json"); + let default_path = credential_store::plain_credentials_path(account); // 1. Explicit env var — plaintext file (User or Service Account) if let Some(path) = env_file { let p = PathBuf::from(path); diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 36853f58..5b6bb656 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -17,7 +17,9 @@ use std::path::PathBuf; use serde_json::json; -use crate::credential_store; +use crate::credential_store::{ + self, plain_credentials_path, sa_token_cache_path, token_cache_path, +}; use crate::error::GwsError; /// Mask a secret string by showing only the first 4 and last 4 characters. @@ -117,36 +119,6 @@ pub fn config_dir() -> PathBuf { primary } -fn plain_credentials_path(account: Option<&str>) -> PathBuf { - if let Ok(path) = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE") { - return PathBuf::from(path); - } - let filename = if let Some(acc) = account { - format!("credentials.{acc}.json") - } else { - "credentials.json".to_string() - }; - config_dir().join(filename) -} - -fn token_cache_path(account: Option<&str>) -> PathBuf { - let filename = if let Some(acc) = account { - format!("token_cache.{acc}.json") - } else { - "token_cache.json".to_string() - }; - config_dir().join(filename) -} - -fn sa_token_cache_path(account: Option<&str>) -> PathBuf { - let filename = if let Some(acc) = account { - format!("sa_token_cache.{acc}.json") - } else { - "sa_token_cache.json".to_string() - }; - config_dir().join(filename) -} - /// Handle `gws auth `. pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { use clap::{Arg, Command}; @@ -165,26 +137,60 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { .subcommand( Command::new("login") .about("Authenticate via OAuth2 (opens browser)") - .arg(Arg::new("readonly").long("readonly").action(clap::ArgAction::SetTrue).help("Request read-only scopes")) - .arg(Arg::new("full").long("full").action(clap::ArgAction::SetTrue).help("Request all scopes incl. pubsub + cloud-platform")) - .arg(Arg::new("scopes").long("scopes").help("Comma-separated custom scopes")) - .arg(Arg::new("services").long("services").short('s').help("Comma-separated service names to limit scope picker")) + .arg( + Arg::new("readonly") + .long("readonly") + .action(clap::ArgAction::SetTrue) + .help("Request read-only scopes"), + ) + .arg( + Arg::new("full") + .long("full") + .action(clap::ArgAction::SetTrue) + .help("Request all scopes incl. pubsub + cloud-platform"), + ) + .arg( + Arg::new("scopes") + .long("scopes") + .help("Comma-separated custom scopes"), + ) + .arg( + Arg::new("services") + .long("services") + .short('s') + .help("Comma-separated service names to limit scope picker"), + ), ) .subcommand( Command::new("setup") .about("Configure GCP project + OAuth client (requires gcloud)") - .arg(Arg::new("project").long("project").help("Use a specific GCP project")) - .arg(Arg::new("login").long("login").action(clap::ArgAction::SetTrue).help("Run 'gws auth login' after successful setup")) + .arg( + Arg::new("project") + .long("project") + .help("Use a specific GCP project"), + ) + .arg( + Arg::new("login") + .long("login") + .action(clap::ArgAction::SetTrue) + .help("Run 'gws auth login' after successful setup"), + ), ) .subcommand(Command::new("status").about("Show current authentication state")) .subcommand( Command::new("export") .about("Print decrypted credentials to stdout") - .arg(Arg::new("unmasked").long("unmasked").action(clap::ArgAction::SetTrue).help("Print raw secrets (CAUTION)")) + .arg( + Arg::new("unmasked") + .long("unmasked") + .action(clap::ArgAction::SetTrue) + .help("Print raw secrets (CAUTION)"), + ), ) .subcommand(Command::new("logout").about("Clear saved credentials and token cache")); - let matches = cmd.try_get_matches_from(std::iter::once("gws auth").chain(args.iter().map(|s| s.as_str()))) + let matches = cmd + .try_get_matches_from(std::iter::once("gws auth").chain(args.iter().map(|s| s.as_str()))) .map_err(|e| GwsError::Validation(e.to_string()))?; let account = matches.get_one::("account").map(|s| s.as_str()); @@ -192,8 +198,12 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { match matches.subcommand() { Some(("login", sub)) => { let mut login_args = Vec::new(); - if sub.get_flag("readonly") { login_args.push("--readonly".to_string()); } - if sub.get_flag("full") { login_args.push("--full".to_string()); } + if sub.get_flag("readonly") { + login_args.push("--readonly".to_string()); + } + if sub.get_flag("full") { + login_args.push("--full".to_string()); + } if let Some(s) = sub.get_one::("scopes") { login_args.push("--scopes".to_string()); login_args.push(s.clone()); @@ -1127,13 +1137,10 @@ async fn handle_status(account: Option<&str>) -> Result<(), GwsError> { // If we have credentials, try to get live info (user, scopes, APIs) // Skip all network calls and subprocess spawning in test builds if !cfg!(test) { - let enc_path = credential_store::encrypted_credentials_path(account); - let default_path = config_dir().join("credentials.json"); - - let creds_json_str = if enc_path.exists() { + let creds_json_str = if has_encrypted { credential_store::load_encrypted(account).ok() - } else if default_path.exists() { - tokio::fs::read_to_string(&default_path).await.ok() + } else if has_plain { + tokio::fs::read_to_string(&plain_path).await.ok() } else { None }; @@ -1240,7 +1247,6 @@ fn handle_logout(account: Option<&str>) -> Result<(), GwsError> { let token_cache = token_cache_path(account); let sa_token_cache = sa_token_cache_path(account); - let mut removed = Vec::new(); for path in [&enc_path, &plain_path, &token_cache, &sa_token_cache] { diff --git a/src/credential_store.rs b/src/credential_store.rs index 0782f995..8c14a52d 100644 --- a/src/credential_store.rs +++ b/src/credential_store.rs @@ -380,6 +380,39 @@ pub fn encrypted_credentials_path(account: Option<&str>) -> PathBuf { crate::auth_commands::config_dir().join(filename) } +/// Returns the path for plaintext credentials. +pub fn plain_credentials_path(account: Option<&str>) -> PathBuf { + if let Ok(path) = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE") { + return PathBuf::from(path); + } + let filename = if let Some(acc) = account { + format!("credentials.{acc}.json") + } else { + "credentials.json".to_string() + }; + crate::auth_commands::config_dir().join(filename) +} + +/// Returns the path for the user token cache. +pub fn token_cache_path(account: Option<&str>) -> PathBuf { + let filename = if let Some(acc) = account { + format!("token_cache.{acc}.json") + } else { + "token_cache.json".to_string() + }; + crate::auth_commands::config_dir().join(filename) +} + +/// Returns the path for the service account token cache. +pub fn sa_token_cache_path(account: Option<&str>) -> PathBuf { + let filename = if let Some(acc) = account { + format!("sa_token_cache.{acc}.json") + } else { + "sa_token_cache.json".to_string() + }; + crate::auth_commands::config_dir().join(filename) +} + /// Saves credentials JSON to an encrypted file. pub fn save_encrypted(json: &str, account: Option<&str>) -> anyhow::Result { let path = encrypted_credentials_path(account); diff --git a/src/executor.rs b/src/executor.rs index 43ed12b9..c252462e 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -232,7 +232,8 @@ async fn handle_json_response( // Run Model Armor sanitization if --sanitize is enabled if let Some(template) = sanitize_template { let text_to_check = serde_json::to_string(&json_val).unwrap_or_default(); - match crate::helpers::modelarmor::sanitize_text(template, &text_to_check, account).await { + match crate::helpers::modelarmor::sanitize_text(template, &text_to_check, account).await + { Ok(result) => { let is_match = result.filter_match_state == "MATCH_FOUND"; if is_match { diff --git a/src/helpers/calendar.rs b/src/helpers/calendar.rs index e9567b78..939647ad 100644 --- a/src/helpers/calendar.rs +++ b/src/helpers/calendar.rs @@ -159,10 +159,11 @@ TIPS: let account = matches.get_one::("account"); let scopes_str: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scopes_str, account.map(|s| s.as_str())).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), - }; + let (token, auth_method) = + match auth::get_token(&scopes_str, account.map(|s| s.as_str())).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(_) => (None, executor::AuthMethod::None), + }; let events_res = doc.resources.get("events").ok_or_else(|| { GwsError::Discovery("Resource 'events' not found".to_string()) @@ -215,7 +216,13 @@ async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> { let client = crate::client::build_client()?; let tz_override = matches.get_one::("timezone").map(|s| s.as_str()); - let tz = crate::timezone::resolve_account_timezone(&client, &token, tz_override, account.map(|s| s.as_str())).await?; + let tz = crate::timezone::resolve_account_timezone( + &client, + &token, + tz_override, + account.map(|s| s.as_str()), + ) + .await?; // Determine time range using the account timezone so that --today and // --tomorrow align with the user's Google account day, not the machine. diff --git a/src/helpers/chat.rs b/src/helpers/chat.rs index 0717561d..1fa5a2b7 100644 --- a/src/helpers/chat.rs +++ b/src/helpers/chat.rs @@ -79,10 +79,11 @@ TIPS: let account = matches.get_one::("account"); let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scope_strs, account.map(|s| s.as_str())).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), - }; + let (token, auth_method) = + match auth::get_token(&scope_strs, account.map(|s| s.as_str())).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(_) => (None, executor::AuthMethod::None), + }; // Method: spaces.messages.create let spaces_res = doc.resources.get("spaces").ok_or_else(|| { diff --git a/src/helpers/docs.rs b/src/helpers/docs.rs index 02d02850..5194619c 100644 --- a/src/helpers/docs.rs +++ b/src/helpers/docs.rs @@ -71,10 +71,11 @@ TIPS: let account = matches.get_one::("account"); let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scope_strs, account.map(|s| s.as_str())).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), - }; + let (token, auth_method) = + match auth::get_token(&scope_strs, account.map(|s| s.as_str())).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(_) => (None, executor::AuthMethod::None), + }; // Method: documents.batchUpdate let documents_res = doc.resources.get("documents").ok_or_else(|| { diff --git a/src/helpers/drive.rs b/src/helpers/drive.rs index 6c8d78cf..781ec3b7 100644 --- a/src/helpers/drive.rs +++ b/src/helpers/drive.rs @@ -97,10 +97,11 @@ TIPS: let scopes: Vec<&str> = create_method.scopes.iter().map(|s| s.as_str()).collect(); let account = matches.get_one::("account"); - let (token, auth_method) = match auth::get_token(&scopes, account.map(|s| s.as_str())).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), - }; + let (token, auth_method) = + match auth::get_token(&scopes, account.map(|s| s.as_str())).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(_) => (None, executor::AuthMethod::None), + }; executor::execute_method( doc, diff --git a/src/helpers/gmail/mod.rs b/src/helpers/gmail/mod.rs index 4e24c946..df7ae74d 100644 --- a/src/helpers/gmail/mod.rs +++ b/src/helpers/gmail/mod.rs @@ -650,7 +650,7 @@ pub(super) async fn send_raw_email( Some(t) => (Some(t.to_string()), executor::AuthMethod::OAuth), None => { let scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect(); - let scope_strs: Vec<&str> = scopes.iter().copied().collect(); + let scope_strs: Vec<&str> = scopes.to_vec(); match auth::get_token(&scope_strs, account.map(|s| s.as_str())).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None), diff --git a/src/helpers/gmail/watch.rs b/src/helpers/gmail/watch.rs index 856edebb..b0939a06 100644 --- a/src/helpers/gmail/watch.rs +++ b/src/helpers/gmail/watch.rs @@ -395,6 +395,7 @@ fn process_pull_response(response: &Value) -> (Vec, u64) { } /// Fetches new messages since `start_history_id` and outputs them as NDJSON. +#[allow(clippy::too_many_arguments)] async fn fetch_and_output_messages( client: &reqwest::Client, gmail_token_provider: &dyn auth::AccessTokenProvider, @@ -441,7 +442,12 @@ async fn fetch_and_output_messages( // Apply sanitization if configured if let Some(ref template) = sanitize_config.template { let text_to_check = serde_json::to_string(&full_msg).unwrap_or_default(); - match crate::helpers::modelarmor::sanitize_text(template, &text_to_check, runtime.account.as_deref()).await + match crate::helpers::modelarmor::sanitize_text( + template, + &text_to_check, + runtime.account.as_deref(), + ) + .await { Ok(result) => { if let Some(sanitized_msg) = apply_sanitization_result( diff --git a/src/helpers/modelarmor.rs b/src/helpers/modelarmor.rs index 1262a649..9e27d89a 100644 --- a/src/helpers/modelarmor.rs +++ b/src/helpers/modelarmor.rs @@ -228,11 +228,23 @@ TIPS: Box::pin(async move { let account = matches.get_one::("account"); if let Some(sub) = matches.subcommand_matches("+sanitize-prompt") { - handle_sanitize(sub, "sanitizeUserPrompt", "userPromptData", account.map(|s| s.as_str())).await?; + handle_sanitize( + sub, + "sanitizeUserPrompt", + "userPromptData", + account.map(|s| s.as_str()), + ) + .await?; return Ok(true); } if let Some(sub) = matches.subcommand_matches("+sanitize-response") { - handle_sanitize(sub, "sanitizeModelResponse", "modelResponseData", account.map(|s| s.as_str())).await?; + handle_sanitize( + sub, + "sanitizeModelResponse", + "modelResponseData", + account.map(|s| s.as_str()), + ) + .await?; return Ok(true); } if let Some(sub) = matches.subcommand_matches("+create-template") { @@ -248,7 +260,11 @@ pub const CLOUD_PLATFORM_SCOPE: &str = "https://www.googleapis.com/auth/cloud-pl /// Sanitize text through a Model Armor template and return the result. /// Template format: projects/PROJECT/locations/LOCATION/templates/TEMPLATE -pub async fn sanitize_text(template: &str, text: &str, account: Option<&str>) -> Result { +pub async fn sanitize_text( + template: &str, + text: &str, + account: Option<&str>, +) -> Result { let (body, url) = build_sanitize_request_data(template, text, "sanitizeUserPrompt")?; let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE], account) @@ -380,7 +396,10 @@ pub fn build_create_template_url(config: &CreateTemplateConfig) -> String { } /// Handle +create-template -async fn handle_create_template(matches: &ArgMatches, account: Option<&str>) -> Result<(), GwsError> { +async fn handle_create_template( + matches: &ArgMatches, + account: Option<&str>, +) -> Result<(), GwsError> { let config = parse_create_template_args(matches)?; let url = build_create_template_url(&config); diff --git a/src/helpers/script.rs b/src/helpers/script.rs index 53ea1566..61be9c82 100644 --- a/src/helpers/script.rs +++ b/src/helpers/script.rs @@ -104,10 +104,11 @@ TIPS: let scopes: Vec<&str> = update_method.scopes.iter().map(|s| s.as_str()).collect(); let account = matches.get_one::("account"); - let (token, auth_method) = match auth::get_token(&scopes, account.map(|s| s.as_str())).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), - }; + let (token, auth_method) = + match auth::get_token(&scopes, account.map(|s| s.as_str())).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(_) => (None, executor::AuthMethod::None), + }; let params = json!({ "scriptId": script_id diff --git a/src/helpers/sheets.rs b/src/helpers/sheets.rs index 7d71b0cd..a3c36e75 100644 --- a/src/helpers/sheets.rs +++ b/src/helpers/sheets.rs @@ -107,10 +107,11 @@ TIPS: let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); let account = matches.get_one::("account"); - let (token, auth_method) = match auth::get_token(&scope_strs, account.map(|s| s.as_str())).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), - }; + let (token, auth_method) = + match auth::get_token(&scope_strs, account.map(|s| s.as_str())).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(_) => (None, executor::AuthMethod::None), + }; let spreadsheets_res = doc.resources.get("spreadsheets").ok_or_else(|| { GwsError::Discovery("Resource 'spreadsheets' not found".to_string()) @@ -168,10 +169,11 @@ TIPS: let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); let account = matches.get_one::("account"); - let (token, auth_method) = match auth::get_token(&scope_strs, account.map(|s| s.as_str())).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), - }; + let (token, auth_method) = + match auth::get_token(&scope_strs, account.map(|s| s.as_str())).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(_) => (None, executor::AuthMethod::None), + }; executor::execute_method( doc, diff --git a/src/helpers/workflows.rs b/src/helpers/workflows.rs index 413fd024..b662eaf2 100644 --- a/src/helpers/workflows.rs +++ b/src/helpers/workflows.rs @@ -277,7 +277,13 @@ async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> { let client = crate::client::build_client()?; // Resolve account timezone for day boundaries - let tz = crate::timezone::resolve_account_timezone(&client, &token, None, account.map(|s| s.as_str())).await?; + let tz = crate::timezone::resolve_account_timezone( + &client, + &token, + None, + account.map(|s| s.as_str()), + ) + .await?; let now_in_tz = chrono::Utc::now().with_timezone(&tz); let today_start_tz = crate::timezone::start_of_today(tz)?; let today_end_tz = today_start_tz + chrono::Duration::days(1); @@ -375,7 +381,13 @@ async fn handle_meeting_prep(matches: &ArgMatches) -> Result<(), GwsError> { .unwrap_or("primary"); // Use account timezone for current time - let tz = crate::timezone::resolve_account_timezone(&client, &token, None, account.map(|s| s.as_str())).await?; + let tz = crate::timezone::resolve_account_timezone( + &client, + &token, + None, + account.map(|s| s.as_str()), + ) + .await?; let now_rfc = chrono::Utc::now().with_timezone(&tz).to_rfc3339(); let events_url = format!( @@ -541,7 +553,13 @@ async fn handle_weekly_digest(matches: &ArgMatches) -> Result<(), GwsError> { let client = crate::client::build_client()?; // Resolve account timezone for week boundaries - let tz = crate::timezone::resolve_account_timezone(&client, &token, None, account.map(|s| s.as_str())).await?; + let tz = crate::timezone::resolve_account_timezone( + &client, + &token, + None, + account.map(|s| s.as_str()), + ) + .await?; let now_in_tz = chrono::Utc::now().with_timezone(&tz); let week_end = now_in_tz + chrono::Duration::days(7); let time_min = now_in_tz.to_rfc3339(); diff --git a/src/setup.rs b/src/setup.rs index 14d257e7..aefd8dae 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1471,9 +1471,10 @@ async fn stage_configure_oauth(ctx: &mut SetupContext) -> Result = crate::credential_store::load_encrypted(None) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()); + let current_creds: Option = + crate::credential_store::load_encrypted(None) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()); w.show_message(&format!( concat!( diff --git a/src/setup_tui.rs b/src/setup_tui.rs index 204d99db..75087cf9 100644 --- a/src/setup_tui.rs +++ b/src/setup_tui.rs @@ -23,6 +23,7 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, ExecutableCommand, }; +use ratatui::prelude::Stylize; use ratatui::{ layout::{Constraint, Layout}, style::{Color, Modifier, Style}, @@ -30,7 +31,6 @@ use ratatui::{ widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, DefaultTerminal, }; -use ratatui::prelude::Stylize; use std::io::stdout; /// An item in the multi-select list. diff --git a/src/timezone.rs b/src/timezone.rs index 98a66ce7..3d7f19b2 100644 --- a/src/timezone.rs +++ b/src/timezone.rs @@ -43,10 +43,22 @@ fn cache_path(account: Option<&str>) -> PathBuf { /// Remove the cached timezone file. Called on auth login/logout to /// invalidate stale values when the account changes. pub fn invalidate_cache(account: Option<&str>) { + // 1. Invalidate account-specific cache let path = cache_path(account); if let Err(e) = std::fs::remove_file(&path) { if e.kind() != std::io::ErrorKind::NotFound { - tracing::warn!(path = %path.display(), error = %e, "failed to invalidate timezone cache"); + tracing::warn!(path = %path.display(), error = %e, "failed to invalidate account-specific timezone cache"); + } + } + + // 2. ALSO invalidate default cache if an account was provided, + // because it might have been used as a fallback or be stale. + if account.is_some() { + let default_path = cache_path(None); + if let Err(e) = std::fs::remove_file(&default_path) { + if e.kind() != std::io::ErrorKind::NotFound { + tracing::warn!(path = %default_path.display(), error = %e, "failed to invalidate default timezone cache"); + } } } } @@ -80,7 +92,11 @@ fn write_cache(tz_name: &str, account: Option<&str>) { } /// Fetch the account timezone from the Google Calendar Settings API. -async fn fetch_account_timezone(client: &reqwest::Client, token: &str, account: Option<&str>) -> Result { +async fn fetch_account_timezone( + client: &reqwest::Client, + token: &str, + account: Option<&str>, +) -> Result { let url = "https://www.googleapis.com/calendar/v3/users/me/settings/timezone"; let resp = client .get(url) From 6b33b3c8444788ba0d83c49d3a46c4ee274db39c Mon Sep 17 00:00:00 2001 From: dumko2001 Date: Sun, 15 Mar 2026 13:36:36 +0530 Subject: [PATCH 4/7] security(auth): prevent path traversal in account names and fix isolation issues - Add value_parser to account argument to restrict allowed characters - Ensure account-specific paths are used consistently for plaintext credentials - Plumb account parameter down to Gmail watch sanitization - Fully implement account-aware timezone cache invalidation - Address all high-priority bot review comments on PR #496 --- src/commands.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/commands.rs b/src/commands.rs index 98156e19..00d51498 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -53,7 +53,32 @@ pub fn build_cli(doc: &RestDescription) -> Command { .short('A') .help("Google account name/alias to use for authentication (e.g. 'work', 'personal')") .value_name("NAME") - .global(true), + .global(true) + .value_parser(|name: &str| -> Result { + const MAX_LEN: usize = 63; + if name.is_empty() { + return Err("Account name cannot be empty.".to_string()); + } + if name.len() > MAX_LEN { + return Err(format!( + "Account name cannot be longer than {} characters.", + MAX_LEN + )); + } + if !name + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + { + return Err( + "Account name must only contain alphanumeric characters, dashes, and underscores." + .to_string(), + ); + } + if name == "." || name == ".." { + return Err("Account name cannot be '.' or '..'.".to_string()); + } + Ok(name.to_string()) + }), ); // Inject helper commands From 38d475baa2c5a9458b8e81c0f11afc895a4da2e3 Mon Sep 17 00:00:00 2001 From: dumko2001 Date: Sun, 15 Mar 2026 13:53:08 +0530 Subject: [PATCH 5/7] fix(auth): rigorous audit fixes for multi-account isolation and test flakiness - Fixed missing account isolation for token caches in `get_token` - Fixed stale token cache deletion to use account-aware file paths - Plumbed `--account` through `gws auth setup` to properly isolate post-setup `login` attempts - Isolated the temporary OAuth credentials file during `login` to prevent concurrent TOCTOU race conditions between different accounts - Added `#[serial_test::serial]` to all environment-mutating async tests in `auth.rs` to fix random parallel CI failures --- src/auth.rs | 30 ++++++++++++------------------ src/auth_commands.rs | 9 +++++++-- src/setup.rs | 4 ++-- 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 4b07cb66..500e195d 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -166,29 +166,22 @@ pub async fn get_token(scopes: &[&str], account: Option<&str>) -> anyhow::Result } let creds_file = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE").ok(); - let config_dir = crate::auth_commands::config_dir(); - - let tc_filename = if let Some(acc) = account { - format!("token_cache.{acc}.json") - } else { - "token_cache.json".to_string() - }; - let token_cache = config_dir.join(tc_filename); let creds = load_credentials_inner(creds_file.as_deref(), account).await?; - get_token_inner(scopes, creds, &token_cache).await + get_token_inner(scopes, creds, account).await } async fn get_token_inner( scopes: &[&str], creds: Credential, - token_cache_path: &std::path::Path, + account: Option<&str>, ) -> anyhow::Result { match creds { Credential::AuthorizedUser(secret) => { + let token_cache_path = crate::credential_store::token_cache_path(account); let auth = yup_oauth2::AuthorizedUserAuthenticator::builder(secret) .with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new( - token_cache_path.to_path_buf(), + token_cache_path, ))) .build() .await @@ -201,11 +194,7 @@ async fn get_token_inner( .to_string()) } Credential::ServiceAccount(key) => { - let tc_filename = token_cache_path - .file_name() - .map(|f| f.to_string_lossy().to_string()) - .unwrap_or_else(|| "token_cache.json".to_string()); - let sa_cache = token_cache_path.with_file_name(format!("sa_{tc_filename}")); + let sa_cache = crate::credential_store::sa_token_cache_path(account); let builder = yup_oauth2::ServiceAccountAuthenticator::builder(key).with_storage( Box::new(crate::token_storage::EncryptedTokenStorage::new(sa_cache)), ); @@ -301,8 +290,11 @@ async fn load_credentials_inner( ); } // Also remove stale token caches that used the old key. - for cache_file in ["token_cache.json", "sa_token_cache.json"] { - let path = enc_path.with_file_name(cache_file); + let cache_paths = [ + crate::credential_store::token_cache_path(account), + crate::credential_store::sa_token_cache_path(account), + ]; + for path in cache_paths { if let Err(err) = tokio::fs::remove_file(&path).await { if err.kind() != std::io::ErrorKind::NotFound { eprintln!( @@ -611,6 +603,7 @@ mod tests { } #[tokio::test] + #[serial_test::serial] async fn test_load_credentials_encrypted_file() { // Simulate an encrypted credentials file let json = r#"{ @@ -641,6 +634,7 @@ mod tests { } #[tokio::test] + #[serial_test::serial] async fn test_load_credentials_encrypted_takes_priority_over_default() { // Encrypted credentials should be loaded before the default plaintext path let enc_json = r#"{ diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 5b6bb656..6ad3f2bd 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -223,7 +223,7 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { if sub.get_flag("login") { setup_args.push("--login".to_string()); } - crate::setup::run_setup(&setup_args).await + crate::setup::run_setup(&setup_args, account).await } Some(("status", _)) => handle_status(account).await, Some(("export", sub)) => { @@ -352,7 +352,12 @@ async fn handle_login(args: &[String], account: Option<&str>) -> Result<(), GwsE } // Use a temp file for yup-oauth2's token persistence, then encrypt it - let temp_path = config_dir().join("credentials.tmp"); + let temp_filename = if let Some(acc) = account { + format!("credentials.{acc}.tmp") + } else { + "credentials.tmp".to_string() + }; + let temp_path = config_dir().join(temp_filename); // Always start fresh — delete any stale temp cache from prior login attempts. let _ = std::fs::remove_file(&temp_path); diff --git a/src/setup.rs b/src/setup.rs index aefd8dae..058ed8d8 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1598,7 +1598,7 @@ fn prompt_login_after_setup() -> Result { } /// Run the full setup flow. Orchestrates all steps and outputs JSON summary. -pub async fn run_setup(args: &[String]) -> Result<(), GwsError> { +pub async fn run_setup(args: &[String], account: Option<&str>) -> Result<(), GwsError> { let opts = parse_setup_args(args); let dry_run = opts.dry_run; let interactive = std::io::IsTerminal::is_terminal(&std::io::stdin()) && !dry_run; @@ -1683,7 +1683,7 @@ pub async fn run_setup(args: &[String]) -> Result<(), GwsError> { eprintln!("\n✅ {message}"); if run_login { - crate::auth_commands::run_login(&[], None).await?; + crate::auth_commands::run_login(&[], account).await?; } Ok(()) From 1251a2d37fe423bea9640535f4bceadaffe11e57 Mon Sep 17 00:00:00 2001 From: dumko2001 Date: Sun, 15 Mar 2026 14:33:38 +0530 Subject: [PATCH 6/7] refactor(auth): remove redundant argument parsing in downstream functions - Introduced and structs to hold parsed arguments - Refactored , , and to accept these structs instead of - Removed manual command-line parsing logic in favor of -populated data structures - Updated tests and internal callers to use the new structured data flow --- src/auth_commands.rs | 220 ++++++++++++++++++++----------------------- src/setup.rs | 101 ++------------------ 2 files changed, 111 insertions(+), 210 deletions(-) diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 6ad3f2bd..0ce18c53 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -174,6 +174,12 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { .long("login") .action(clap::ArgAction::SetTrue) .help("Run 'gws auth login' after successful setup"), + ) + .arg( + Arg::new("dry-run") + .long("dry-run") + .action(clap::ArgAction::SetTrue) + .help("Show what would be done without making changes"), ), ) .subcommand(Command::new("status").about("Show current authentication state")) @@ -197,33 +203,32 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { match matches.subcommand() { Some(("login", sub)) => { - let mut login_args = Vec::new(); - if sub.get_flag("readonly") { - login_args.push("--readonly".to_string()); - } - if sub.get_flag("full") { - login_args.push("--full".to_string()); - } + let mut opts = LoginOptions { + readonly: sub.get_flag("readonly"), + full: sub.get_flag("full"), + ..Default::default() + }; if let Some(s) = sub.get_one::("scopes") { - login_args.push("--scopes".to_string()); - login_args.push(s.clone()); + opts.custom_scopes = Some(s.split(',').map(|s| s.trim().to_string()).collect()); } if let Some(s) = sub.get_one::("services") { - login_args.push("--services".to_string()); - login_args.push(s.clone()); + opts.services_filter = Some( + s.split(',') + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect(), + ); } - run_login(&login_args, account).await + run_login(opts, account).await } Some(("setup", sub)) => { - let mut setup_args = Vec::new(); - if let Some(p) = sub.get_one::("project") { - setup_args.push("--project".to_string()); - setup_args.push(p.clone()); - } - if sub.get_flag("login") { - setup_args.push("--login".to_string()); - } - crate::setup::run_setup(&setup_args, account).await + let opts = crate::setup::SetupOptions { + project: sub.get_one::("project").cloned(), + dry_run: sub.get_flag("dry-run"), + login: sub.get_flag("login"), + account: account.map(|s| s.to_string()), + }; + crate::setup::run_setup(opts).await } Some(("status", _)) => handle_status(account).await, Some(("export", sub)) => { @@ -235,11 +240,20 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { } } +/// Options for the login command. +#[derive(Default, Clone)] +pub struct LoginOptions { + pub readonly: bool, + pub full: bool, + pub custom_scopes: Option>, + pub services_filter: Option>, +} + /// Run the `auth login` flow. /// /// Exposed for internal orchestration (e.g. `auth setup --login`). -pub async fn run_login(args: &[String], account: Option<&str>) -> Result<(), GwsError> { - handle_login(args, account).await +pub async fn run_login(opts: LoginOptions, account: Option<&str>) -> Result<(), GwsError> { + handle_login(opts, account).await } /// Custom delegate that prints the OAuth URL on its own line for easy copying. /// Optionally includes `login_hint` in the URL for account pre-selection. @@ -277,36 +291,7 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega } } -async fn handle_login(args: &[String], account: Option<&str>) -> Result<(), GwsError> { - // Extract -s/--services from args - let mut services_filter: Option> = None; - let mut filtered_args: Vec = Vec::new(); - let mut skip_next = false; - for i in 0..args.len() { - if skip_next { - skip_next = false; - continue; - } - let services_str = if (args[i] == "-s" || args[i] == "--services") && i + 1 < args.len() { - skip_next = true; - Some(args[i + 1].as_str()) - } else { - args[i].strip_prefix("--services=") - }; - - if let Some(value) = services_str { - services_filter = Some( - value - .split(',') - .map(|s| s.trim().to_lowercase()) - .filter(|s| !s.is_empty()) - .collect(), - ); - continue; - } - filtered_args.push(args[i].clone()); - } - +async fn handle_login(opts: LoginOptions, account: Option<&str>) -> Result<(), GwsError> { // Resolve client_id and client_secret: // 1. Env vars (highest priority) // 2. Saved client_secret.json from `gws auth setup` or manual download @@ -324,9 +309,8 @@ async fn handle_login(args: &[String], account: Option<&str>) -> Result<(), GwsE // Determine scopes: explicit flags > interactive TUI > defaults let scopes = resolve_scopes( - &filtered_args, + &opts, project_id.as_deref(), - services_filter.as_ref(), ) .await; @@ -543,31 +527,24 @@ fn resolve_client_credentials() -> Result<(String, String, Option), GwsE /// When `services_filter` is `Some`, only scopes belonging to the specified /// services are shown in the picker (or returned in non-interactive mode). async fn resolve_scopes( - args: &[String], + opts: &LoginOptions, project_id: Option<&str>, - services_filter: Option<&HashSet>, ) -> Vec { // Explicit --scopes flag takes priority (bypasses services filter) - for i in 0..args.len() { - if args[i] == "--scopes" && i + 1 < args.len() { - return args[i + 1] - .split(',') - .map(|s| s.trim().to_string()) - .collect(); - } + if let Some(ref scopes) = opts.custom_scopes { + return scopes.clone(); } - let readonly_only = args.iter().any(|a| a == "--readonly"); - if readonly_only { + if opts.readonly { let scopes: Vec = READONLY_SCOPES.iter().map(|s| s.to_string()).collect(); - let mut result = filter_scopes_by_services(scopes, services_filter); - augment_with_dynamic_scopes(&mut result, services_filter, true).await; + let mut result = filter_scopes_by_services(scopes, opts.services_filter.as_ref()); + augment_with_dynamic_scopes(&mut result, opts.services_filter.as_ref(), true).await; return result; } - if args.iter().any(|a| a == "--full") { + if opts.full { let scopes: Vec = FULL_SCOPES.iter().map(|s| s.to_string()).collect(); - let mut result = filter_scopes_by_services(scopes, services_filter); - augment_with_dynamic_scopes(&mut result, services_filter, false).await; + let mut result = filter_scopes_by_services(scopes, opts.services_filter.as_ref()); + augment_with_dynamic_scopes(&mut result, opts.services_filter.as_ref(), false).await; return result; } @@ -580,7 +557,9 @@ async fn resolve_scopes( let api_ids: Vec = enabled_apis; let scopes = crate::setup::fetch_scopes_for_apis(&api_ids).await; if !scopes.is_empty() { - if let Some(selected) = run_discovery_scope_picker(&scopes, services_filter) { + if let Some(selected) = + run_discovery_scope_picker(&scopes, opts.services_filter.as_ref()) + { return selected; } } @@ -588,14 +567,14 @@ async fn resolve_scopes( } // Fallback: simple scope picker using static SCOPE_ENTRIES - if let Some(selected) = run_simple_scope_picker(services_filter) { + if let Some(selected) = run_simple_scope_picker(opts.services_filter.as_ref()) { return selected; } } let defaults: Vec = DEFAULT_SCOPES.iter().map(|s| s.to_string()).collect(); - let mut result = filter_scopes_by_services(defaults, services_filter); - augment_with_dynamic_scopes(&mut result, services_filter, false).await; + let mut result = filter_scopes_by_services(defaults, opts.services_filter.as_ref()); + augment_with_dynamic_scopes(&mut result, opts.services_filter.as_ref(), false).await; result } @@ -1517,49 +1496,51 @@ mod tests { use super::*; /// Helper to run resolve_scopes in tests (async). - fn run_resolve_scopes(args: &[String], project_id: Option<&str>) -> Vec { + fn run_resolve_scopes(opts: LoginOptions, project_id: Option<&str>) -> Vec { let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(resolve_scopes(args, project_id, None)) + rt.block_on(resolve_scopes(&opts, project_id)) } /// Helper to run resolve_scopes with a services filter. fn run_resolve_scopes_with_services( - args: &[String], + mut opts: LoginOptions, project_id: Option<&str>, services: &[&str], ) -> Vec { let filter: HashSet = services.iter().map(|s| s.to_string()).collect(); + opts.services_filter = Some(filter); let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(resolve_scopes(args, project_id, Some(&filter))) + rt.block_on(resolve_scopes(&opts, project_id)) } #[test] fn resolve_scopes_returns_defaults_when_no_flag() { - let args: Vec = vec![]; - let scopes = run_resolve_scopes(&args, None); + let scopes = run_resolve_scopes(LoginOptions::default(), None); assert_eq!(scopes.len(), DEFAULT_SCOPES.len()); assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive"); } #[test] fn resolve_scopes_returns_custom_scopes() { - let args: Vec = vec![ - "--scopes".to_string(), - "https://www.googleapis.com/auth/drive.readonly".to_string(), - ]; - let scopes = run_resolve_scopes(&args, None); + let opts = LoginOptions { + custom_scopes: Some(vec!["https://www.googleapis.com/auth/drive.readonly".to_string()]), + ..LoginOptions::default() + }; + let scopes = run_resolve_scopes(opts, None); assert_eq!(scopes.len(), 1); assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive.readonly"); } #[test] fn resolve_scopes_handles_multiple_comma_separated() { - let args: Vec = vec![ - "--scopes".to_string(), - "https://www.googleapis.com/auth/drive, https://www.googleapis.com/auth/gmail.readonly" - .to_string(), - ]; - let scopes = run_resolve_scopes(&args, None); + let opts = LoginOptions { + custom_scopes: Some(vec![ + "https://www.googleapis.com/auth/drive".to_string(), + "https://www.googleapis.com/auth/gmail.readonly".to_string(), + ]), + ..LoginOptions::default() + }; + let scopes = run_resolve_scopes(opts, None); assert_eq!(scopes.len(), 2); assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive"); assert_eq!(scopes[1], "https://www.googleapis.com/auth/gmail.readonly"); @@ -1567,16 +1548,19 @@ mod tests { #[test] fn resolve_scopes_ignores_trailing_flag() { - // --scopes with no value should use defaults - let args: Vec = vec!["--scopes".to_string()]; - let scopes = run_resolve_scopes(&args, None); + // In the refactored version, LoginOptions is pre-populated. + // If custom_scopes is None, it should use defaults. + let scopes = run_resolve_scopes(LoginOptions::default(), None); assert_eq!(scopes.len(), DEFAULT_SCOPES.len()); } #[test] fn resolve_scopes_readonly_returns_readonly_scopes() { - let args = vec!["--readonly".to_string()]; - let scopes = run_resolve_scopes(&args, None); + let opts = LoginOptions { + readonly: true, + ..LoginOptions::default() + }; + let scopes = run_resolve_scopes(opts, None); assert_eq!(scopes.len(), READONLY_SCOPES.len()); for scope in &scopes { assert!( @@ -1589,12 +1573,12 @@ mod tests { #[test] fn resolve_scopes_custom_overrides_readonly() { // --scopes takes priority over --readonly - let args = vec![ - "--scopes".to_string(), - "https://www.googleapis.com/auth/drive".to_string(), - "--readonly".to_string(), - ]; - let scopes = run_resolve_scopes(&args, None); + let opts = LoginOptions { + custom_scopes: Some(vec!["https://www.googleapis.com/auth/drive".to_string()]), + readonly: true, + ..LoginOptions::default() + }; + let scopes = run_resolve_scopes(opts, None); assert_eq!(scopes.len(), 1); assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive"); } @@ -2053,8 +2037,7 @@ mod tests { #[test] fn resolve_scopes_with_services_filter() { - let args: Vec = vec![]; - let scopes = run_resolve_scopes_with_services(&args, None, &["drive", "gmail"]); + let scopes = run_resolve_scopes_with_services(LoginOptions::default(), None, &["drive", "gmail"]); assert!(!scopes.is_empty()); for scope in &scopes { let short = scope @@ -2071,8 +2054,7 @@ mod tests { #[test] fn resolve_scopes_services_filter_unknown_service_ignored() { - let args: Vec = vec![]; - let scopes = run_resolve_scopes_with_services(&args, None, &["drive", "nonexistent"]); + let scopes = run_resolve_scopes_with_services(LoginOptions::default(), None, &["drive", "nonexistent"]); assert!(!scopes.is_empty()); // Should contain drive scope but not be affected by nonexistent assert!(scopes.iter().any(|s| s.contains("/auth/drive"))); @@ -2080,8 +2062,11 @@ mod tests { #[test] fn resolve_scopes_services_takes_priority_with_readonly() { - let args = vec!["--readonly".to_string()]; - let scopes = run_resolve_scopes_with_services(&args, None, &["drive"]); + let opts = LoginOptions { + readonly: true, + ..LoginOptions::default() + }; + let scopes = run_resolve_scopes_with_services(opts, None, &["drive"]); assert!(!scopes.is_empty()); for scope in &scopes { let short = scope @@ -2096,8 +2081,11 @@ mod tests { #[test] fn resolve_scopes_services_takes_priority_with_full() { - let args = vec!["--full".to_string()]; - let scopes = run_resolve_scopes_with_services(&args, None, &["gmail"]); + let opts = LoginOptions { + full: true, + ..LoginOptions::default() + }; + let scopes = run_resolve_scopes_with_services(opts, None, &["gmail"]); assert!(!scopes.is_empty()); for scope in &scopes { let short = scope @@ -2113,11 +2101,11 @@ mod tests { #[test] fn resolve_scopes_explicit_scopes_bypass_services_filter() { // --scopes should take priority over -s - let args = vec![ - "--scopes".to_string(), - "https://www.googleapis.com/auth/calendar".to_string(), - ]; - let scopes = run_resolve_scopes_with_services(&args, None, &["drive"]); + let opts = LoginOptions { + custom_scopes: Some(vec!["https://www.googleapis.com/auth/calendar".to_string()]), + ..LoginOptions::default() + }; + let scopes = run_resolve_scopes_with_services(opts, None, &["drive"]); assert_eq!(scopes.len(), 1); assert_eq!(scopes[0], "https://www.googleapis.com/auth/calendar"); } diff --git a/src/setup.rs b/src/setup.rs index 058ed8d8..25650972 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -374,36 +374,7 @@ pub struct SetupOptions { pub project: Option, pub dry_run: bool, pub login: bool, -} - -/// Parse setup flags from args. -pub fn parse_setup_args(args: &[String]) -> SetupOptions { - let mut project = None; - let mut dry_run = false; - let mut login = false; - let mut i = 0; - while i < args.len() { - if args[i] == "--project" && i + 1 < args.len() { - project = Some(args[i + 1].clone()); - i += 2; - } else if args[i].starts_with("--project=") { - project = Some(args[i].split_once('=').unwrap().1.to_string()); - i += 1; - } else if args[i] == "--dry-run" { - dry_run = true; - i += 1; - } else if args[i] == "--login" { - login = true; - i += 1; - } else { - i += 1; - } - } - SetupOptions { - project, - dry_run, - login, - } + pub account: Option, } // ── gcloud helpers ────────────────────────────────────────────── @@ -1598,8 +1569,7 @@ fn prompt_login_after_setup() -> Result { } /// Run the full setup flow. Orchestrates all steps and outputs JSON summary. -pub async fn run_setup(args: &[String], account: Option<&str>) -> Result<(), GwsError> { - let opts = parse_setup_args(args); +pub async fn run_setup(opts: SetupOptions) -> Result<(), GwsError> { let dry_run = opts.dry_run; let interactive = std::io::IsTerminal::is_terminal(&std::io::stdin()) && !dry_run; @@ -1620,15 +1590,15 @@ pub async fn run_setup(args: &[String], account: Option<&str>) -> Result<(), Gws wizard, interactive, dry_run, - opts, - account: String::new(), - project_id: String::new(), + account: opts.account.clone().unwrap_or_default(), + project_id: opts.project.clone().unwrap_or_default(), api_ids: Vec::new(), client_id: String::new(), client_secret: String::new(), enabled: Vec::new(), skipped: Vec::new(), failed: Vec::new(), + opts, }; let mut stage = SetupStage::CheckGcloud; @@ -1683,7 +1653,8 @@ pub async fn run_setup(args: &[String], account: Option<&str>) -> Result<(), Gws eprintln!("\n✅ {message}"); if run_login { - crate::auth_commands::run_login(&[], account).await?; + crate::auth_commands::run_login(crate::auth_commands::LoginOptions::default(), Some(&ctx.account)) + .await?; } Ok(()) @@ -1824,64 +1795,6 @@ mod tests { // ── parse_setup_args tests ────────────────────────────────── - #[test] - fn test_parse_setup_args_empty() { - let opts = parse_setup_args(&[]); - assert!(opts.project.is_none()); - assert!(!opts.dry_run); - assert!(!opts.login); - } - - #[test] - fn test_parse_setup_args_with_project() { - let args = vec!["--project".into(), "my-project".into()]; - let opts = parse_setup_args(&args); - assert_eq!(opts.project.as_deref(), Some("my-project")); - assert!(!opts.login); - } - - #[test] - fn test_parse_setup_args_with_project_equals() { - let args = vec!["--project=my-project".into()]; - let opts = parse_setup_args(&args); - assert_eq!(opts.project.as_deref(), Some("my-project")); - assert!(!opts.login); - } - - #[test] - fn test_parse_setup_args_ignores_unknown() { - let args = vec!["--verbose".into(), "--unknown".into()]; - let opts = parse_setup_args(&args); - assert!(opts.project.is_none()); - assert!(!opts.login); - } - - #[test] - fn test_parse_setup_args_dry_run() { - let args = vec!["--dry-run".into()]; - let opts = parse_setup_args(&args); - assert!(opts.dry_run); - assert!(!opts.login); - } - - #[test] - fn test_parse_setup_args_dry_run_with_project() { - let args: Vec = vec!["--dry-run".into(), "--project".into(), "p".into()]; - let opts = parse_setup_args(&args); - assert!(opts.dry_run); - assert_eq!(opts.project.as_deref(), Some("p")); - assert!(!opts.login); - } - - #[test] - fn test_parse_setup_args_login_flag() { - let args: Vec = vec!["--login".into()]; - let opts = parse_setup_args(&args); - assert!(opts.login); - assert!(!opts.dry_run); - assert!(opts.project.is_none()); - } - #[test] fn test_should_offer_login_prompt_default_interactive() { assert!(should_offer_login_prompt(true, false, false, true)); From 6fed5b88d93258d0b592ad028dd4652c03687292 Mon Sep 17 00:00:00 2001 From: dumko2001 Date: Sun, 15 Mar 2026 14:43:50 +0530 Subject: [PATCH 7/7] fix(auth): ensure account isolation during setup pre-fill and clean up idiomatic borrows - Fixed setup wizard incorrectly loading default account credentials when pre-filling manual OAuth prompts - Optimized by removing an unnecessary allocation when reading user secrets - Verified all 180+ auth/setup unit tests pass --- src/auth.rs | 2 +- src/setup.rs | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 500e195d..31bb1613 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -312,7 +312,7 @@ async fn load_credentials_inner( // 3. Plaintext credentials at default path (AuthorizedUser) if default_path.exists() { return Ok(Credential::AuthorizedUser( - yup_oauth2::read_authorized_user_secret(default_path.clone()) + yup_oauth2::read_authorized_user_secret(&default_path) .await .with_context(|| { format!("Failed to read credentials from {}", default_path.display()) diff --git a/src/setup.rs b/src/setup.rs index 25650972..a5cdfdf7 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1442,8 +1442,13 @@ async fn stage_configure_oauth(ctx: &mut SetupContext) -> Result = - crate::credential_store::load_encrypted(None) + crate::credential_store::load_encrypted(account_param) .ok() .and_then(|s| serde_json::from_str(&s).ok());