パスを操作する(結合・正規化・絶対パス化・拡張子の取得)

path API の join・normalize・resolve・extname と Rust の std::path・PathResolver でパスを加工する。拡張子なしで例外になる点や、フォルダー外に出ない確かめ方も示す。

ファイルシステム 対象: Tauri 2.x 更新日: 読了目安: 約10分 fs-021
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 主な関数と注意点
  4. 入力でフォルダーの外に出ない
  5. 拡張子で分ける・付け替える
  6. fs プラグインに渡すとき
  7. 2. バックエンドから実装する (Rust)
  8. 動作確認
  9. よくあるエラーと対処法
  10. OS ごとの違いと注意点
  11. 関連レシピ

保存先のパスを組み立てる、ダイアログで選ばれたファイルの拡張子で処理を分ける、ユーザーが入力したフォルダー名を足す、といったパスの操作は、文字列の連結ではなく専用の関数で行います。フロントエンドは @tauri-apps/api/path、Rust は標準の std::path と Tauri の PathResolver(app.path())を使います。どれも文字列だけを扱い、ファイルが実在するかは確かめません。JS の関数は Node.js の path と名前が同じでも結果が違うものがあるので、その点を中心に説明します。

前提条件

パスを加工するだけならプラグインは不要です。権限 core:path:default は core:default に含まれるので、capability に何も足さずに使えます。組み立てたパスでファイルを読み書きするには、File System プラグイン(npm run tauri add fs)とその権限が別に要ります。

基準になるフォルダーの取り方は アプリ専用のデータ保存フォルダパスを取得する・アプリの設定保存フォルダパスを取得する・一時ファイル用フォルダパスを取得する・ユーザーのホームディレクトリパスを取得する で扱います。

1. フロントエンドから実装する (TypeScript)

主な関数と注意点

sep() と delimiter() 以外は Rust 側で処理されるため Promise を返します。await を忘れると、テンプレート文字列の中で「[object Promise]」がパスに混ざります。

関数役割注意する点
join(...paths)つないで . と .. を整理途中の絶対パスで置き換わらない
normalize(path).・..・重なった区切りを整理先頭の戻れない .. は消える(Node.js では残る)
resolve(...paths)絶対パスにする基準はカレントディレクトリ。途中の絶対パスからやり直す
dirname() / basename(path, ext?)親フォルダー / 最後の要素ext はドットを含めて渡す
extname(path)拡張子(ドットなし)無いと例外(Node.js は空文字列)
isAbsolute(path)絶対パスかWindows ではドライブ名の無い /data は false
import { appDataDir, basename, dirname, extname, join, normalize, resolve, sep } from '@tauri-apps/api/path';

const base = await appDataDir(); // 例: C:\Users\me\AppData\Roaming\com.example.app

console.log(await join(base, 'notes', '2026', 'memo.md'));  // ...\com.example.app\notes\2026\memo.md
console.log(await join('notes', '..', 'backup', './a.txt')); // backup\a.txt
console.log(await join('data', '/tmp', 'x.txt'));           // data\tmp\x.txt(/tmp で置き換わらない)
console.log(await normalize('docs/./old/../memo.txt'));     // docs\memo.txt
console.log(await normalize('../memo.txt'));                // memo.txt
console.log(await resolve('logs', 'app.log'));              // <カレントディレクトリ>\logs\app.log
console.log(await dirname('/path/to/report.pdf'));          // /path/to
console.log(await basename('/path/to/report.pdf', '.pdf')); // report('pdf' だと report. になる)
console.log(await extname('archive.tar.gz'));               // gz(最後の 1 つだけ)
console.log(sep());                                         // \(macOS / Linux は /)

入力でフォルダーの外に出ない

join() は .. を整理するので、ユーザーが入力した ../../ はそのまま基準フォルダーの外を指すパスになります。整理後の文字列には .. が残らないため、後述する fs プラグインの .. の拒否もすり抜けます。入力を足したら、結果が基準フォルダーの中にあるかを確かめます。

