edupage-cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE +21 -0
- data/README.md +164 -69
- data/lib/edupage/account.rb +1 -1
- data/lib/edupage/cli.rb +29 -25
- data/lib/edupage/credentials/adapter.rb +34 -0
- data/lib/edupage/credentials/adapters/env.rb +36 -0
- data/lib/edupage/credentials/adapters/macos_keychain.rb +87 -0
- data/lib/edupage/credentials/adapters/secret_service.rb +60 -0
- data/lib/edupage/credentials/adapters/windows_credential_manager.rb +127 -0
- data/lib/edupage/credentials/system_adapter.rb +78 -0
- data/lib/edupage/credentials.rb +55 -50
- data/lib/edupage/version.rb +1 -1
- metadata +22 -3
- data/lib/edupage/credentials/env.rb +0 -27
- data/lib/edupage/credentials/keychain.rb +0 -120
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 121fd1c2fcc0aa186c79565930684a7bd169589616178ed763fdd742d5330926
|
|
4
|
+
data.tar.gz: 17a1e8c2205330a58eb1208e91948b445f643c4c9078b9a43e6d0e7ac1713099
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5147f956c0b5dcd81674676a3219adda32d453fda2d766ae2d3b8dd5573d809a4298a6e8c9f57ccb76e1bb4e16d5e3f44db60dd7aaf9e2cbb791f11e762f49b0
|
|
7
|
+
data.tar.gz: 19a1ec3e5a0b9e47883d5199ddbd81f0a9bf615b5618460cd601a1831a60387ca5ab44604439ff47ce4c772a27b2fb5e4a42b7b6e133947cd7d3c098f54e833e
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ahmed Al Hafoudh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
CHANGED
|
@@ -1,44 +1,117 @@
|
|
|
1
1
|
# edupage-cli
|
|
2
2
|
|
|
3
3
|
Read-only prístup k účtu na [Edupage](https://www.edupage.org) - ako Ruby knižnica, CLI,
|
|
4
|
-
REST API a MCP server. Všetky štyri
|
|
5
|
-
parity test.
|
|
4
|
+
REST API a MCP server. Všetky štyri rozhrania používajú rovnaký kód a ich zhodu
|
|
5
|
+
stráži parity test.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
Edupage nemá verejné API. Každá stránka je HTML vygenerované na serveri s JSON-om
|
|
10
|
+
vloženým do blokov `<script>`. Session si navyše interne pamätá "aktuálne dieťa"
|
|
11
|
+
a "aktuálny školský rok", ktoré treba nastaviť pred načítaním dát. Toto všetko
|
|
12
|
+
rieši knižnica za teba.
|
|
11
13
|
|
|
12
14
|
## Inštalácia
|
|
13
15
|
|
|
16
|
+
### Inštalácia jedným príkazom
|
|
17
|
+
|
|
18
|
+
**macOS (funguje aj na Linuxe)**
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
curl -fsSL https://raw.githubusercontent.com/alhafoudh/edupage-cli/main/install.sh | sh
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Skript použije Homebrew, ak je dostupný, inak nainštaluje gem cez Ruby 3.2 alebo novšie.
|
|
25
|
+
Ak nie je dostupná ani jedna možnosť, skončí s pokynmi. Homebrew ani Ruby sám neinštaluje.
|
|
26
|
+
|
|
27
|
+
**Windows (PowerShell)**
|
|
28
|
+
|
|
29
|
+
```powershell
|
|
30
|
+
irm https://raw.githubusercontent.com/alhafoudh/edupage-cli/main/install.ps1 | iex
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Skript použije existujúce Ruby 3.2 alebo novšie. Ak Ruby nie je nainštalované vôbec,
|
|
34
|
+
nainštaluje Ruby+Devkit 3.4 cez winget a nástroje MSYS2 na zostavovanie cez `ridk install`. Potom
|
|
35
|
+
nainštaluje gem edupage-cli. Na Windows na ARM navyše nainštaluje libxml2/libxslt z MSYS2
|
|
36
|
+
a zostaví s nimi nokogiri, pretože pre arm64 Windows nie je dostupný predkompilovaný gem.
|
|
37
|
+
|
|
38
|
+
Potom sa prihlás cez `edupage login`. Heslo sa uloží do Správcu poverení Windows
|
|
39
|
+
(Credential Manager). Prihlasovacie údaje môžeš nastaviť aj cez premenné prostredia:
|
|
40
|
+
|
|
41
|
+
```powershell
|
|
42
|
+
setx EDUPAGE_USERNAME "tvoje_pouzivatelske_meno"
|
|
43
|
+
setx EDUPAGE_PASSWORD "tvoje_heslo"
|
|
44
|
+
setx EDUPAGE_SCHOOL "tvoja_skola"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Po nastavení otvor nové okno terminálu, aby sa premenné načítali.
|
|
48
|
+
|
|
49
|
+
edupage-cli si môžeš nainštalovať aj manuálne nasledujúcimi spôsobmi.
|
|
50
|
+
|
|
51
|
+
### Ručná inštalácia
|
|
52
|
+
|
|
53
|
+
Cez Homebrew na macOS aj Linuxe:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
brew install alhafoudh/edupage/edupage-cli
|
|
57
|
+
edupage login
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Formula je v tape [alhafoudh/homebrew-edupage](https://github.com/alhafoudh/homebrew-edupage).
|
|
61
|
+
Zostavuje sa zo zdrojového kódu označeného tagom, používa Ruby z Homebrew a gemy si
|
|
62
|
+
ukladá oddelene, takže nezasahuje do ostatných inštalácií Ruby. Pri prvej inštalácii sa kompiluje zopár natívnych
|
|
63
|
+
gemov (puma, nio4r), čo trvá pár desiatok sekúnd.
|
|
64
|
+
|
|
65
|
+
Alebo ako gem, ak už Ruby 3.2+ máš:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
gem install edupage-cli
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Alebo priamo z checkoutu:
|
|
72
|
+
|
|
14
73
|
```bash
|
|
15
74
|
bundle install
|
|
16
75
|
bundle exec exe/edupage login
|
|
17
76
|
```
|
|
18
77
|
|
|
19
|
-
`login`
|
|
20
|
-
|
|
78
|
+
`login` najprv overí heslo prihlásením do Edupage a až potom ho uloží do úložiska
|
|
79
|
+
hesiel operačného systému, takže heslo s preklepom neuloží.
|
|
21
80
|
|
|
22
|
-
##
|
|
81
|
+
## Prihlasovacie údaje
|
|
23
82
|
|
|
24
83
|
Hľadajú sa v tomto poradí:
|
|
25
84
|
|
|
26
85
|
| Poradie | Zdroj |
|
|
27
86
|
|---|---|
|
|
28
87
|
| 1 | `EDUPAGE_USERNAME`, `EDUPAGE_PASSWORD`, `EDUPAGE_SCHOOL` |
|
|
29
|
-
| 2 |
|
|
88
|
+
| 2 | `--username`, `--school`, potom `~/.config/edupage-cli/config.yml` (len meno a škola) |
|
|
89
|
+
| 3 | úložisko hesiel systému, cez `edupage login` (len heslo) |
|
|
90
|
+
|
|
91
|
+
Úložisko hesiel podľa systému:
|
|
92
|
+
|
|
93
|
+
| Systém | Úložisko |
|
|
94
|
+
|---|---|
|
|
95
|
+
| macOS | keychain (service `edupage-cli`) |
|
|
96
|
+
| Windows | Správca poverení, generic credential `edupage-cli:<meno>` |
|
|
97
|
+
| Linux | Secret Service (GNOME Keyring, KWallet) cez `secret-tool`, atribúty `service=edupage-cli`, `account=<meno>` |
|
|
30
98
|
|
|
31
|
-
|
|
32
|
-
|
|
99
|
+
Na Linuxe treba mať nainštalovaný `secret-tool` (balík `libsecret-tools` alebo `libsecret`)
|
|
100
|
+
a bežiacu D-Bus session. Bez nich ostáva len `EDUPAGE_PASSWORD`.
|
|
33
101
|
|
|
34
|
-
|
|
102
|
+
Keď je nastavená `EDUPAGE_PASSWORD`, úložisko hesiel sa vôbec nepoužije, ani sa do neho
|
|
103
|
+
nenahliada. `edupage auth` ukáže, ktorý zdroj sa práve používa, a pri úložisku vtedy napíše
|
|
104
|
+
`not consulted` - inak by to vyzeralo, že `edupage login` nič neurobil.
|
|
105
|
+
|
|
106
|
+
Nastavenia bez citlivých údajov sú v `~/.config/edupage-cli/config.yml`, session a cache stránok
|
|
35
107
|
v `~/.cache/edupage-cli/`.
|
|
36
108
|
|
|
37
|
-
##
|
|
109
|
+
## Hierarchia výberu
|
|
38
110
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
111
|
+
Výber sa riadi hierarchiou `account > škola > študent > školský rok` a žiadna úroveň
|
|
112
|
+
sa nedá preskočiť. Na každej úrovni platí rovnaké pravidlo: prednosť má explicitný
|
|
113
|
+
výber, jediná možnosť sa vyberie automaticky a v ostatných prípadoch príkaz skončí
|
|
114
|
+
chybou so zoznamom možností.
|
|
42
115
|
|
|
43
116
|
```
|
|
44
117
|
$ edupage students
|
|
@@ -47,11 +120,12 @@ No school selected. Pick one:
|
|
|
47
120
|
--school zusdemo Základná umelecká škola Demo
|
|
48
121
|
```
|
|
49
122
|
|
|
50
|
-
|
|
51
|
-
`*.edupage.org
|
|
123
|
+
Predvolená hodnota v configu sa **za výber nepočíta**: `default_school` určuje iba
|
|
124
|
+
server `*.edupage.org`, na ktorý sa prihlásiť, nie to, čie dáta čítaš.
|
|
52
125
|
|
|
53
|
-
Jedinou výnimkou je
|
|
54
|
-
|
|
126
|
+
Jedinou výnimkou je školský rok - automaticky sa vyberie aktuálny, lebo "teraz" je
|
|
127
|
+
jednoznačné. Vo výstupe je vždy uvedené, ktorý rok sa použil, a ak preň nie sú žiadne
|
|
128
|
+
dáta, výstup ukáže, v ktorých rokoch sú:
|
|
55
129
|
|
|
56
130
|
```
|
|
57
131
|
$ edupage grades --school zsdemo --student Jana
|
|
@@ -78,8 +152,9 @@ Globálne prepínače: `--school --student --year --username --json --yaml --no-
|
|
|
78
152
|
--verbose`. Meno študenta stačí zadať ako unikátny prefix, takže `--student Jana`
|
|
79
153
|
postačuje.
|
|
80
154
|
|
|
81
|
-
Tabuľkový výstup má hlavičku s
|
|
82
|
-
`--json` a `--yaml`
|
|
155
|
+
Tabuľkový výstup má hlavičku s údajmi o škole, študentovi a školskom roku. Výstupy
|
|
156
|
+
`--json` a `--yaml` hlavičku nemajú, aby boli bajt po bajte zhodné s odpoveďami REST API
|
|
157
|
+
a MCP.
|
|
83
158
|
|
|
84
159
|
```
|
|
85
160
|
$ edupage grades --school zsdemo --student Jana --year 2025
|
|
@@ -95,8 +170,8 @@ year : 2025/2026
|
|
|
95
170
|
└──────────────────┴───────────────────────────────┴───────┴────────┴─────────────────────────┘
|
|
96
171
|
```
|
|
97
172
|
|
|
98
|
-
Tabuľka sa prispôsobí šírke terminálu: keď sa nezmestí,
|
|
99
|
-
|
|
173
|
+
Tabuľka sa prispôsobí šírke terminálu: keď sa nezmestí, vždy sa zúži najširší
|
|
174
|
+
stĺpec a dlhý text sa zalomí, takže dátumy a známky zostanú celé.
|
|
100
175
|
|
|
101
176
|
Servisné príkazy: `edupage auth`, `edupage session status|refresh|logout`,
|
|
102
177
|
`edupage cache info|clear`, `edupage config path|get|set`.
|
|
@@ -119,8 +194,9 @@ jana.grades # skratka pre aktuálny rok
|
|
|
119
194
|
```
|
|
120
195
|
|
|
121
196
|
Kolekcie sú lazy a reťaziteľné: `where order limit offset find_by first count`.
|
|
122
|
-
`where(name: ...)` hľadá
|
|
123
|
-
|
|
197
|
+
`where(name: ...)` hľadá v mene, skratke aj id záznamu; pri ostatných kľúčoch sa
|
|
198
|
+
porovnáva príslušný atribút. Ako podmienku možno zadať hodnotu, regulárny výraz, rozsah,
|
|
199
|
+
pole alebo lambdu.
|
|
124
200
|
|
|
125
201
|
## Server
|
|
126
202
|
|
|
@@ -130,7 +206,7 @@ edupage mcp # MCP cez stdio, pre editory a desktop klientov
|
|
|
130
206
|
```
|
|
131
207
|
|
|
132
208
|
Počúva na `127.0.0.1` a pri prvom spustení si vygeneruje bearer token do configu.
|
|
133
|
-
Všetky
|
|
209
|
+
Všetky trasy sú `GET`, všetky MCP tooly sú označené ako read-only.
|
|
134
210
|
|
|
135
211
|
```bash
|
|
136
212
|
curl -H "Authorization: Bearer $TOKEN" \
|
|
@@ -140,7 +216,7 @@ curl -H "Authorization: Bearer $TOKEN" \
|
|
|
140
216
|
### Zapojenie MCP
|
|
141
217
|
|
|
142
218
|
`edupage mcp-add` zaregistruje server u klienta, `edupage mcp-config` iba vypíše
|
|
143
|
-
`mcpServers
|
|
219
|
+
záznam `mcpServers`, ak si ho chceš do configu pridať ručne.
|
|
144
220
|
|
|
145
221
|
```bash
|
|
146
222
|
edupage mcp-add claude-code --scope user # cez `claude mcp add`
|
|
@@ -153,26 +229,30 @@ edupage mcp-config # len JSON a nič iné
|
|
|
153
229
|
edupage mcp-config --http
|
|
154
230
|
```
|
|
155
231
|
|
|
156
|
-
Pridávanie je **idempotentné**: zhodný záznam
|
|
232
|
+
Pridávanie je **idempotentné**: zhodný záznam ponechá, odlišný upraví a chýbajúci
|
|
157
233
|
doplní. Config Claude Desktopu sa zlučuje, nie prepisuje - ostatné servery aj nesúvisiace
|
|
158
234
|
nastavenia zostanú - a predošlá verzia sa odloží ako `.bak`.
|
|
159
235
|
|
|
160
|
-
Pre lokálny nástroj je
|
|
161
|
-
|
|
162
|
-
`edupage server` a
|
|
163
|
-
zdieľa viac klientov.
|
|
236
|
+
Pre lokálny nástroj je lepšie predvolené stdio: proces spravuje klient, takže netreba
|
|
237
|
+
riešiť autentifikáciu ani udržiavať server v chode. `--http` sa pripája na `/mcp`
|
|
238
|
+
spusteného `edupage server` a posiela token v hlavičke `Authorization`, čo sa hodí, keď
|
|
239
|
+
jeden proces zdieľa viac klientov.
|
|
164
240
|
|
|
165
|
-
Ani jeden variant
|
|
166
|
-
zvoliť pri každom volaní.
|
|
241
|
+
Ani jeden variant nenastavuje školu ani študenta napevno - sú to úrovne hierarchie
|
|
242
|
+
a model si ich má zvoliť pri každom volaní.
|
|
167
243
|
|
|
168
|
-
Vygenerovaný stdio záznam používa absolútne cesty a
|
|
169
|
-
klienti spúšťajú servery z vlastného pracovného adresára.
|
|
170
|
-
|
|
171
|
-
|
|
244
|
+
Vygenerovaný stdio záznam používa absolútne cesty a pri behu z checkoutu nastavuje
|
|
245
|
+
`BUNDLE_GEMFILE`, lebo MCP klienti spúšťajú servery z vlastného pracovného adresára.
|
|
246
|
+
Pri inštalácii cez Homebrew ukazuje na `$(brew --prefix)/opt/edupage-cli/bin/edupage`,
|
|
247
|
+
nie na verziovanú cestu v `Cellar`, takže záznam zostane platný aj po `brew upgrade`. Zariaďuje to
|
|
248
|
+
premenná `EDUPAGE_EXECUTABLE`, ktorú nastavuje wrapper z formuly; rovnako ju môže
|
|
249
|
+
použiť akýkoľvek iný balíčkovač. MCP klienti navyše spúšťajú servery aj **bez
|
|
250
|
+
nastaveného locale**, preto každý súbor otvárame výslovne ako UTF-8 a nespoliehame sa
|
|
251
|
+
na `Encoding.default_external`.
|
|
172
252
|
|
|
173
|
-
## Ako
|
|
253
|
+
## Ako udržiavame rozhrania v súlade
|
|
174
254
|
|
|
175
|
-
|
|
255
|
+
Zdroje sú deklarované na jednom mieste, v `lib/edupage/registry/resources.rb`:
|
|
176
256
|
|
|
177
257
|
```ruby
|
|
178
258
|
resource :grades do
|
|
@@ -183,31 +263,34 @@ resource :grades do
|
|
|
183
263
|
end
|
|
184
264
|
```
|
|
185
265
|
|
|
186
|
-
Z toho sa vygeneruje CLI príkaz, REST
|
|
187
|
-
spadne, ak niektorý chýba alebo
|
|
266
|
+
Z toho sa vygeneruje CLI príkaz, REST trasa aj MCP tool a `spec/registry_parity_spec.rb`
|
|
267
|
+
spadne, ak niektorý z nich chýba alebo má odlišné parametre. Výstup `--json`, telo REST
|
|
188
268
|
odpovede aj výsledok MCP toolu idú cez jeden serializer, takže sú bajt na bajt zhodné.
|
|
189
269
|
|
|
190
|
-
`scope` zároveň
|
|
191
|
-
|
|
192
|
-
|
|
270
|
+
`scope` zároveň určuje, ktoré úrovne hierarchie zdroj vyžaduje, a všetky tri rozhrania
|
|
271
|
+
ich vyžadujú rovnako: v REST sú súčasťou cesty, vstupná schéma MCP ich označí ako povinné
|
|
272
|
+
a CLI príkaz bez nich odmietne a vypíše zoznam možností.
|
|
193
273
|
|
|
194
274
|
## Poznámky k samotnému Edupage
|
|
195
275
|
|
|
196
|
-
Veci, ktoré stojí za to vedieť - všetky overené
|
|
197
|
-
|
|
198
|
-
- **
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
276
|
+
Veci, ktoré stojí za to vedieť - všetky overené na živom účte:
|
|
277
|
+
|
|
278
|
+
- **Jedným účtom sa dá prihlásiť do viacerých škôl.** `mauth` vráti jednu session na
|
|
279
|
+
každú školu.
|
|
280
|
+
- **Session drží aktuálne dieťa a aktuálny školský rok.** Prepnutie dieťaťa aj roka mení
|
|
281
|
+
zdieľaný stav na serveri, preto prebieha pod súborovým zámkom a pri každej odpovedi sa
|
|
282
|
+
overuje, či zodpovedá požadovanému dieťaťu a roku.
|
|
283
|
+
- **Prepnutie sa prejaví s oneskorením.** Edupage prepnutie potvrdí okamžite, ale ešte
|
|
284
|
+
request alebo dva vracia stránku predošlého dieťaťa či roka, takže sa fetch opakuje,
|
|
285
|
+
kým stránka nesedí. Ak by sa vrátila neaktuálna stránka, dostal by si bez upozornenia
|
|
286
|
+
rozvrh iného dieťaťa.
|
|
205
287
|
- **Číselníky sa medzi rokmi menia.** Zoznam tried za 2025 nie je ten istý ako za 2026,
|
|
206
288
|
preto je rok súčasťou každého cache kľúča.
|
|
207
289
|
- **Timeline je spoločná pre všetky deti rodiča** a delí sa lokálne podľa mapy
|
|
208
290
|
`childGroups`.
|
|
209
|
-
- **Domáce úlohy
|
|
210
|
-
účte, na ktorom to vzniklo, je 15 zo 16 úloh jedného
|
|
291
|
+
- **Domáce úlohy sa zadávajú buď celej triede, alebo jednotlivým žiakom.** Treba
|
|
292
|
+
podporovať oba prípady: na účte, na ktorom to vzniklo, je 15 zo 16 úloh jedného
|
|
293
|
+
dieťaťa zadaných individuálne.
|
|
211
294
|
|
|
212
295
|
## Vývoj
|
|
213
296
|
|
|
@@ -215,16 +298,16 @@ Veci, ktoré stojí za to vedieť - všetky overené proti živému účtu:
|
|
|
215
298
|
bundle exec rspec
|
|
216
299
|
```
|
|
217
300
|
|
|
218
|
-
Testy
|
|
301
|
+
Testy používajú ručne pripravené payloady, nie uložené stránky: tie skutočné
|
|
219
302
|
majú stovky kilobajtov a sú v nich mená cudzích detí.
|
|
220
303
|
|
|
221
304
|
Keby predsa len vznikla VCR kazeta, `spec/support/cassette_scrubber.rb` z nej pred
|
|
222
|
-
zápisom na disk
|
|
223
|
-
v tele odpovede, v URI aj v hlavičkách. Mená
|
|
224
|
-
|
|
305
|
+
zápisom na disk odstráni osobné údaje - mená, názvy škôl, subdomény aj e-maily, a to
|
|
306
|
+
v tele odpovede, v URI aj v hlavičkách. Mená neberie z pevného zoznamu, ale **priamo
|
|
307
|
+
z odpovede** - z polí, kam ich Edupage vždy dáva - a potom nahradí každý ich výskyt vrátane
|
|
225
308
|
tých vo voľnom texte správ. Pseudonym je odvodený z pôvodnej hodnoty, takže ten istý
|
|
226
|
-
človek
|
|
227
|
-
|
|
309
|
+
človek má v každej kazete rovnaký pseudonym a vzájomné odkazy v payloade zostanú
|
|
310
|
+
platné; pôvodnú hodnotu sa z pseudonymu spätne získať nedá. Keďže slovenčina skloňuje, hľadá sa aj
|
|
228
311
|
kmeň mena, takže zmiznú aj tvary ako `Janu` či `Kováčovej`, nielen základný tvar.
|
|
229
312
|
|
|
230
313
|
Overiť sa to dá proti živému účtu:
|
|
@@ -237,14 +320,26 @@ EDUPAGE_LIVE_USERNAME=you@example.com EDUPAGE_LIVE_SCHOOL=yourschool \
|
|
|
237
320
|
Ten test si zoznam mien, ktoré sa v kazete nesmú objaviť, **načíta zo živého účtu**, nie
|
|
238
321
|
z ručne napísaného zoznamu - spadne teda aj vtedy, keď pribudne spolužiak alebo druhá
|
|
239
322
|
škola, ktorú scrubber nevie nájsť. Zároveň kontroluje, že sa kazeta uložila ako čitateľný
|
|
240
|
-
text: Edupage
|
|
241
|
-
|
|
323
|
+
text: Edupage posiela stránky gzipnuté a kontrola mien by v komprimovanom tele nič
|
|
324
|
+
nenašla, hoci by v ňom všetky mená zostali.
|
|
325
|
+
|
|
326
|
+
CI beží na Ruby 3.2 až 4.0 na Linuxe, macOS a Windows. Každý adapter úložiska hesiel sa
|
|
327
|
+
testuje proti skutočnému úložisku na svojom systéme: macOS job si vytvorí vlastný odomknutý
|
|
328
|
+
keychain, Linux job spustí gnome-keyring v D-Bus session a Windows job použije Správcu
|
|
329
|
+
poverení. Na ostatných systémoch sa tieto testy preskočia.
|
|
330
|
+
|
|
331
|
+
### Vydanie
|
|
242
332
|
|
|
243
|
-
|
|
244
|
-
|
|
333
|
+
Vydanie spustí zmena `Edupage::VERSION` v `lib/edupage/version.rb`. Keď CI na `main`
|
|
334
|
+
prejde, release workflow pushne gem na rubygems.org, vytvorí tag a GitHub release
|
|
335
|
+
a nakoniec aktualizuje formulu v tape
|
|
336
|
+
[alhafoudh/homebrew-edupage](https://github.com/alhafoudh/homebrew-edupage) na nový tag
|
|
337
|
+
(cez deploy key v secrete `HOMEBREW_TAP_DEPLOY_KEY`). Tap má vlastné CI, ktoré formulu
|
|
338
|
+
postaví a otestuje na macOS (arm64, Intel) aj Linuxe (x64, arm64). Každý krok je
|
|
339
|
+
idempotentný, takže zlyhaný beh stačí spustiť znova.
|
|
245
340
|
|
|
246
341
|
## Rozsah
|
|
247
342
|
|
|
248
|
-
|
|
343
|
+
Zámerne iba na čítanie. Do Edupage sa nič nezapisuje - žiadne odpovede na správy, žiadne
|
|
249
344
|
označovanie úloh za hotové, žiadne podpisovanie známok. `spec/registry_parity_spec.rb`
|
|
250
345
|
aj samostatný CI job overujú, že sa v `lib/` neobjaví write endpoint.
|
data/lib/edupage/account.rb
CHANGED
|
@@ -111,7 +111,7 @@ module Edupage
|
|
|
111
111
|
{ username: username, name: name, schools: schools.map(&:origin) }
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
-
# Forgets the stored sessions. The
|
|
114
|
+
# Forgets the stored sessions. The stored password is untouched.
|
|
115
115
|
def logout!
|
|
116
116
|
@store.delete(username)
|
|
117
117
|
end
|
data/lib/edupage/cli.rb
CHANGED
|
@@ -45,11 +45,11 @@ module Edupage
|
|
|
45
45
|
|
|
46
46
|
# --- credentials -------------------------------------------------------------------
|
|
47
47
|
|
|
48
|
-
desc "login", "Verify credentials and store the password in the
|
|
48
|
+
desc "login", "Verify credentials and store the password in the OS credential store"
|
|
49
49
|
method_option :username, type: :string, desc: "Edupage login (email)"
|
|
50
50
|
method_option :stdin, type: :boolean, desc: "Read the password from stdin instead of prompting"
|
|
51
51
|
def login
|
|
52
|
-
|
|
52
|
+
store = require_system_adapter!
|
|
53
53
|
username = options[:username] || Edupage.config.default_username ||
|
|
54
54
|
ask("Username (email):")
|
|
55
55
|
school = options[:school] || Edupage.config.default_school || ask("School (e.g. zsdemo):")
|
|
@@ -57,23 +57,26 @@ module Edupage
|
|
|
57
57
|
|
|
58
58
|
raise Error, "No password given" if password.empty?
|
|
59
59
|
|
|
60
|
-
# Verified before it is stored, so a typo never ends up in the
|
|
60
|
+
# Verified before it is stored, so a typo never ends up in the store.
|
|
61
61
|
users = Client.mauth(school: school, username: username, password: password)
|
|
62
|
-
|
|
62
|
+
store.store(username: username, password: password)
|
|
63
63
|
|
|
64
|
-
say "Stored password for #{username}."
|
|
64
|
+
say "Stored password for #{username} in the #{store.display_name}."
|
|
65
65
|
say "Schools: #{users.map { |u| u[:origin] }.join(", ")}"
|
|
66
66
|
remember_defaults(username, school)
|
|
67
|
+
return unless Credentials.new.password_from_env?
|
|
68
|
+
|
|
69
|
+
say "EDUPAGE_PASSWORD is set and wins over the stored password until you unset it.", :yellow
|
|
67
70
|
end
|
|
68
71
|
|
|
69
|
-
desc "logout", "Remove the stored password from the
|
|
72
|
+
desc "logout", "Remove the stored password from the OS credential store"
|
|
70
73
|
method_option :username, type: :string
|
|
71
74
|
def logout
|
|
72
|
-
|
|
75
|
+
store = require_system_adapter!
|
|
73
76
|
username = options[:username] || Edupage.config.default_username or
|
|
74
77
|
raise MissingCredentialsError, "No username; pass --username."
|
|
75
78
|
|
|
76
|
-
say(
|
|
79
|
+
say(store.delete(username: username) ? "Removed password for #{username}." : "Nothing stored for #{username}.")
|
|
77
80
|
say "Session cache is separate; use `edupage session logout` to drop it.", :yellow
|
|
78
81
|
end
|
|
79
82
|
|
|
@@ -87,12 +90,8 @@ module Edupage
|
|
|
87
90
|
auth_row("school", safe { credentials.school }, credentials.school_source),
|
|
88
91
|
auth_row("password", credentials.password_source ? "(set)" : "(missing)",
|
|
89
92
|
credentials.password_source),
|
|
90
|
-
auth_row("
|
|
93
|
+
auth_row("credential store", credential_store_status(credentials, username), nil)
|
|
91
94
|
])
|
|
92
|
-
|
|
93
|
-
return unless credentials.password_shadowed?
|
|
94
|
-
|
|
95
|
-
say "EDUPAGE_PASSWORD is set and overrides the keychain entry.", :yellow
|
|
96
95
|
end
|
|
97
96
|
|
|
98
97
|
# --- session and cache ---------------------------------------------------------------
|
|
@@ -115,7 +114,7 @@ module Edupage
|
|
|
115
114
|
say "Logged in again: #{account.schools.map(&:origin).join(", ")}"
|
|
116
115
|
when "logout"
|
|
117
116
|
store.delete(username)
|
|
118
|
-
say "Dropped stored sessions for #{username}. The
|
|
117
|
+
say "Dropped stored sessions for #{username}. The stored password is untouched."
|
|
119
118
|
else
|
|
120
119
|
raise Error, "Unknown session subcommand #{subcommand.inspect}"
|
|
121
120
|
end
|
|
@@ -310,12 +309,9 @@ module Edupage
|
|
|
310
309
|
format: options[:json] ? :json : (options[:yaml] ? :yaml : :table))
|
|
311
310
|
end
|
|
312
311
|
|
|
313
|
-
def
|
|
314
|
-
|
|
315
|
-
raise Thor::Error, "
|
|
316
|
-
end
|
|
317
|
-
|
|
318
|
-
Credentials::Keychain.new
|
|
312
|
+
def require_system_adapter!
|
|
313
|
+
Credentials.system_adapter or
|
|
314
|
+
raise Thor::Error, "No OS credential store is available on #{RUBY_PLATFORM}; use EDUPAGE_PASSWORD."
|
|
319
315
|
end
|
|
320
316
|
|
|
321
317
|
def ask_password
|
|
@@ -375,8 +371,10 @@ module Edupage
|
|
|
375
371
|
}
|
|
376
372
|
end
|
|
377
373
|
|
|
374
|
+
# Packagers that run edupage through a wrapper (the Homebrew formula) name the
|
|
375
|
+
# stable entry point here, so the registered path survives upgrades.
|
|
378
376
|
def executable_path
|
|
379
|
-
File.expand_path($PROGRAM_NAME)
|
|
377
|
+
ENV["EDUPAGE_EXECUTABLE"] || File.expand_path($PROGRAM_NAME)
|
|
380
378
|
end
|
|
381
379
|
|
|
382
380
|
def bundler_gemfile
|
|
@@ -523,11 +521,17 @@ module Edupage
|
|
|
523
521
|
"'#{part.gsub("'", %q('\''))}'"
|
|
524
522
|
end
|
|
525
523
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
524
|
+
# Reads the outcome off the password resolution instead of asking the store again,
|
|
525
|
+
# so with EDUPAGE_PASSWORD set the store is never touched.
|
|
526
|
+
def credential_store_status(credentials, username)
|
|
527
|
+
return "not consulted (EDUPAGE_PASSWORD set)" if credentials.password_from_env?
|
|
528
|
+
|
|
529
|
+
adapter = Credentials.system_adapter
|
|
530
|
+
return "unavailable on #{RUBY_PLATFORM}" unless adapter
|
|
531
|
+
return "#{adapter.display_name}, but no username to look up" unless username
|
|
529
532
|
|
|
530
|
-
|
|
533
|
+
stored = credentials.password_source == adapter.source_for(:password)
|
|
534
|
+
"#{adapter.display_name}, #{stored ? "stored" : "nothing stored"} for #{username}"
|
|
531
535
|
end
|
|
532
536
|
|
|
533
537
|
def auth_row(label, value, source)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
class Credentials
|
|
3
|
+
# Common interface of every credential source.
|
|
4
|
+
#
|
|
5
|
+
# Credentials asks adapters in a fixed order and stops at the first value, so an
|
|
6
|
+
# adapter only ever runs when every adapter before it came up empty. A source that
|
|
7
|
+
# does not hold a field just returns nil for it.
|
|
8
|
+
class Adapter
|
|
9
|
+
class << self
|
|
10
|
+
def available?
|
|
11
|
+
raise NotImplementedError, "#{name}.available? is not implemented"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def display_name
|
|
15
|
+
raise NotImplementedError, "#{name}.display_name is not implemented"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def display_name = self.class.display_name
|
|
20
|
+
|
|
21
|
+
def username(**) = nil
|
|
22
|
+
def school(**) = nil
|
|
23
|
+
def password(**) = nil
|
|
24
|
+
|
|
25
|
+
# Whether `edupage login` can store a password here.
|
|
26
|
+
def writable? = false
|
|
27
|
+
|
|
28
|
+
# Label shown by `edupage auth` next to a value this adapter supplied.
|
|
29
|
+
def source_for(_field)
|
|
30
|
+
raise NotImplementedError, "#{self.class.name}#source_for is not implemented"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
class Credentials
|
|
3
|
+
module Adapters
|
|
4
|
+
# Highest-priority source: plain environment variables. Whatever it supplies wins,
|
|
5
|
+
# and nothing after it is consulted for that field.
|
|
6
|
+
class Env < Adapter
|
|
7
|
+
VARS = {
|
|
8
|
+
username: "EDUPAGE_USERNAME",
|
|
9
|
+
password: "EDUPAGE_PASSWORD",
|
|
10
|
+
school: "EDUPAGE_SCHOOL"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def available? = true
|
|
15
|
+
def display_name = "environment"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(env = ENV)
|
|
19
|
+
super()
|
|
20
|
+
@env = env
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def username(**) = @env[VARS[:username]]
|
|
24
|
+
def password(**) = @env[VARS[:password]]
|
|
25
|
+
|
|
26
|
+
# Accepts either "zsdemo" or "zsdemo.edupage.org".
|
|
27
|
+
def school(**)
|
|
28
|
+
value = @env[VARS[:school]]
|
|
29
|
+
value&.sub(/\.edupage\.org\z/, "")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def source_for(field) = "ENV: #{VARS[field]}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require "rbconfig"
|
|
3
|
+
|
|
4
|
+
module Edupage
|
|
5
|
+
class Credentials
|
|
6
|
+
module Adapters
|
|
7
|
+
# The macOS keychain, driven through /usr/bin/security.
|
|
8
|
+
#
|
|
9
|
+
# The password is never passed in argv (where `ps` would expose it). `security`
|
|
10
|
+
# reads it from stdin instead, prompting twice, so writes send the value twice.
|
|
11
|
+
#
|
|
12
|
+
# `security add-generic-password` exits 0 even when the two reads disagree, storing
|
|
13
|
+
# an empty password - which is what the read-back in SystemAdapter#store catches.
|
|
14
|
+
class MacosKeychain < SystemAdapter
|
|
15
|
+
SECURITY = "/usr/bin/security".freeze
|
|
16
|
+
# Runs a command in a new session, without a controlling terminal. macOS ships no
|
|
17
|
+
# setsid(1), so Ruby itself does the setsid before exec.
|
|
18
|
+
DETACH = [RbConfig.ruby, "-e", "Process.setsid; exec(*ARGV)"].freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def available?
|
|
22
|
+
RUBY_PLATFORM.include?("darwin") && File.executable?(SECURITY)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def display_name = "macOS keychain"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def source_for(_field) = "keychain"
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def read(username)
|
|
33
|
+
# -g, not -w: for non-ASCII passwords `-w` prints bare hex, which is
|
|
34
|
+
# indistinguishable from an ASCII password that happens to look like hex
|
|
35
|
+
# ("deadbeef"). -g prefixes the hex form with 0x, so it can be told apart.
|
|
36
|
+
_out, err, status = Open3.capture3(
|
|
37
|
+
SECURITY, "find-generic-password", "-s", service, "-a", username, "-g"
|
|
38
|
+
)
|
|
39
|
+
return nil unless status.success?
|
|
40
|
+
|
|
41
|
+
parse_password(err)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def write(username, secret)
|
|
45
|
+
# -w last with no value: `security` prompts on stdin rather than taking argv.
|
|
46
|
+
# It prefers /dev/tty over stdin when it has a terminal, so it runs detached
|
|
47
|
+
# from ours; otherwise it would ignore stdin_data and prompt the user instead.
|
|
48
|
+
_out, err, status = Open3.capture3(
|
|
49
|
+
*DETACH, SECURITY, "add-generic-password", "-s", service, "-a", username,
|
|
50
|
+
"-l", "#{service} (#{username})", "-U", "-w",
|
|
51
|
+
stdin_data: "#{secret}\n#{secret}\n"
|
|
52
|
+
)
|
|
53
|
+
raise Error, "Failed to store password in keychain: #{err.strip}" unless status.success?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def remove(username)
|
|
57
|
+
_out, _err, status = Open3.capture3(
|
|
58
|
+
SECURITY, "delete-generic-password", "-s", service, "-a", username
|
|
59
|
+
)
|
|
60
|
+
status.success?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# `security -g` writes one of these to stderr:
|
|
64
|
+
# password: "plain-ascii"
|
|
65
|
+
# password: 0x73336372 "s3cr3t-\303\241"
|
|
66
|
+
# The hex form is exact bytes, so it is preferred whenever present.
|
|
67
|
+
def parse_password(stderr)
|
|
68
|
+
line = stderr.lines.find { |l| l.start_with?("password:") }
|
|
69
|
+
return nil unless line
|
|
70
|
+
|
|
71
|
+
if (hex = line[/password: 0x([0-9A-Fa-f]+)/, 1])
|
|
72
|
+
[hex].pack("H*").force_encoding(Encoding::UTF_8)
|
|
73
|
+
elsif (quoted = line[/password: "(.*)"\s*\z/m, 1])
|
|
74
|
+
unescape(quoted)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def unescape(str)
|
|
79
|
+
str.gsub(/\\(\d{3}|.)/) do
|
|
80
|
+
esc = Regexp.last_match(1)
|
|
81
|
+
esc.match?(/\A\d{3}\z/) ? esc.to_i(8).chr : esc
|
|
82
|
+
end.force_encoding(Encoding::UTF_8)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
module Edupage
|
|
4
|
+
class Credentials
|
|
5
|
+
module Adapters
|
|
6
|
+
# The freedesktop Secret Service (GNOME Keyring, KWallet), driven through
|
|
7
|
+
# `secret-tool` from libsecret.
|
|
8
|
+
#
|
|
9
|
+
# Entries carry the attributes service=<service> and account=<username>. The
|
|
10
|
+
# password goes in on stdin, never in argv; with a pipe rather than a terminal on
|
|
11
|
+
# stdin, `secret-tool store` reads it verbatim instead of prompting.
|
|
12
|
+
#
|
|
13
|
+
# Without a D-Bus session (a plain SSH login, a container) lookups fail; that reads
|
|
14
|
+
# as "nothing stored" so ENV-less runs still get the usual missing-password hint.
|
|
15
|
+
class SecretService < SystemAdapter
|
|
16
|
+
class << self
|
|
17
|
+
def available?
|
|
18
|
+
RUBY_PLATFORM.include?("linux") && !executable.nil?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def display_name = "Secret Service (libsecret)"
|
|
22
|
+
|
|
23
|
+
def executable
|
|
24
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
|
|
25
|
+
.map { |dir| File.join(dir, "secret-tool") }
|
|
26
|
+
.find { |path| File.executable?(path) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def source_for(_field) = "secret service"
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def attributes(username) = ["service", service, "account", username]
|
|
35
|
+
|
|
36
|
+
def read(username)
|
|
37
|
+
# Prints the secret as-is, without a trailing newline when stdout is a pipe.
|
|
38
|
+
out, _err, status = Open3.capture3(self.class.executable, "lookup", *attributes(username))
|
|
39
|
+
status.success? ? out.force_encoding(Encoding::UTF_8) : nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def write(username, secret)
|
|
43
|
+
_out, err, status = Open3.capture3(
|
|
44
|
+
self.class.executable, "store", "--label", "#{service} (#{username})", *attributes(username),
|
|
45
|
+
stdin_data: secret
|
|
46
|
+
)
|
|
47
|
+
raise Error, "Failed to store password in #{display_name}: #{err.strip}" unless status.success?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# `secret-tool clear` exits 0 whether or not anything matched, so whether there
|
|
51
|
+
# was something to remove is checked first.
|
|
52
|
+
def remove(username)
|
|
53
|
+
existed = stored?(username: username)
|
|
54
|
+
Open3.capture3(self.class.executable, "clear", *attributes(username))
|
|
55
|
+
existed
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
class Credentials
|
|
3
|
+
module Adapters
|
|
4
|
+
# Windows Credential Manager, called through advapi32 with Fiddle.
|
|
5
|
+
#
|
|
6
|
+
# Windows ships no command that reads a stored password back (cmdkey only writes
|
|
7
|
+
# one), so this talks to the Cred* API directly: no subprocess, and the password
|
|
8
|
+
# never appears in argv.
|
|
9
|
+
#
|
|
10
|
+
# Entries are generic credentials named "<service>:<username>". The blob holds the
|
|
11
|
+
# password as UTF-8 bytes; Credential Manager treats it as opaque.
|
|
12
|
+
class WindowsCredentialManager < SystemAdapter
|
|
13
|
+
CRED_TYPE_GENERIC = 1
|
|
14
|
+
CRED_PERSIST_LOCAL_MACHINE = 2
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def available? = Gem.win_platform?
|
|
18
|
+
def display_name = "Windows Credential Manager"
|
|
19
|
+
|
|
20
|
+
# Built on first use, so Fiddle is never loaded on other platforms or when ENV
|
|
21
|
+
# already supplied the password.
|
|
22
|
+
def api
|
|
23
|
+
@api ||= begin
|
|
24
|
+
require "fiddle/import"
|
|
25
|
+
|
|
26
|
+
Module.new do
|
|
27
|
+
extend Fiddle::Importer
|
|
28
|
+
|
|
29
|
+
dlload "advapi32.dll"
|
|
30
|
+
extern "int CredReadW(void*, uint32_t, uint32_t, void*)"
|
|
31
|
+
extern "int CredWriteW(void*, uint32_t)"
|
|
32
|
+
extern "int CredDeleteW(void*, uint32_t, uint32_t)"
|
|
33
|
+
extern "void CredFree(void*)"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# CREDENTIALW. FILETIME is two DWORDs; Fiddle works out the padding before
|
|
39
|
+
# each pointer, so the layout matches on both x64 and arm64.
|
|
40
|
+
def credential
|
|
41
|
+
@credential ||= api.struct([
|
|
42
|
+
"uint32_t flags",
|
|
43
|
+
"uint32_t type",
|
|
44
|
+
"void* target_name",
|
|
45
|
+
"void* comment",
|
|
46
|
+
"uint32_t last_written_low",
|
|
47
|
+
"uint32_t last_written_high",
|
|
48
|
+
"uint32_t blob_size",
|
|
49
|
+
"void* blob",
|
|
50
|
+
"uint32_t persist",
|
|
51
|
+
"uint32_t attribute_count",
|
|
52
|
+
"void* attributes",
|
|
53
|
+
"void* target_alias",
|
|
54
|
+
"void* user_name"
|
|
55
|
+
])
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def source_for(_field) = "credential manager"
|
|
60
|
+
|
|
61
|
+
def target_name(username) = "#{service}:#{username}"
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Each entry point fetches the API first: that is what requires Fiddle, so it has
|
|
66
|
+
# to happen before any other Fiddle constant is touched.
|
|
67
|
+
def read(username)
|
|
68
|
+
api = self.class.api
|
|
69
|
+
slot = Fiddle::Pointer.malloc(Fiddle::SIZEOF_VOIDP, Fiddle::RUBY_FREE)
|
|
70
|
+
return nil if api.CredReadW(wide(target_name(username)), CRED_TYPE_GENERIC, 0, slot).zero?
|
|
71
|
+
|
|
72
|
+
address = slot.ptr.to_i
|
|
73
|
+
begin
|
|
74
|
+
entry = self.class.credential.new(address)
|
|
75
|
+
Fiddle::Pointer.new(entry.blob.to_i)[0, entry.blob_size].force_encoding(Encoding::UTF_8)
|
|
76
|
+
ensure
|
|
77
|
+
api.CredFree(address)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def write(username, secret)
|
|
82
|
+
api = self.class.api
|
|
83
|
+
# Locals keep the buffers alive until CredWriteW has copied them.
|
|
84
|
+
target = wide(target_name(username))
|
|
85
|
+
user = wide(username)
|
|
86
|
+
blob = secret.b
|
|
87
|
+
|
|
88
|
+
entry = self.class.credential.malloc(Fiddle::RUBY_FREE)
|
|
89
|
+
entry.flags = 0
|
|
90
|
+
entry.type = CRED_TYPE_GENERIC
|
|
91
|
+
entry.target_name = target.to_i
|
|
92
|
+
entry.comment = 0
|
|
93
|
+
entry.last_written_low = 0
|
|
94
|
+
entry.last_written_high = 0
|
|
95
|
+
entry.blob_size = blob.bytesize
|
|
96
|
+
entry.blob = Fiddle::Pointer[blob].to_i
|
|
97
|
+
entry.persist = CRED_PERSIST_LOCAL_MACHINE
|
|
98
|
+
entry.attribute_count = 0
|
|
99
|
+
entry.attributes = 0
|
|
100
|
+
entry.target_alias = 0
|
|
101
|
+
entry.user_name = user.to_i
|
|
102
|
+
|
|
103
|
+
return unless api.CredWriteW(entry.to_ptr, 0).zero?
|
|
104
|
+
|
|
105
|
+
raise Error, "Failed to store password in #{display_name} (Win32 error #{Fiddle.win32_last_error})"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Whether there is anything to remove is checked up front rather than read off
|
|
109
|
+
# ERROR_NOT_FOUND (1168): on Windows arm64 with Ruby 4.0, Fiddle reports the last error
|
|
110
|
+
# as 0 after a failed CredDeleteW.
|
|
111
|
+
def remove(username)
|
|
112
|
+
return false unless stored?(username: username)
|
|
113
|
+
|
|
114
|
+
api = self.class.api
|
|
115
|
+
return true unless api.CredDeleteW(wide(target_name(username)), CRED_TYPE_GENERIC, 0).zero?
|
|
116
|
+
|
|
117
|
+
raise Error, "Failed to remove password from #{display_name} (Win32 error #{Fiddle.win32_last_error.inspect})"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A NUL-terminated UTF-16LE copy of +str+, as the W functions expect.
|
|
121
|
+
def wide(str)
|
|
122
|
+
Fiddle::Pointer["#{str}\0".encode(Encoding::UTF_16LE)]
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module Edupage
|
|
2
|
+
class Credentials
|
|
3
|
+
# Base for the operating system's own credential stores. They hold only the
|
|
4
|
+
# password, keyed by service and username; username and school come from ENV,
|
|
5
|
+
# flags or config.
|
|
6
|
+
#
|
|
7
|
+
# Subclasses implement the private #read, #write and #remove. Everything that has to
|
|
8
|
+
# hold for every store - no lookups where the store does not exist, no empty
|
|
9
|
+
# passwords, a read-back after every write - lives here.
|
|
10
|
+
class SystemAdapter < Adapter
|
|
11
|
+
SERVICE = "edupage-cli".freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :service
|
|
14
|
+
|
|
15
|
+
def initialize(service: SERVICE)
|
|
16
|
+
super()
|
|
17
|
+
@service = service
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def writable? = true
|
|
21
|
+
|
|
22
|
+
def password(username: nil, **)
|
|
23
|
+
return nil if username.nil? || username.empty?
|
|
24
|
+
return nil unless self.class.available?
|
|
25
|
+
|
|
26
|
+
value = read(username)
|
|
27
|
+
value && !value.empty? ? value : nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def store(username:, password:)
|
|
31
|
+
ensure_available!
|
|
32
|
+
secret = password.to_s
|
|
33
|
+
raise ArgumentError, "password must not be empty" if secret.empty?
|
|
34
|
+
|
|
35
|
+
write(username, secret)
|
|
36
|
+
|
|
37
|
+
# Stores can report success and still persist something else (see
|
|
38
|
+
# MacosKeychain), so a write only counts once it reads back intact.
|
|
39
|
+
unless password(username: username) == secret
|
|
40
|
+
raise Error, "#{display_name} reported success but stored a different password"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# True when something was stored and is now gone.
|
|
47
|
+
def delete(username:)
|
|
48
|
+
ensure_available!
|
|
49
|
+
remove(username)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def stored?(username:)
|
|
53
|
+
!password(username: username).nil?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def read(_username)
|
|
59
|
+
raise NotImplementedError, "#{self.class.name}#read is not implemented"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def write(_username, _secret)
|
|
63
|
+
raise NotImplementedError, "#{self.class.name}#write is not implemented"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def remove(_username)
|
|
67
|
+
raise NotImplementedError, "#{self.class.name}#remove is not implemented"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def ensure_available!
|
|
71
|
+
return if self.class.available?
|
|
72
|
+
|
|
73
|
+
raise UnsupportedPlatformError,
|
|
74
|
+
"The #{display_name} is not available on #{RUBY_PLATFORM}. Use EDUPAGE_PASSWORD instead."
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/edupage/credentials.rb
CHANGED
|
@@ -1,25 +1,39 @@
|
|
|
1
1
|
module Edupage
|
|
2
|
-
# Resolves username / password / school from
|
|
2
|
+
# Resolves username / password / school from credential adapters.
|
|
3
3
|
#
|
|
4
|
-
# ENV
|
|
5
|
-
#
|
|
6
|
-
#
|
|
4
|
+
# ENV wins over everything. When it supplies a value nothing else is consulted, and the
|
|
5
|
+
# operating system's credential store in particular is never touched: no keychain
|
|
6
|
+
# prompt, no subprocess, no FFI load. That store is looked up lazily and only as the
|
|
7
|
+
# last resort for the password. Username and school fall back to --username/--school
|
|
8
|
+
# and then config instead.
|
|
7
9
|
class Credentials
|
|
8
10
|
Resolved = Struct.new(:value, :source, keyword_init: true)
|
|
9
11
|
|
|
12
|
+
# Tried in order; the first one available on this machine is the system store.
|
|
13
|
+
SYSTEM_ADAPTERS = [
|
|
14
|
+
Adapters::MacosKeychain,
|
|
15
|
+
Adapters::WindowsCredentialManager,
|
|
16
|
+
Adapters::SecretService
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# The credential store of this operating system, or nil when there is none.
|
|
21
|
+
def system_adapter
|
|
22
|
+
SYSTEM_ADAPTERS.find(&:available?)&.new
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
10
26
|
attr_reader :username_override, :school_override
|
|
11
27
|
|
|
12
|
-
|
|
28
|
+
# +system+ is a callable rather than an adapter so that building Credentials never
|
|
29
|
+
# touches the store; it is called at most once, and only when ENV has no password.
|
|
30
|
+
def initialize(username: nil, school: nil, env: Adapters::Env.new,
|
|
31
|
+
system: -> { Credentials.system_adapter }, config: Edupage.config)
|
|
13
32
|
@username_override = username
|
|
14
33
|
@school_override = school
|
|
15
34
|
@config = config
|
|
16
|
-
@
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def default_providers
|
|
20
|
-
[Credentials::Env.new].tap do |list|
|
|
21
|
-
list << Credentials::Keychain.new if Credentials::Keychain.available?
|
|
22
|
-
end
|
|
35
|
+
@env = env
|
|
36
|
+
@system = system
|
|
23
37
|
end
|
|
24
38
|
|
|
25
39
|
def username
|
|
@@ -41,20 +55,9 @@ module Edupage
|
|
|
41
55
|
def password_source = resolved_password.source
|
|
42
56
|
def school_source = resolved_school.source
|
|
43
57
|
|
|
44
|
-
# True when ENV supplies the password
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def password_shadowed?
|
|
48
|
-
winning = resolved_password.source
|
|
49
|
-
return false unless winning&.start_with?("ENV:")
|
|
50
|
-
|
|
51
|
-
user = resolved_username.value
|
|
52
|
-
@providers.any? do |provider|
|
|
53
|
-
next false if provider.source_for(:password) == winning
|
|
54
|
-
|
|
55
|
-
value = provider.password(username: user)
|
|
56
|
-
value && !value.empty?
|
|
57
|
-
end
|
|
58
|
+
# True when ENV supplies the password, i.e. the system store is not consulted.
|
|
59
|
+
def password_from_env?
|
|
60
|
+
!from(@env, :password).nil?
|
|
58
61
|
end
|
|
59
62
|
|
|
60
63
|
def to_h
|
|
@@ -68,37 +71,39 @@ module Edupage
|
|
|
68
71
|
private
|
|
69
72
|
|
|
70
73
|
def resolved_username
|
|
71
|
-
@resolved_username ||=
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
end
|
|
74
|
+
@resolved_username ||=
|
|
75
|
+
from(@env, :username) ||
|
|
76
|
+
wrap(@username_override, "--username") ||
|
|
77
|
+
wrap(@config.default_username, "config") ||
|
|
78
|
+
Resolved.new(value: nil, source: nil)
|
|
77
79
|
end
|
|
78
80
|
|
|
79
81
|
def resolved_password
|
|
80
82
|
@resolved_password ||=
|
|
81
|
-
|
|
83
|
+
from(@env, :password) ||
|
|
84
|
+
from(system_adapter, :password, username: resolved_username.value) ||
|
|
82
85
|
Resolved.new(value: nil, source: nil)
|
|
83
86
|
end
|
|
84
87
|
|
|
85
88
|
def resolved_school
|
|
86
|
-
@resolved_school ||=
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
end
|
|
89
|
+
@resolved_school ||=
|
|
90
|
+
from(@env, :school) ||
|
|
91
|
+
wrap(@school_override, "--school") ||
|
|
92
|
+
wrap(@config.default_school, "config") ||
|
|
93
|
+
Resolved.new(value: nil, source: nil)
|
|
92
94
|
end
|
|
93
95
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
@
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
nil
|
|
96
|
+
def system_adapter
|
|
97
|
+
return @system_adapter if defined?(@system_adapter)
|
|
98
|
+
|
|
99
|
+
@system_adapter = @system.call
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def from(adapter, field, **args)
|
|
103
|
+
return nil unless adapter
|
|
104
|
+
|
|
105
|
+
value = adapter.public_send(field, **args)
|
|
106
|
+
Resolved.new(value: value, source: adapter.source_for(field)) if value && !value.empty?
|
|
102
107
|
end
|
|
103
108
|
|
|
104
109
|
def wrap(value, source)
|
|
@@ -108,10 +113,10 @@ module Edupage
|
|
|
108
113
|
end
|
|
109
114
|
|
|
110
115
|
def missing_password_message
|
|
111
|
-
if
|
|
112
|
-
"No password. Run `edupage login
|
|
116
|
+
if (adapter = system_adapter)
|
|
117
|
+
"No password. Run `edupage login` to store it in the #{adapter.display_name}, or set EDUPAGE_PASSWORD."
|
|
113
118
|
else
|
|
114
|
-
"No password and
|
|
119
|
+
"No password and no OS credential store is available on #{RUBY_PLATFORM}. Set EDUPAGE_PASSWORD."
|
|
115
120
|
end
|
|
116
121
|
end
|
|
117
122
|
end
|
data/lib/edupage/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: edupage-cli
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ahmed Al Hafoudh
|
|
@@ -9,6 +9,20 @@ bindir: exe
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: fiddle
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.1'
|
|
12
26
|
- !ruby/object:Gem::Dependency
|
|
13
27
|
name: mcp
|
|
14
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -130,6 +144,7 @@ executables:
|
|
|
130
144
|
extensions: []
|
|
131
145
|
extra_rdoc_files: []
|
|
132
146
|
files:
|
|
147
|
+
- LICENSE
|
|
133
148
|
- README.md
|
|
134
149
|
- exe/edupage
|
|
135
150
|
- lib/edupage.rb
|
|
@@ -141,8 +156,12 @@ files:
|
|
|
141
156
|
- lib/edupage/client.rb
|
|
142
157
|
- lib/edupage/config.rb
|
|
143
158
|
- lib/edupage/credentials.rb
|
|
144
|
-
- lib/edupage/credentials/
|
|
145
|
-
- lib/edupage/credentials/
|
|
159
|
+
- lib/edupage/credentials/adapter.rb
|
|
160
|
+
- lib/edupage/credentials/adapters/env.rb
|
|
161
|
+
- lib/edupage/credentials/adapters/macos_keychain.rb
|
|
162
|
+
- lib/edupage/credentials/adapters/secret_service.rb
|
|
163
|
+
- lib/edupage/credentials/adapters/windows_credential_manager.rb
|
|
164
|
+
- lib/edupage/credentials/system_adapter.rb
|
|
146
165
|
- lib/edupage/errors.rb
|
|
147
166
|
- lib/edupage/model.rb
|
|
148
167
|
- lib/edupage/models/assignment.rb
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
module Edupage
|
|
2
|
-
class Credentials
|
|
3
|
-
# Highest-priority provider: plain environment variables.
|
|
4
|
-
class Env
|
|
5
|
-
VARS = {
|
|
6
|
-
username: "EDUPAGE_USERNAME",
|
|
7
|
-
password: "EDUPAGE_PASSWORD",
|
|
8
|
-
school: "EDUPAGE_SCHOOL"
|
|
9
|
-
}.freeze
|
|
10
|
-
|
|
11
|
-
def initialize(env = ENV)
|
|
12
|
-
@env = env
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def username(**) = @env[VARS[:username]]
|
|
16
|
-
def password(**) = @env[VARS[:password]]
|
|
17
|
-
|
|
18
|
-
# Accepts either "zsdemo" or "zsdemo.edupage.org".
|
|
19
|
-
def school(**)
|
|
20
|
-
value = @env[VARS[:school]]
|
|
21
|
-
value&.sub(/\.edupage\.org\z/, "")
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def source_for(field) = "ENV: #{VARS[field]}"
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
require "open3"
|
|
2
|
-
require "rbconfig"
|
|
3
|
-
|
|
4
|
-
module Edupage
|
|
5
|
-
class Credentials
|
|
6
|
-
# Secondary provider: the macOS keychain, driven through /usr/bin/security.
|
|
7
|
-
#
|
|
8
|
-
# The password is never passed in argv (where `ps` would expose it). `security`
|
|
9
|
-
# reads it from stdin instead, prompting twice, so writes send the value twice.
|
|
10
|
-
#
|
|
11
|
-
# `security add-generic-password` exits 0 even when the two reads disagree, storing
|
|
12
|
-
# an empty password - so #store always reads the value back before reporting success.
|
|
13
|
-
class Keychain
|
|
14
|
-
SERVICE = "edupage-cli".freeze
|
|
15
|
-
SECURITY = "/usr/bin/security".freeze
|
|
16
|
-
# Runs a command in a new session, without a controlling terminal. macOS ships no
|
|
17
|
-
# setsid(1), so Ruby itself does the setsid before exec.
|
|
18
|
-
DETACH = [RbConfig.ruby, "-e", "Process.setsid; exec(*ARGV)"].freeze
|
|
19
|
-
|
|
20
|
-
class << self
|
|
21
|
-
def available?
|
|
22
|
-
RUBY_PLATFORM.include?("darwin") && File.executable?(SECURITY)
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def initialize(service: SERVICE)
|
|
27
|
-
@service = service
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# Only the password lives in the keychain; the other fields come from ENV or config.
|
|
31
|
-
def username(**) = nil
|
|
32
|
-
def school(**) = nil
|
|
33
|
-
|
|
34
|
-
def password(username: nil, **)
|
|
35
|
-
return nil if username.nil? || username.empty?
|
|
36
|
-
return nil unless self.class.available?
|
|
37
|
-
|
|
38
|
-
# -g, not -w: for non-ASCII passwords `-w` prints bare hex, which is
|
|
39
|
-
# indistinguishable from an ASCII password that happens to look like hex
|
|
40
|
-
# ("deadbeef"). -g prefixes the hex form with 0x, so it can be told apart.
|
|
41
|
-
_out, err, status = Open3.capture3(
|
|
42
|
-
SECURITY, "find-generic-password", "-s", @service, "-a", username, "-g"
|
|
43
|
-
)
|
|
44
|
-
return nil unless status.success?
|
|
45
|
-
|
|
46
|
-
value = parse_password(err)
|
|
47
|
-
value && !value.empty? ? value : nil
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def store(username:, password:)
|
|
51
|
-
ensure_available!
|
|
52
|
-
secret = password.to_s
|
|
53
|
-
raise ArgumentError, "password must not be empty" if secret.empty?
|
|
54
|
-
|
|
55
|
-
# -w last with no value: `security` prompts on stdin rather than taking argv.
|
|
56
|
-
# It prefers /dev/tty over stdin when it has a terminal, so it runs detached
|
|
57
|
-
# from ours; otherwise it would ignore stdin_data and prompt the user instead.
|
|
58
|
-
_out, err, status = Open3.capture3(
|
|
59
|
-
*DETACH, SECURITY, "add-generic-password", "-s", @service, "-a", username,
|
|
60
|
-
"-l", "#{@service} (#{username})", "-U", "-w",
|
|
61
|
-
stdin_data: "#{secret}\n#{secret}\n"
|
|
62
|
-
)
|
|
63
|
-
raise Error, "Failed to store password in keychain: #{err.strip}" unless status.success?
|
|
64
|
-
|
|
65
|
-
# Guard against the silent-empty-write path described above.
|
|
66
|
-
unless password(username: username) == secret
|
|
67
|
-
raise Error, "Keychain reported success but stored a different password"
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
true
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def delete(username:)
|
|
74
|
-
ensure_available!
|
|
75
|
-
|
|
76
|
-
_out, _err, status = Open3.capture3(
|
|
77
|
-
SECURITY, "delete-generic-password", "-s", @service, "-a", username
|
|
78
|
-
)
|
|
79
|
-
status.success?
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def stored?(username:)
|
|
83
|
-
!password(username: username).nil?
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def source_for(_field) = "keychain"
|
|
87
|
-
|
|
88
|
-
private
|
|
89
|
-
|
|
90
|
-
# `security -g` writes one of these to stderr:
|
|
91
|
-
# password: "plain-ascii"
|
|
92
|
-
# password: 0x73336372 "s3cr3t-\303\241"
|
|
93
|
-
# The hex form is exact bytes, so it is preferred whenever present.
|
|
94
|
-
def parse_password(stderr)
|
|
95
|
-
line = stderr.lines.find { |l| l.start_with?("password:") }
|
|
96
|
-
return nil unless line
|
|
97
|
-
|
|
98
|
-
if (hex = line[/password: 0x([0-9A-Fa-f]+)/, 1])
|
|
99
|
-
[hex].pack("H*").force_encoding(Encoding::UTF_8)
|
|
100
|
-
elsif (quoted = line[/password: "(.*)"\s*\z/m, 1])
|
|
101
|
-
unescape(quoted)
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def unescape(str)
|
|
106
|
-
str.gsub(/\\(\d{3}|.)/) do
|
|
107
|
-
esc = Regexp.last_match(1)
|
|
108
|
-
esc.match?(/\A\d{3}\z/) ? esc.to_i(8).chr : esc
|
|
109
|
-
end.force_encoding(Encoding::UTF_8)
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def ensure_available!
|
|
113
|
-
return if self.class.available?
|
|
114
|
-
|
|
115
|
-
raise UnsupportedPlatformError,
|
|
116
|
-
"The macOS keychain is not available on #{RUBY_PLATFORM}. Use EDUPAGE_PASSWORD instead."
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
end
|