Skip to main content

atmos/os_lib/
cloud.rs

1//! Phase 7 外部連携: OAuth2 + Google Drive / GitHub の REST クライアント。
2//!
3//! ヘッドレス OS のため OAuth2 は **デバイスフロー**(ブラウザ無しで user_code を表示し、
4//! 別端末で認可 → token をポーリング取得)を用いる。リクエスト構築と JSON レスポンス解析は
5//! 純関数として `cloud::selftest()` で検証する。実際の HTTPS 呼び出しは `tls::https_request`
6//! 経由で行う(実機/ネットワーク必須のため QEMU 自己テストでは解析部のみを確認)。
7
8#![allow(dead_code)]
9
10use crate::os_lib::json::{self, JsonValue};
11use alloc::format;
12use alloc::string::{String, ToString};
13use alloc::vec::Vec;
14
15// ============ JSON アクセスヘルパ ============
16
17fn obj_get<'a>(v: &'a JsonValue, key: &str) -> Option<&'a JsonValue> {
18    if let JsonValue::Object(m) = v {
19        m.get(key)
20    } else {
21        None
22    }
23}
24
25fn val_to_string(v: &JsonValue) -> Option<String> {
26    match v {
27        JsonValue::String(s) => Some(s.clone()),
28        JsonValue::Number(n) => Some(crate::os_lib::js::value::fmt_number(*n)),
29        JsonValue::Bool(b) => Some(b.to_string()),
30        _ => None,
31    }
32}
33
34fn field_str(v: &JsonValue, key: &str) -> String {
35    obj_get(v, key).and_then(val_to_string).unwrap_or_default()
36}
37
38/// 複数キー候補のうち最初に見つかった文字列。
39fn field_str_any(v: &JsonValue, keys: &[&str]) -> String {
40    for k in keys {
41        if let Some(found) = obj_get(v, k).and_then(val_to_string) {
42            return found;
43        }
44    }
45    String::new()
46}
47
48fn field_i64(v: &JsonValue, key: &str) -> i64 {
49    match obj_get(v, key) {
50        Some(JsonValue::Number(n)) => *n as i64,
51        Some(JsonValue::String(s)) => s.parse().unwrap_or(0),
52        _ => 0,
53    }
54}
55
56fn field_bool(v: &JsonValue, key: &str) -> bool {
57    matches!(obj_get(v, key), Some(JsonValue::Bool(true)))
58}
59
60// ============ URL / フォームエンコード ============
61
62/// パーセントエンコード(unreserved 以外を %XX に)。
63pub fn url_encode(s: &str) -> String {
64    let mut out = String::new();
65    for b in s.bytes() {
66        let c = b as char;
67        if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~') {
68            out.push(c);
69        } else {
70            out.push('%');
71            out.push(hex_digit(b >> 4));
72            out.push(hex_digit(b & 0xF));
73        }
74    }
75    out
76}
77
78fn hex_digit(n: u8) -> char {
79    match n {
80        0..=9 => (b'0' + n) as char,
81        _ => (b'A' + (n - 10)) as char,
82    }
83}
84
85/// `application/x-www-form-urlencoded` ボディを組み立てる。
86pub fn form_encode(params: &[(&str, &str)]) -> String {
87    let mut parts: Vec<String> = Vec::new();
88    for (k, v) in params {
89        parts.push(format!("{}={}", url_encode(k), url_encode(v)));
90    }
91    parts.join("&")
92}
93
94// ============ OAuth2 ============
95
96#[derive(Default, Debug)]
97pub struct TokenResponse {
98    pub access_token: String,
99    pub refresh_token: String,
100    pub token_type: String,
101    pub expires_in: i64,
102    pub scope: String,
103    /// `authorization_pending` 等のエラーコード(成功時は空)。
104    pub error: String,
105}
106
107pub fn parse_token_response(body: &str) -> TokenResponse {
108    let v = match json::parse(body) {
109        Ok(v) => v,
110        Err(_) => return TokenResponse::default(),
111    };
112    TokenResponse {
113        access_token: field_str(&v, "access_token"),
114        refresh_token: field_str(&v, "refresh_token"),
115        token_type: field_str(&v, "token_type"),
116        expires_in: field_i64(&v, "expires_in"),
117        scope: field_str(&v, "scope"),
118        error: field_str(&v, "error"),
119    }
120}
121
122#[derive(Default, Debug)]
123pub struct DeviceCode {
124    pub device_code: String,
125    pub user_code: String,
126    pub verification_url: String,
127    pub expires_in: i64,
128    pub interval: i64,
129}
130
131pub fn parse_device_code(body: &str) -> DeviceCode {
132    let v = match json::parse(body) {
133        Ok(v) => v,
134        Err(_) => return DeviceCode::default(),
135    };
136    DeviceCode {
137        device_code: field_str(&v, "device_code"),
138        user_code: field_str(&v, "user_code"),
139        // Google は verification_url、GitHub は verification_uri。
140        verification_url: field_str_any(&v, &["verification_url", "verification_uri"]),
141        expires_in: field_i64(&v, "expires_in"),
142        interval: field_i64(&v, "interval"),
143    }
144}
145
146// ---- リクエストボディ構築 ----
147
148pub fn google_device_code_body(client_id: &str, scope: &str) -> String {
149    form_encode(&[("client_id", client_id), ("scope", scope)])
150}
151
152pub fn google_token_body(client_id: &str, client_secret: &str, device_code: &str) -> String {
153    form_encode(&[
154        ("client_id", client_id),
155        ("client_secret", client_secret),
156        ("device_code", device_code),
157        ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
158    ])
159}
160
161pub fn google_refresh_body(client_id: &str, client_secret: &str, refresh_token: &str) -> String {
162    form_encode(&[
163        ("client_id", client_id),
164        ("client_secret", client_secret),
165        ("refresh_token", refresh_token),
166        ("grant_type", "refresh_token"),
167    ])
168}
169
170pub fn github_device_code_body(client_id: &str, scope: &str) -> String {
171    form_encode(&[("client_id", client_id), ("scope", scope)])
172}
173
174pub fn github_token_body(client_id: &str, device_code: &str) -> String {
175    form_encode(&[
176        ("client_id", client_id),
177        ("device_code", device_code),
178        ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
179    ])
180}
181
182// ============ HTTPS 実呼び出し(ネットワーク必須) ============
183
184/// OAuth2 トークン/デバイスコードエンドポイントへ form POST し、レスポンス本文を返す。
185pub fn oauth_post(host: &str, path: &str, form: &str) -> Result<String, &'static str> {
186    let res = crate::kernel::tls::https_request(
187        "POST",
188        host,
189        path,
190        &[("Accept", "application/json")],
191        "application/x-www-form-urlencoded",
192        Some(form.as_bytes()),
193    )?;
194    Ok(res.body)
195}
196
197/// Bearer トークン付き API GET。
198pub fn api_get(host: &str, path: &str, token: &str) -> Result<String, &'static str> {
199    let auth = format!("Bearer {}", token);
200    let res = crate::kernel::tls::https_request(
201        "GET",
202        host,
203        path,
204        &[("Authorization", &auth), ("Accept", "application/json")],
205        "",
206        None,
207    )?;
208    Ok(res.body)
209}
210
211// ============ Google Drive ============
212
213#[derive(Default, Debug, Clone)]
214pub struct DriveFile {
215    pub id: String,
216    pub name: String,
217    pub mime_type: String,
218}
219
220pub fn parse_drive_file_list(body: &str) -> Vec<DriveFile> {
221    let v = match json::parse(body) {
222        Ok(v) => v,
223        Err(_) => return Vec::new(),
224    };
225    let mut out = Vec::new();
226    if let Some(JsonValue::Array(files)) = obj_get(&v, "files") {
227        for f in files {
228            out.push(DriveFile {
229                id: field_str(f, "id"),
230                name: field_str(f, "name"),
231                mime_type: field_str(f, "mimeType"),
232            });
233        }
234    }
235    out
236}
237
238/// Google Drive のファイル一覧(live)。
239pub fn drive_list_files(token: &str) -> Result<Vec<DriveFile>, &'static str> {
240    let body = api_get(
241        "www.googleapis.com",
242        "/drive/v3/files?pageSize=100&fields=files(id,name,mimeType)",
243        token,
244    )?;
245    Ok(parse_drive_file_list(&body))
246}
247
248/// 指定フォルダ直下のファイル一覧。`folder_id` が None なら Drive ルート直下。
249/// フォルダ階層ナビゲーション用(drive アプリの Enter でフォルダに入る操作)。
250pub fn drive_list_files_in_folder(
251    token: &str,
252    folder_id: Option<&str>,
253) -> Result<Vec<DriveFile>, &'static str> {
254    let parent = folder_id.unwrap_or("root");
255    let q = format!("'{}' in parents and trashed = false", parent);
256    let path = format!(
257        "/drive/v3/files?pageSize=100&fields=files(id,name,mimeType)&q={}",
258        url_encode(&q)
259    );
260    let body = api_get("www.googleapis.com", &path, token)?;
261    Ok(parse_drive_file_list(&body))
262}
263
264// ---- 読み書き REST 操作 ----
265
266const GOOGLE_API_HOST: &str = "www.googleapis.com";
267/// Google ネイティブ Docs の MIME(本文取得は export が必要)。
268pub const MIME_GOOGLE_DOC: &str = "application/vnd.google-apps.document";
269/// Google Drive のフォルダ MIME。
270pub const MIME_FOLDER: &str = "application/vnd.google-apps.folder";
271
272/// Bearer トークン付き API 送信(GET 以外の任意メソッド + ボディ)。
273fn api_send(
274    method: &str,
275    host: &str,
276    path: &str,
277    token: &str,
278    content_type: &str,
279    body: Option<&[u8]>,
280) -> Result<crate::kernel::net::HttpsGetResult, &'static str> {
281    let auth = format!("Bearer {}", token);
282    crate::kernel::tls::https_request(
283        method,
284        host,
285        path,
286        &[("Authorization", &auth), ("Accept", "application/json")],
287        content_type,
288        body,
289    )
290}
291
292/// HTTP ステータスが 2xx かを判定。失敗時はステータスを含むエラーにしたいが
293/// `&'static str` 制約のため、代表的なケースを分岐する。
294fn ok_or_status(res: &crate::kernel::net::HttpsGetResult) -> Result<(), &'static str> {
295    match res.status_code {
296        200..=299 => Ok(()),
297        401 => Err("unauthorized (token expired?)"),
298        403 => Err("forbidden (scope/quota)"),
299        404 => Err("not found"),
300        _ => Err("drive API error"),
301    }
302}
303
304/// 通常ファイル(text/plain 等)の本文をダウンロード(`alt=media`)。
305/// バイナリは https_request が UTF-8 lossy になるため、テキスト用途のみ想定。
306pub fn drive_download_text(token: &str, file_id: &str) -> Result<String, &'static str> {
307    let path = format!("/drive/v3/files/{}?alt=media", url_encode(file_id));
308    let res = api_send("GET", GOOGLE_API_HOST, &path, token, "", None)?;
309    ok_or_status(&res)?;
310    Ok(res.body)
311}
312
313/// ファイル本文を生バイト列でダウンロード(`alt=media`)。画像/zip 等のバイナリ用。
314/// `HttpsGetResult.body_bytes` を使うため lossy UTF-8 変換によるデータ破損が起きない。
315pub fn drive_download_binary(token: &str, file_id: &str) -> Result<Vec<u8>, &'static str> {
316    let path = format!("/drive/v3/files/{}?alt=media", url_encode(file_id));
317    let res = api_send("GET", GOOGLE_API_HOST, &path, token, "", None)?;
318    ok_or_status(&res)?;
319    Ok(res.body_bytes)
320}
321
322/// Google ネイティブ Docs をプレーンテキストとして export して取得。
323pub fn drive_export_doc_text(token: &str, file_id: &str) -> Result<String, &'static str> {
324    let path = format!(
325        "/drive/v3/files/{}/export?mimeType=text%2Fplain",
326        url_encode(file_id)
327    );
328    let res = api_send("GET", GOOGLE_API_HOST, &path, token, "", None)?;
329    ok_or_status(&res)?;
330    Ok(res.body)
331}
332
333/// MIME に応じて Docs は export、それ以外は alt=media でダウンロード。
334pub fn drive_get_text(token: &str, file_id: &str, mime_type: &str) -> Result<String, &'static str> {
335    if mime_type == MIME_GOOGLE_DOC {
336        drive_export_doc_text(token, file_id)
337    } else {
338        drive_download_text(token, file_id)
339    }
340}
341
342/// ファイルの mimeType のみを取得(CUI の `drive cat <id>` のように mime_type を
343/// 事前に知らない呼び出し元向け。mime に応じた export/download 分岐に使う)。
344pub fn drive_get_mime_type(token: &str, file_id: &str) -> Result<String, &'static str> {
345    let path = format!(
346        "/drive/v3/files/{}?fields=mimeType",
347        url_encode(file_id)
348    );
349    let body = api_get(GOOGLE_API_HOST, &path, token)?;
350    let v = json::parse(&body).unwrap_or(JsonValue::Null);
351    Ok(field_str(&v, "mimeType"))
352}
353
354/// file_id だけから本文を取得する(mime_type をまず問い合わせてから分岐)。
355/// CUI コマンド(`drive cat <id>`)向けの一括ヘルパー。
356pub fn drive_cat(token: &str, file_id: &str) -> Result<String, &'static str> {
357    let mime_type = drive_get_mime_type(token, file_id)?;
358    drive_get_text(token, file_id, &mime_type)
359}
360
361/// JSON 文字列値のエスケープ(メタデータ部のファイル名用、最小限)。
362fn json_escape(s: &str) -> String {
363    let mut out = String::new();
364    for c in s.chars() {
365        match c {
366            '"' => out.push_str("\\\""),
367            '\\' => out.push_str("\\\\"),
368            '\n' => out.push_str("\\n"),
369            '\r' => out.push_str("\\r"),
370            '\t' => out.push_str("\\t"),
371            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
372            c => out.push(c),
373        }
374    }
375    out
376}
377
378/// multipart/related ボディを組み立てる(メタデータ JSON + メディア本文)。
379/// 返り値は (boundary, body_bytes)。Content-Type に boundary を載せる必要がある。
380pub fn drive_multipart_body(name: &str, mime_type: &str, content: &[u8]) -> (String, Vec<u8>) {
381    // 衝突しにくい固定 boundary(本文に出現しない前提の十分長い文字列)。
382    let boundary = String::from("atmos_drive_boundary_7f3a9c1e2b");
383    let meta = format!("{{\"name\":\"{}\"}}", json_escape(name));
384
385    let mut body: Vec<u8> = Vec::new();
386    let push = |s: &str, b: &mut Vec<u8>| b.extend_from_slice(s.as_bytes());
387
388    push(&format!("--{}\r\n", boundary), &mut body);
389    push(
390        "Content-Type: application/json; charset=UTF-8\r\n\r\n",
391        &mut body,
392    );
393    push(&meta, &mut body);
394    push("\r\n", &mut body);
395    push(&format!("--{}\r\n", boundary), &mut body);
396    push(&format!("Content-Type: {}\r\n\r\n", mime_type), &mut body);
397    body.extend_from_slice(content);
398    push(&format!("\r\n--{}--\r\n", boundary), &mut body);
399
400    (boundary, body)
401}
402
403/// 新規ファイルをアップロード(multipart:メタデータ + 本文)。成功時は作成された file id。
404pub fn drive_create_text(token: &str, name: &str, content: &str) -> Result<String, &'static str> {
405    let (boundary, body) = drive_multipart_body(name, "text/plain", content.as_bytes());
406    let ctype = format!("multipart/related; boundary={}", boundary);
407    let res = api_send(
408        "POST",
409        GOOGLE_API_HOST,
410        "/upload/drive/v3/files?uploadType=multipart&fields=id",
411        token,
412        &ctype,
413        Some(&body),
414    )?;
415    ok_or_status(&res)?;
416    Ok(field_str(
417        &json::parse(&res.body).unwrap_or(JsonValue::Null),
418        "id",
419    ))
420}
421
422/// 既存ファイルの本文を上書き(メディアのみ PATCH)。
423pub fn drive_update_text(token: &str, file_id: &str, content: &str) -> Result<(), &'static str> {
424    let path = format!(
425        "/upload/drive/v3/files/{}?uploadType=media",
426        url_encode(file_id)
427    );
428    let res = api_send(
429        "PATCH",
430        GOOGLE_API_HOST,
431        &path,
432        token,
433        "text/plain",
434        Some(content.as_bytes()),
435    )?;
436    ok_or_status(&res)
437}
438
439/// ファイルを削除。
440pub fn drive_delete(token: &str, file_id: &str) -> Result<(), &'static str> {
441    let path = format!("/drive/v3/files/{}", url_encode(file_id));
442    let res = api_send("DELETE", GOOGLE_API_HOST, &path, token, "", None)?;
443    // 削除成功は 204 No Content。
444    ok_or_status(&res)
445}
446
447// ============ Google Drive 認証情報の永続化 + デバイスフロー ============
448
449const AUTH_FILE: &str = "gdrive_auth.json";
450const GOOGLE_OAUTH_HOST: &str = "oauth2.googleapis.com";
451/// Drive 読み書きフルスコープ。
452pub const DRIVE_SCOPE: &str = "https://www.googleapis.com/auth/drive";
453
454/// ディスクに保存する Drive 認証状態。client_id/secret はユーザーが Google Cloud で
455/// 発行したものを事前に書き込んでおく(このアプリからは編集しない)。token 類は
456/// デバイスフロー成功後に書き戻す。
457#[derive(Default, Debug, Clone)]
458pub struct GdriveAuth {
459    pub client_id: String,
460    pub client_secret: String,
461    pub access_token: String,
462    pub refresh_token: String,
463}
464
465impl GdriveAuth {
466    pub fn has_client(&self) -> bool {
467        !self.client_id.is_empty() && !self.client_secret.is_empty()
468    }
469    pub fn is_logged_in(&self) -> bool {
470        !self.access_token.is_empty()
471    }
472}
473
474pub fn auth_to_json(a: &GdriveAuth) -> String {
475    format!(
476        "{{\"client_id\":\"{}\",\"client_secret\":\"{}\",\"access_token\":\"{}\",\"refresh_token\":\"{}\"}}",
477        json_escape(&a.client_id), json_escape(&a.client_secret),
478        json_escape(&a.access_token), json_escape(&a.refresh_token)
479    )
480}
481
482pub fn parse_auth(body: &str) -> GdriveAuth {
483    let v = json::parse(body).unwrap_or(JsonValue::Null);
484    GdriveAuth {
485        client_id: field_str(&v, "client_id"),
486        client_secret: field_str(&v, "client_secret"),
487        access_token: field_str(&v, "access_token"),
488        refresh_token: field_str(&v, "refresh_token"),
489    }
490}
491
492/// `gdrive_auth.json` を読む(無ければ default)。
493pub fn load_auth() -> GdriveAuth {
494    let fs = crate::kernel::fs::get_fs();
495    if let Some((_m, bytes)) = fs.read_file(AUTH_FILE) {
496        if let Ok(s) = core::str::from_utf8(&bytes) {
497            return parse_auth(s);
498        }
499    }
500    GdriveAuth::default()
501}
502
503/// `gdrive_auth.json` に書き戻す。
504pub fn save_auth(a: &GdriveAuth) -> Result<(), &'static str> {
505    let json = auth_to_json(a);
506    let fs = crate::kernel::fs::get_fs();
507    fs.save_file(AUTH_FILE, json.as_bytes(), "system,auth")
508}
509
510/// デバイスコードを要求(ユーザーに user_code と verification_url を提示するため)。
511pub fn drive_request_device_code(client_id: &str) -> Result<DeviceCode, &'static str> {
512    let body = google_device_code_body(client_id, DRIVE_SCOPE);
513    let resp = oauth_post(GOOGLE_OAUTH_HOST, "/device/code", &body)?;
514    Ok(parse_device_code(&resp))
515}
516
517/// device_code でトークンを1回ポーリングする。`authorization_pending` の間は
518/// TokenResponse.error にコードが入る(成功時は access_token が埋まる)。
519pub fn drive_poll_token(
520    client_id: &str,
521    client_secret: &str,
522    device_code: &str,
523) -> Result<TokenResponse, &'static str> {
524    let body = google_token_body(client_id, client_secret, device_code);
525    let resp = oauth_post(GOOGLE_OAUTH_HOST, "/token", &body)?;
526    Ok(parse_token_response(&resp))
527}
528
529/// refresh_token から access_token を更新する。
530pub fn drive_refresh_token(
531    client_id: &str,
532    client_secret: &str,
533    refresh_token: &str,
534) -> Result<TokenResponse, &'static str> {
535    let body = google_refresh_body(client_id, client_secret, refresh_token);
536    let resp = oauth_post(GOOGLE_OAUTH_HOST, "/token", &body)?;
537    Ok(parse_token_response(&resp))
538}
539
540// ============ Microsoft OneDrive (Graph API) ============
541//
542// Google Drive と同じデバイスフロー方式。Microsoft の device flow はパブリック
543// クライアント向けのため client_secret を要求しない点が Google と異なる(よりシンプル)。
544
545pub fn ms_device_code_body(client_id: &str, scope: &str) -> String {
546    form_encode(&[("client_id", client_id), ("scope", scope)])
547}
548
549pub fn ms_token_body(client_id: &str, device_code: &str) -> String {
550    form_encode(&[
551        ("client_id", client_id),
552        ("device_code", device_code),
553        ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
554    ])
555}
556
557pub fn ms_refresh_body(client_id: &str, refresh_token: &str, scope: &str) -> String {
558    form_encode(&[
559        ("client_id", client_id),
560        ("refresh_token", refresh_token),
561        ("grant_type", "refresh_token"),
562        ("scope", scope),
563    ])
564}
565
566const MS_OAUTH_HOST: &str = "login.microsoftonline.com";
567const GRAPH_API_HOST: &str = "graph.microsoft.com";
568/// OneDrive 読み書き + オフラインアクセス(refresh_token 発行)に必要な最小スコープ。
569pub const ONEDRIVE_SCOPE: &str = "Files.ReadWrite offline_access";
570
571pub fn onedrive_request_device_code(client_id: &str) -> Result<DeviceCode, &'static str> {
572    let body = ms_device_code_body(client_id, ONEDRIVE_SCOPE);
573    let resp = oauth_post(MS_OAUTH_HOST, "/common/oauth2/v2.0/devicecode", &body)?;
574    Ok(parse_device_code(&resp))
575}
576
577/// device_code でトークンを1回ポーリングする。Google 版と同じ挙動(pending 中は
578/// TokenResponse.error にコードが入る)。
579pub fn onedrive_poll_token(client_id: &str, device_code: &str) -> Result<TokenResponse, &'static str> {
580    let body = ms_token_body(client_id, device_code);
581    let resp = oauth_post(MS_OAUTH_HOST, "/common/oauth2/v2.0/token", &body)?;
582    Ok(parse_token_response(&resp))
583}
584
585pub fn onedrive_refresh_token(
586    client_id: &str,
587    refresh_token: &str,
588) -> Result<TokenResponse, &'static str> {
589    let body = ms_refresh_body(client_id, refresh_token, ONEDRIVE_SCOPE);
590    let resp = oauth_post(MS_OAUTH_HOST, "/common/oauth2/v2.0/token", &body)?;
591    Ok(parse_token_response(&resp))
592}
593
594#[derive(Default, Debug, Clone)]
595pub struct OneDriveFile {
596    pub id: String,
597    pub name: String,
598    pub is_folder: bool,
599}
600
601fn parse_onedrive_file_list(body: &str) -> Vec<OneDriveFile> {
602    let v = match json::parse(body) {
603        Ok(v) => v,
604        Err(_) => return Vec::new(),
605    };
606    let mut out = Vec::new();
607    if let Some(JsonValue::Array(items)) = obj_get(&v, "value") {
608        for it in items {
609            out.push(OneDriveFile {
610                id: field_str(it, "id"),
611                name: field_str(it, "name"),
612                // フォルダかどうかは "folder" プロパティの有無で判定(Graph API の慣例)。
613                is_folder: obj_get(it, "folder").is_some(),
614            });
615        }
616    }
617    out
618}
619
620/// 指定フォルダ直下のアイテム一覧。`folder_id` が None なら OneDrive ルート直下。
621pub fn onedrive_list_files_in_folder(
622    token: &str,
623    folder_id: Option<&str>,
624) -> Result<Vec<OneDriveFile>, &'static str> {
625    let path = match folder_id {
626        Some(id) => format!("/v1.0/me/drive/items/{}/children", url_encode(id)),
627        None => String::from("/v1.0/me/drive/root/children"),
628    };
629    let body = api_get(GRAPH_API_HOST, &path, token)?;
630    Ok(parse_onedrive_file_list(&body))
631}
632
633/// `https://host/path` 形式の絶対URLを (host, path) に分解する(リダイレクト追従用の簡易パーサ)。
634fn split_https_url(url: &str) -> Option<(String, String)> {
635    let rest = url.strip_prefix("https://")?;
636    #[allow(clippy::string_slice)] // find() で ASCII バイト位置を特定済み
637    match rest.find('/') {
638        Some(idx) => Some((String::from(&rest[..idx]), String::from(&rest[idx..]))),
639        None => Some((String::from(rest), String::from("/"))),
640    }
641}
642
643/// ファイル本文を生バイト列でダウンロード。Graph API はしばしば 302 で
644/// 署名付きCDN URLへリダイレクトするため、その場合は Authorization ヘッダを付けずに
645/// リダイレクト先へ再取得する(署名済みURLは通常トークン不要かつ拒否されることがある)。
646pub fn onedrive_download_binary(token: &str, file_id: &str) -> Result<Vec<u8>, &'static str> {
647    let path = format!("/v1.0/me/drive/items/{}/content", url_encode(file_id));
648    let res = api_send("GET", GRAPH_API_HOST, &path, token, "", None)?;
649    if matches!(res.status_code, 301 | 302 | 303 | 307 | 308) {
650        let location = res
651            .headers
652            .get("location")
653            .cloned()
654            .unwrap_or_default();
655        let (host, redir_path) =
656            split_https_url(&location).ok_or("onedrive: invalid redirect location")?;
657        let res2 = crate::kernel::tls::https_request("GET", &host, &redir_path, &[], "", None)?;
658        ok_or_status(&res2)?;
659        return Ok(res2.body_bytes);
660    }
661    ok_or_status(&res)?;
662    Ok(res.body_bytes)
663}
664
665/// 新規ファイルをアップロード(4MB未満の単純 PUT。folder_id=None ならルート直下)。
666/// 同名ファイルが既にあれば上書きされる(Graph API のデフォルト挙動)。成功時は item id。
667pub fn onedrive_upload_binary(
668    token: &str,
669    folder_id: Option<&str>,
670    name: &str,
671    content: &[u8],
672) -> Result<String, &'static str> {
673    let path = match folder_id {
674        Some(id) => format!(
675            "/v1.0/me/drive/items/{}:/{}:/content",
676            url_encode(id),
677            url_encode(name)
678        ),
679        None => format!("/v1.0/me/drive/root:/{}:/content", url_encode(name)),
680    };
681    let res = api_send(
682        "PUT",
683        GRAPH_API_HOST,
684        &path,
685        token,
686        "application/octet-stream",
687        Some(content),
688    )?;
689    ok_or_status(&res)?;
690    Ok(field_str(
691        &json::parse(&res.body).unwrap_or(JsonValue::Null),
692        "id",
693    ))
694}
695
696/// ファイルを削除。
697pub fn onedrive_delete(token: &str, file_id: &str) -> Result<(), &'static str> {
698    let path = format!("/v1.0/me/drive/items/{}", url_encode(file_id));
699    let res = api_send("DELETE", GRAPH_API_HOST, &path, token, "", None)?;
700    ok_or_status(&res)
701}
702
703// ============ Microsoft OneDrive 認証情報の永続化 ============
704
705const MS_AUTH_FILE: &str = "onedrive_auth.json";
706
707/// ディスクに保存する OneDrive 認証状態。Microsoft の device flow はパブリック
708/// クライアント向けで client_secret 不要のため GdriveAuth より単純。
709#[derive(Default, Debug, Clone)]
710pub struct MsAuth {
711    pub client_id: String,
712    pub access_token: String,
713    pub refresh_token: String,
714}
715
716impl MsAuth {
717    pub fn has_client(&self) -> bool {
718        !self.client_id.is_empty()
719    }
720    pub fn is_logged_in(&self) -> bool {
721        !self.access_token.is_empty()
722    }
723}
724
725pub fn ms_auth_to_json(a: &MsAuth) -> String {
726    format!(
727        "{{\"client_id\":\"{}\",\"access_token\":\"{}\",\"refresh_token\":\"{}\"}}",
728        json_escape(&a.client_id),
729        json_escape(&a.access_token),
730        json_escape(&a.refresh_token)
731    )
732}
733
734pub fn parse_ms_auth(body: &str) -> MsAuth {
735    let v = json::parse(body).unwrap_or(JsonValue::Null);
736    MsAuth {
737        client_id: field_str(&v, "client_id"),
738        access_token: field_str(&v, "access_token"),
739        refresh_token: field_str(&v, "refresh_token"),
740    }
741}
742
743/// `onedrive_auth.json` を読む(無ければ default)。
744pub fn load_ms_auth() -> MsAuth {
745    let fs = crate::kernel::fs::get_fs();
746    if let Some((_m, bytes)) = fs.read_file(MS_AUTH_FILE) {
747        if let Ok(s) = core::str::from_utf8(&bytes) {
748            return parse_ms_auth(s);
749        }
750    }
751    MsAuth::default()
752}
753
754/// `onedrive_auth.json` に書き戻す。
755pub fn save_ms_auth(a: &MsAuth) -> Result<(), &'static str> {
756    let json = ms_auth_to_json(a);
757    let fs = crate::kernel::fs::get_fs();
758    fs.save_file(MS_AUTH_FILE, json.as_bytes(), "system,auth")
759}
760
761// ============ GitHub ============
762
763#[derive(Default, Debug)]
764pub struct GitHubUser {
765    pub login: String,
766    pub name: String,
767    pub id: i64,
768}
769
770pub fn parse_github_user(body: &str) -> GitHubUser {
771    let v = match json::parse(body) {
772        Ok(v) => v,
773        Err(_) => return GitHubUser::default(),
774    };
775    GitHubUser {
776        login: field_str(&v, "login"),
777        name: field_str(&v, "name"),
778        id: field_i64(&v, "id"),
779    }
780}
781
782#[derive(Default, Debug, Clone)]
783pub struct Repo {
784    pub name: String,
785    pub full_name: String,
786    pub private: bool,
787}
788
789pub fn parse_github_repos(body: &str) -> Vec<Repo> {
790    let v = match json::parse(body) {
791        Ok(v) => v,
792        Err(_) => return Vec::new(),
793    };
794    let mut out = Vec::new();
795    if let JsonValue::Array(repos) = v {
796        for r in &repos {
797            out.push(Repo {
798                name: field_str(r, "name"),
799                full_name: field_str(r, "full_name"),
800                private: field_bool(r, "private"),
801            });
802        }
803    }
804    out
805}
806
807/// GitHub の認証ユーザ情報(live)。GitHub API は User-Agent 必須(https_request が付与)。
808pub fn github_get_user(token: &str) -> Result<GitHubUser, &'static str> {
809    let body = api_get("api.github.com", "/user", token)?;
810    Ok(parse_github_user(&body))
811}
812
813pub fn github_list_repos(token: &str) -> Result<Vec<Repo>, &'static str> {
814    let body = api_get("api.github.com", "/user/repos?per_page=100", token)?;
815    Ok(parse_github_repos(&body))
816}
817
818// ============ GitHub 認証情報の永続化 + デバイスフロー ============
819
820const GH_AUTH_FILE: &str = "gh_auth.json";
821const GITHUB_HOST: &str = "github.com";
822/// User情報 + リポジトリ一覧に必要な最小スコープ。
823pub const GITHUB_DEFAULT_SCOPE: &str = "repo read:user";
824
825/// ディスクに保存する GitHub 認証状態。GitHub のデバイスフローは client_secret を
826/// 要求しないため GdriveAuth と異なり client_id + token のみ。device_code は
827/// login → poll の間、CUI の別呼び出しをまたいで引き継ぐために保存する。
828#[derive(Default, Debug, Clone)]
829pub struct GithubAuth {
830    pub client_id: String,
831    pub device_code: String,
832    pub access_token: String,
833}
834
835impl GithubAuth {
836    pub fn has_client(&self) -> bool {
837        !self.client_id.is_empty()
838    }
839    pub fn is_logged_in(&self) -> bool {
840        !self.access_token.is_empty()
841    }
842}
843
844pub fn gh_auth_to_json(a: &GithubAuth) -> String {
845    format!(
846        "{{\"client_id\":\"{}\",\"device_code\":\"{}\",\"access_token\":\"{}\"}}",
847        json_escape(&a.client_id),
848        json_escape(&a.device_code),
849        json_escape(&a.access_token)
850    )
851}
852
853pub fn parse_gh_auth(body: &str) -> GithubAuth {
854    let v = json::parse(body).unwrap_or(JsonValue::Null);
855    GithubAuth {
856        client_id: field_str(&v, "client_id"),
857        device_code: field_str(&v, "device_code"),
858        access_token: field_str(&v, "access_token"),
859    }
860}
861
862/// `gh_auth.json` を読む(無ければ default)。
863pub fn load_gh_auth() -> GithubAuth {
864    let fs = crate::kernel::fs::get_fs();
865    if let Some((_m, bytes)) = fs.read_file(GH_AUTH_FILE) {
866        if let Ok(s) = core::str::from_utf8(&bytes) {
867            return parse_gh_auth(s);
868        }
869    }
870    GithubAuth::default()
871}
872
873/// `gh_auth.json` に書き戻す。
874pub fn save_gh_auth(a: &GithubAuth) -> Result<(), &'static str> {
875    let json = gh_auth_to_json(a);
876    let fs = crate::kernel::fs::get_fs();
877    fs.save_file(GH_AUTH_FILE, json.as_bytes(), "system,auth")
878}
879
880/// デバイスコードを要求(ユーザーに user_code と verification_url を提示するため)。
881pub fn github_request_device_code(client_id: &str) -> Result<DeviceCode, &'static str> {
882    let body = github_device_code_body(client_id, GITHUB_DEFAULT_SCOPE);
883    let resp = oauth_post(GITHUB_HOST, "/login/device/code", &body)?;
884    Ok(parse_device_code(&resp))
885}
886
887/// device_code でトークンを1回ポーリングする。`authorization_pending` の間は
888/// TokenResponse.error にコードが入る(成功時は access_token が埋まる)。
889pub fn github_poll_token(
890    client_id: &str,
891    device_code: &str,
892) -> Result<TokenResponse, &'static str> {
893    let body = github_token_body(client_id, device_code);
894    let resp = oauth_post(GITHUB_HOST, "/login/oauth/access_token", &body)?;
895    Ok(parse_token_response(&resp))
896}
897
898// ============ 自己テスト(解析・構築の純関数部) ============
899
900pub fn selftest() -> (usize, usize) {
901    let mut passed = 0usize;
902    let mut total = 0usize;
903    let mut check = |cond: bool, label: &str| {
904        total += 1;
905        if cond {
906            passed += 1;
907        } else {
908            crate::println!("CLOUD_SELFTEST FAIL: {}", label);
909        }
910    };
911
912    // URL / フォームエンコード
913    check(url_encode("a b&c=d") == "a%20b%26c%3Dd", "url_encode");
914    check(
915        url_encode("safe-_.~") == "safe-_.~",
916        "url_encode unreserved",
917    );
918    check(
919        form_encode(&[("grant_type", "refresh_token"), ("code", "x y")])
920            == "grant_type=refresh_token&code=x%20y",
921        "form_encode",
922    );
923
924    // OAuth2 トークンレスポンス
925    let tok = parse_token_response(
926        r#"{"access_token":"ya29.AbC","expires_in":3599,"refresh_token":"1//rt","scope":"drive","token_type":"Bearer"}"#,
927    );
928    check(tok.access_token == "ya29.AbC", "token access_token");
929    check(tok.expires_in == 3599, "token expires_in");
930    check(tok.refresh_token == "1//rt", "token refresh_token");
931    let pend = parse_token_response(r#"{"error":"authorization_pending"}"#);
932    check(
933        pend.error == "authorization_pending" && pend.access_token.is_empty(),
934        "token pending",
935    );
936
937    // デバイスコード(Google / GitHub のキー差を吸収)
938    let g = parse_device_code(
939        r#"{"device_code":"DC","user_code":"WDJB-MJHT","verification_url":"https://www.google.com/device","expires_in":1800,"interval":5}"#,
940    );
941    check(
942        g.user_code == "WDJB-MJHT"
943            && g.verification_url == "https://www.google.com/device"
944            && g.interval == 5,
945        "google device code",
946    );
947    let gh = parse_device_code(
948        r#"{"device_code":"DC2","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":900,"interval":5}"#,
949    );
950    check(
951        gh.user_code == "ABCD-1234" && gh.verification_url == "https://github.com/login/device",
952        "github device code",
953    );
954
955    // ボディ構築
956    check(
957        google_token_body("cid", "sec", "dc")
958            .contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"),
959        "google token body",
960    );
961    check(
962        google_refresh_body("cid", "sec", "rt").contains("grant_type=refresh_token"),
963        "google refresh body",
964    );
965
966    // Google Drive ファイル一覧
967    let files = parse_drive_file_list(
968        r#"{"files":[{"id":"1","name":"memo.txt","mimeType":"text/plain"},{"id":"2","name":"work","mimeType":"application/vnd.google-apps.folder"}]}"#,
969    );
970    check(files.len() == 2, "drive file count");
971    check(
972        files.first().map(|f| f.name == "memo.txt").unwrap_or(false),
973        "drive file name",
974    );
975    check(
976        files
977            .get(1)
978            .map(|f| f.mime_type.contains("folder"))
979            .unwrap_or(false),
980        "drive folder mime",
981    );
982
983    // Drive 書き込み系の純粋ロジック
984    check(json_escape(r#"a"b\c"#) == r#"a\"b\\c"#, "json_escape");
985    let (boundary, body) = drive_multipart_body("memo.txt", "text/plain", b"hello");
986    let body_str = alloc::string::String::from_utf8_lossy(&body);
987    check(
988        body_str.contains("--atmos_drive_boundary_7f3a9c1e2b"),
989        "multipart boundary",
990    );
991    check(
992        body_str.contains(r#"{"name":"memo.txt"}"#),
993        "multipart metadata name",
994    );
995    check(
996        body_str.contains("Content-Type: text/plain"),
997        "multipart media type",
998    );
999    check(
1000        body_str.contains("hello") && body_str.ends_with("--atmos_drive_boundary_7f3a9c1e2b--\r\n"),
1001        "multipart closing",
1002    );
1003    check(
1004        boundary == "atmos_drive_boundary_7f3a9c1e2b",
1005        "multipart boundary value",
1006    );
1007
1008    // Drive 認証情報の round-trip
1009    let a = GdriveAuth {
1010        client_id: String::from("cid.apps.googleusercontent.com"),
1011        client_secret: String::from("sec"),
1012        access_token: String::from("ya29.tok"),
1013        refresh_token: String::from("1//rt"),
1014    };
1015    let round = parse_auth(&auth_to_json(&a));
1016    check(
1017        round.client_id == a.client_id
1018            && round.access_token == "ya29.tok"
1019            && round.refresh_token == "1//rt",
1020        "auth round-trip",
1021    );
1022    check(a.has_client() && a.is_logged_in(), "auth flags");
1023    check(
1024        !GdriveAuth::default().is_logged_in(),
1025        "auth default logged-out",
1026    );
1027
1028    // ---- Microsoft OneDrive (Graph API) ----
1029
1030    // デバイスコード(Microsoft は verification_uri を使う。GitHub と同じキーを
1031    // parse_device_code が既に吸収しているため専用分岐は不要だが念のため確認)。
1032    let ms_dc = parse_device_code(
1033        r#"{"device_code":"MSDC","user_code":"ABC-DEF-123","verification_uri":"https://microsoft.com/devicelogin","expires_in":900,"interval":5,"message":"..."}"#,
1034    );
1035    check(
1036        ms_dc.user_code == "ABC-DEF-123"
1037            && ms_dc.verification_url == "https://microsoft.com/devicelogin",
1038        "ms device code",
1039    );
1040
1041    // ボディ構築(client_secret 不要。google_token_body と違う点)
1042    check(
1043        ms_token_body("cid", "dc")
1044            .contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"),
1045        "ms token body",
1046    );
1047    check(
1048        !ms_token_body("cid", "dc").contains("client_secret"),
1049        "ms token body no secret",
1050    );
1051    check(
1052        ms_refresh_body("cid", "rt", ONEDRIVE_SCOPE).contains("grant_type=refresh_token"),
1053        "ms refresh body",
1054    );
1055
1056    // Graph API のファイル/フォルダ一覧("folder" プロパティの有無で判定)
1057    let od_files = parse_onedrive_file_list(
1058        r#"{"value":[{"id":"1","name":"memo.txt"},{"id":"2","name":"work","folder":{"childCount":3}}]}"#,
1059    );
1060    check(od_files.len() == 2, "onedrive file count");
1061    check(
1062        od_files.first().map(|f| !f.is_folder).unwrap_or(false),
1063        "onedrive file is not folder",
1064    );
1065    check(
1066        od_files.get(1).map(|f| f.is_folder).unwrap_or(false),
1067        "onedrive folder detection",
1068    );
1069
1070    // リダイレクト先URLの分解(Graph API の 302 追従用)
1071    check(
1072        split_https_url("https://cdn.example.com/blob/abc?sig=xyz")
1073            == Some((
1074                String::from("cdn.example.com"),
1075                String::from("/blob/abc?sig=xyz"),
1076            )),
1077        "split_https_url",
1078    );
1079    check(split_https_url("http://insecure.example.com/x").is_none(), "split_https_url rejects non-https");
1080
1081    // OneDrive 認証情報の round-trip(client_secret フィールドが無い点が Google と異なる)
1082    let ms_a = MsAuth {
1083        client_id: String::from("11111111-2222-3333-4444-555555555555"),
1084        access_token: String::from("eyJ0eXAi.tok"),
1085        refresh_token: String::from("M.R3_BAY.rt"),
1086    };
1087    let ms_round = parse_ms_auth(&ms_auth_to_json(&ms_a));
1088    check(
1089        ms_round.client_id == ms_a.client_id
1090            && ms_round.access_token == "eyJ0eXAi.tok"
1091            && ms_round.refresh_token == "M.R3_BAY.rt",
1092        "ms auth round-trip",
1093    );
1094    check(ms_a.has_client() && ms_a.is_logged_in(), "ms auth flags");
1095    check(!MsAuth::default().is_logged_in(), "ms auth default logged-out");
1096
1097    // GitHub ユーザ / リポジトリ
1098    let user = parse_github_user(r#"{"login":"octocat","name":"The Octocat","id":583231}"#);
1099    check(user.login == "octocat" && user.id == 583231, "github user");
1100    let repos = parse_github_repos(
1101        r#"[{"name":"r1","full_name":"u/r1","private":false},{"name":"r2","full_name":"u/r2","private":true}]"#,
1102    );
1103    check(
1104        repos.len() == 2 && repos[1].private && repos[0].full_name == "u/r1",
1105        "github repos",
1106    );
1107
1108    // GitHub 認証情報の round-trip
1109    let gh = GithubAuth {
1110        client_id: String::from("Iv1.abc123"),
1111        device_code: String::from("dc-xyz"),
1112        access_token: String::from("gho_tok"),
1113    };
1114    let gh_round = parse_gh_auth(&gh_auth_to_json(&gh));
1115    check(
1116        gh_round.client_id == gh.client_id
1117            && gh_round.device_code == "dc-xyz"
1118            && gh_round.access_token == "gho_tok",
1119        "gh auth round-trip",
1120    );
1121    check(gh.has_client() && gh.is_logged_in(), "gh auth flags");
1122    check(
1123        !GithubAuth::default().is_logged_in(),
1124        "gh auth default logged-out",
1125    );
1126
1127    (passed, total)
1128}