import { appDataDir, isAbsolute, join, sep } from '@tauri-apps/api/path';

// base の中を指すパスだけを返す。外に出る入力や絶対パスは例外にする
export async function pathInside(base: string, input: string): Promise<string> {
  if (!input.trim() || (await isAbsolute(input))) throw new Error(`invalid path: ${input}`);
  const root = await join(base, '.'); // base も同じ形に整理してから比べる
  const target = await join(root, input);
  if (!target.startsWith(root + sep())) throw new Error(`outside of base: ${input}`);
  return target;
}

const dir = await appDataDir();
console.log(await pathInside(dir, 'exports/2026/report.csv'));
await pathInside(dir, '../../secret.txt').catch((e) => console.error(String(e)));

拡張子で分ける・付け替える

extname() は拡張子が無いと失敗します。README やフォルダー名のほか、.gitignore のようにドットで始まるだけの名前も「拡張子なし」です。photo.JPG は JPG のまま返るので、比べる前に小文字にします。

import { basename, dirname, extname, join } from '@tauri-apps/api/path';

// 拡張子を小文字で返す。無ければ空文字列
export async function extOf(path: string): Promise<string> {
  return (await extname(path).catch(() => '')).toLowerCase();
}

// 拡張子を付け替える(memo.txt → memo.md)。無ければ付け足す
export async function withExt(path: string, ext: string): Promise<string> {
  const raw = await extname(path).catch(() => ''); // 取り除くときは元の大文字小文字のまま使う
  const name = await basename(path, raw ? `.${raw}` : undefined);
  return join(await dirname(path), `${name}.${ext}`);
}

fs プラグインに渡すとき

fs プラグインには、join() で作った絶対パスより「基準フォルダーからの相対パス + baseDir」を渡す方が、capability に書く $APPDATA などの変数と対応が見えやすくなります。fs プラグインは .. を含むパスを受け付けません。また baseDir を指定しても、パスが絶対パスなら baseDir は無視されます。削除と移動での使い方は ファイルやディレクトリを削除する と ファイル名を変更する・別の場所へ移動する を参照してください。

2. バックエンドから実装する (Rust)

Rust では std::path::Path / PathBuf を使います。JS の join() と違い、Path::join() は引数が絶対パスだとそのパスに置き換わり、.. も整理しません。app.path().resolve(相対パス, BaseDirectory::AppData) も同じで、絶対パスを渡すとそのパスが返ります。JS から受け取った文字列は components() で確かめてから足します。app.path().parse("$APPLOG/app.log") のように、capability と同じ変数で始まる文字列から絶対パスを作ることもできます。

拡張子は extension()(ドットなし。無ければ None)、拡張子を除いた名前は file_stem()、付け替えは with_extension() です。絶対パス化には、ファイルを見ない std::path::absolute()(Rust 1.79 以降)と、実在するパスのリンクをたどる std::fs::canonicalize() があります。

use std::path::{Component, Path, PathBuf};
use tauri::path::BaseDirectory;
use tauri::Manager;

/// 相対パスだけを受け付け、アプリデータフォルダーの中の絶対パスにする
fn app_data_path(app: &tauri::AppHandle, rel: &str) -> Result<PathBuf, String> {
    let rel_path = Path::new(rel);
    // 絶対パス・ドライブ指定・`..` を拒否する(Path::join は絶対パスで置き換わるため)
    let plain = rel_path.components().all(|c| matches!(c, Component::Normal(_) | Component::CurDir));
    if rel.is_empty() || !plain {
        return Err(format!("invalid path: {rel}"));
    }
    app.path().resolve(rel_path, BaseDirectory::AppData).map_err(|e| e.to_string())
}

#[derive(serde::Serialize)]
struct PathParts {
    full: String,
    stem: String,
    ext: Option<String>, // 拡張子が無ければ null(JS の extname と違って失敗しない)
    backup: String,      // 拡張子を .bak に替えたパス
}

#[tauri::command]
fn describe_path(app: tauri::AppHandle, rel: String) -> Result<PathParts, String> {
    let path = app_data_path(&app, &rel)?;
    let text = |s: Option<&std::ffi::OsStr>| s.map(|s| s.to_string_lossy().into_owned());
    Ok(PathParts {
        full: path.display().to_string(),
        stem: text(path.file_stem()).unwrap_or_default(),
        ext: text(path.extension()),
        backup: path.with_extension("bak").display().to_string(),
    })
}

/// 絶対パスにする 2 つの方法を並べて返す
#[tauri::command]
fn to_absolute(path: String) -> Result<(String, Option<String>), String> {
    // ファイルを見ずに、カレントディレクトリを基準に絶対パスにする
    let abs = std::path::absolute(&path).map_err(|e| e.to_string())?;
    // 実在するときだけ、リンクをたどった実体のパスにする(無ければ None)
    let real = std::fs::canonicalize(&path).ok();
    Ok((abs.display().to_string(), real.map(|p| p.display().to_string())))
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![describe_path, to_absolute])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';

type PathParts = { full: string; stem: string; ext: string | null; backup: string };

const parts = await invoke<PathParts>('describe_path', { rel: 'notes/memo.md' });
console.log(parts.stem, parts.ext, parts.backup);
await invoke('describe_path', { rel: '../secret.txt' }).catch((e) => console.error(e));

const [abs, real] = await invoke<[string, string | null]>('to_absolute', { path: 'notes.txt' });
console.log(abs, real); // real はファイルが無ければ null

動作確認

npm run tauri dev で起動し、1 章の最初のコードをボタンなどから実行すると、Windows では DevTools のコンソールに次のように出ます(ユーザー名と identifier は環境によって変わります)。macOS / Linux では区切りが / になります。

C:\Users\me\AppData\Roaming\com.example.app\notes\2026\memo.md
backup\a.txt
data\tmp\x.txt
docs\memo.txt
memo.txt

pathInside() の 2 つ目は「Error: outside of base: ../../secret.txt」、Rust の describe_path は memo md ...\notes\memo.bak と「invalid path: ../secret.txt」を出します。

よくあるエラーと対処法

  • 「path does not have an extension」: extname() に拡張子の無いパス(README、.gitignore、フォルダー)を渡しました。extOf() のように catch で空文字列に置き換えます。
  • パスに「[object Promise]」が混ざる: join() などの await 漏れです。sep() と delimiter() 以外はすべて await が要ります。
  • fs プラグインが「cannot traverse directory, rewrite the path without the use of ../」を含むエラーを返す: .. を含むパスを渡しました。.. を含む入力はそもそも受け付けないか、pathInside() で外に出ないことを確かめてから渡します。
  • resolve() の結果が開発中と配布後で違う: 基準のカレントディレクトリが起動のしかたで変わるためです。保存先は appDataDir() などから join() で作ります。
  • Rust で組み立てたパスがベースと関係ない場所を指す: Path::join() や resolve() に絶対パスが渡っています。components() で確かめてから足します。

OS ごとの違いと注意点

  • Windows: 区切りは \、delimiter() は ; です。join()・normalize()・resolve() は / を \ に直して返します。std::fs::canonicalize() は \\?\C:\... という拡張形式で返すので、JS 側のパスと文字列で比べても一致しません。std::path::absolute() は .. も整理します。
  • macOS / Linux: 区切りは /、delimiter() は : です。\ はファイル名に使える普通の文字なので、Windows 形式のパス文字列は分割されません。std::path::absolute() は .. を残します。
  • 共通: path API は呼ぶたびに Rust とのプロセス間通信(IPC)が起きます。数千件のパスを一度に加工するなら、Rust のコマンドにまとめます。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する