From 8d3ef26128fddd439d64e21ed345d033483523e9 Mon Sep 17 00:00:00 2001 From: zztkm <33755694+zztkm@users.noreply.github.com.> Date: Sat, 31 Jan 2026 03:53:29 +0000 Subject: [PATCH 1/4] =?UTF-8?q?serve=20=E3=82=B3=E3=83=9E=E3=83=B3?= =?UTF-8?q?=E3=83=89=E3=81=AB=E5=A2=97=E5=88=86=E3=83=93=E3=83=AB=E3=83=89?= =?UTF-8?q?=E6=A9=9F=E8=83=BD=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ファイル変更時に全ファイルを再ビルドするのではなく、変更があったファイルのみを 再ビルドするように改善。 - Markdown ファイル変更時はそのファイルのみ再生成 - 静的ファイル変更時はそのファイルのみコピー - テンプレート変更時はそのテンプレートを使用する Markdown ファイルのみ再生成 - 設定ファイル (vss.toml) 変更時のみフルビルドを実行 - ファイル削除時は対応する出力ファイルを削除 Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 7 + Cargo.lock | 2 +- src/subcommand_build.rs | 332 +++++++++++++++++++++++++++++++++++++--- src/subcommand_serve.rs | 159 +++++++++++++++++-- 4 files changed, 461 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd284a..f44c271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ ## main +- [UPDATE] `serve` コマンドで増分ビルド機能を追加 + - ファイル変更時に全ファイルを再ビルドするのではなく、変更があったファイルのみを再ビルドするように改善 + - Markdown ファイル変更時はそのファイルのみ再生成 + - 静的ファイル変更時はそのファイルのみコピー + - テンプレート変更時はそのテンプレートを使用する Markdown ファイルのみ再生成 + - 設定ファイル (vss.toml) 変更時のみフルビルドを実行 + - ファイル削除時は対応する出力ファイルを削除 - [FIX] `serve` コマンドで `ls` 実行時にビルドが誤って走る問題を修正 - `notify-debouncer-mini` から `notify-debouncer-full` に移行し、`EventKind::Access` を除外するようにした - [#46](https://github.com/veltiosoft/vss/pull/46) diff --git a/Cargo.lock b/Cargo.lock index 5fb136d..c687ced 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2206,7 +2206,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vss" -version = "0.18.0" +version = "0.19.0" dependencies = [ "anyhow", "axum", diff --git a/src/subcommand_build.rs b/src/subcommand_build.rs index e0aab30..845f039 100644 --- a/src/subcommand_build.rs +++ b/src/subcommand_build.rs @@ -9,49 +9,64 @@ use std::{ time::Instant, }; +/// 変更されたファイルの種別 +#[derive(Debug, Clone)] +pub enum ChangedFile { + /// Markdown ファイルの変更 + Markdown(PathBuf), + /// 静的ファイルの変更 + Static(PathBuf), + /// テンプレートファイルの変更 + Template(PathBuf), + /// 設定ファイルの変更 + Config, + /// ファイル削除 + Deleted(PathBuf), +} + /// vss.toml の設定構造 #[derive(Debug, Deserialize)] -struct Config { +pub(crate) struct Config { #[serde(default = "default_site_title")] - site_title: String, + pub(crate) site_title: String, #[serde(default)] - site_description: String, + pub(crate) site_description: String, #[serde(default)] - base_url: String, + pub(crate) base_url: String, #[serde(default = "default_dist")] - dist: String, + pub(crate) dist: String, #[serde(default = "default_static")] - r#static: String, + pub(crate) r#static: String, #[serde(default = "default_layouts")] - layouts: String, + pub(crate) layouts: String, #[serde(default)] - build: BuildConfig, + pub(crate) build: BuildConfig, } #[derive(Debug, Deserialize, Default)] -struct BuildConfig { +pub(crate) struct BuildConfig { #[serde(default)] - ignore_files: Vec, + pub(crate) ignore_files: Vec, #[serde(default)] - markdown: MarkdownConfig, + pub(crate) markdown: MarkdownConfig, #[serde(default)] - tags: TagsConfig, + pub(crate) tags: TagsConfig, } #[derive(Debug, Deserialize, Default)] -struct MarkdownConfig { +pub(crate) struct MarkdownConfig { #[serde(default)] - allow_dangerous_html: bool, + pub(crate) allow_dangerous_html: bool, } #[derive(Debug, Deserialize)] -struct TagsConfig { +pub(crate) struct TagsConfig { #[serde(default = "default_tags_enable")] - enable: bool, + pub(crate) enable: bool, #[serde(default = "default_tags_template")] - template: String, + pub(crate) template: String, #[serde(default = "default_tags_url_pattern")] - url_pattern: String, + pub(crate) url_pattern: String, } impl Default for TagsConfig { @@ -134,7 +149,7 @@ struct Tag { /// タグページ生成用の投稿メタデータ #[derive(Content, Clone)] -struct PostMetadata { +pub(crate) struct PostMetadata { title: String, description: String, author: String, @@ -154,7 +169,7 @@ struct TagPageContext { } /// 設定ファイルを読み込む -fn load_config(path: &Path) -> Result { +pub(crate) fn load_config(path: &Path) -> Result { let content = fs::read_to_string(path) .with_context(|| format!("Failed to read config file: {}", path.display()))?; let config: Config = toml::from_str(&content) @@ -189,7 +204,7 @@ fn markdown_to_html(markdown: &str, allow_dangerous_html: bool) -> Result Result>> { +pub(crate) fn load_templates(layouts_dir: &str) -> Result>> { let mut templates = HashMap::new(); let pattern = format!("{}/**/*.html", layouts_dir); @@ -371,7 +386,7 @@ pub fn run_build(config_path: &Path) -> Result<()> { } /// 個別の Markdown ファイルを処理する -fn process_markdown_file( +pub(crate) fn process_markdown_file( md_path: &Path, config: &Config, templates: &HashMap>, @@ -563,3 +578,276 @@ fn find_files_with_glob(extension: &str) -> Result, glob::PatternEr } Ok(files) } + +/// 単一の静的ファイルをコピーする +pub(crate) fn copy_single_static_file( + src_path: &Path, + static_dir: &str, + dist_dir: &str, +) -> Result<()> { + // static/ からの相対パスを取得 + if let Ok(rel_path) = src_path.strip_prefix(static_dir) { + let dest_path = Path::new(dist_dir).join(rel_path); + + // 親ディレクトリを作成 + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("Failed to create directory: {}", parent.display()) + })?; + } + + // ファイルをコピー + fs::copy(src_path, &dest_path).with_context(|| { + format!( + "Failed to copy file from {} to {}", + src_path.display(), + dest_path.display() + ) + })?; + + println!("Copied: {}", dest_path.display()); + } + + Ok(()) +} + +/// 出力ファイルを削除する(ソースファイル削除時に呼び出す) +pub(crate) fn delete_output_file( + src_path: &Path, + src_base_dir: &str, + dist_dir: &str, + convert_extension: Option<&str>, +) -> Result<()> { + // ソースファイルからの相対パスを取得 + if let Ok(rel_path) = src_path.strip_prefix(src_base_dir) { + let mut dest_path = Path::new(dist_dir).join(rel_path); + + // 拡張子変換が必要な場合(.md → .html) + if let Some(ext) = convert_extension { + dest_path.set_extension(ext); + } + + // ファイルが存在する場合は削除 + if dest_path.exists() { + fs::remove_file(&dest_path).with_context(|| { + format!("Failed to delete output file: {}", dest_path.display()) + })?; + println!("Deleted: {}", dest_path.display()); + } + } + + Ok(()) +} + +/// テンプレートを使用する Markdown ファイルを検索 +/// +/// テンプレートの検索優先順位: +/// 1. 完全一致: layouts/{md_path}.html +/// 2. ディレクトリデフォルト: layouts/{dir}/default.html +/// 3. ルートデフォルト: layouts/default.html +pub(crate) fn find_markdown_files_using_template( + template_path: &Path, + layouts_dir: &str, + ignore_files: &[String], +) -> Result> { + let mut affected_files = Vec::new(); + + // テンプレートの相対パス(layouts/ からの相対) + let template_rel_path = template_path + .strip_prefix(layouts_dir) + .ok() + .map(|p| p.to_string_lossy().to_string()); + + let Some(template_key) = template_rel_path else { + return Ok(affected_files); + }; + + // すべての Markdown ファイルを取得 + let md_files = find_files_with_glob("md").context("Failed to find markdown files")?; + + // ignore_files でフィルタリング + let md_files: Vec = md_files + .into_iter() + .filter(|path| { + let path_str = path.to_string_lossy(); + !ignore_files.iter().any(|ignore| path_str.contains(ignore)) + }) + .collect(); + + // 全テンプレートを読み込んで、どの Markdown ファイルがどのテンプレートを使うか判定 + let templates = load_templates(layouts_dir)?; + + for md_path in md_files { + let html_path = md_path.with_extension("html"); + let html_path_str = html_path.to_string_lossy().to_string(); + + // このファイルが使用するテンプレートを特定 + let used_template_key = determine_template_key(&templates, &html_path_str); + + // 変更されたテンプレートを使用しているか確認 + if let Some(key) = used_template_key { + if key == template_key { + affected_files.push(md_path); + } + } + } + + Ok(affected_files) +} + +/// Markdown ファイルが使用するテンプレートキーを決定する +fn determine_template_key( + templates: &HashMap>, + html_path: &str, +) -> Option { + // 1. 完全一致 + if templates.contains_key(html_path) { + return Some(html_path.to_string()); + } + + // 2. ディレクトリ内の default.html + if let Some(dir) = Path::new(html_path).parent() { + let dir_default = format!("{}/default.html", dir.display()); + if templates.contains_key(&dir_default) { + return Some(dir_default); + } + } + + // 3. ルートの default.html + if templates.contains_key("default.html") { + return Some("default.html".to_string()); + } + + None +} + +/// 増分ビルドのエントリポイント +/// 変更されたファイルの種別に応じて最小限の再ビルドを行う +pub fn run_incremental_build( + config_path: &Path, + changed_files: &[ChangedFile], +) -> Result<()> { + // 設定ファイルを読み込む + let config = load_config(config_path)?; + + // テンプレートを読み込む + let templates = load_templates(&config.layouts)?; + + // 投稿メタデータを収集(タグページ再生成用) + let mut all_posts: Vec = Vec::new(); + let mut need_regenerate_tags = false; + + for changed_file in changed_files { + match changed_file { + ChangedFile::Markdown(path) => { + // Markdown ファイルが ignore_files に含まれているかチェック + let path_str = path.to_string_lossy(); + if config + .build + .ignore_files + .iter() + .any(|ignore| path_str.contains(ignore)) + { + continue; + } + + // 単一の Markdown ファイルを処理 + if let Ok(Some(metadata)) = process_markdown_file(path, &config, &templates) { + all_posts.push(metadata); + } + need_regenerate_tags = true; + } + ChangedFile::Static(path) => { + // 単一の静的ファイルをコピー + copy_single_static_file(path, &config.r#static, &config.dist)?; + } + ChangedFile::Template(path) => { + // テンプレート変更時は、そのテンプレートを使用する全 Markdown を再ビルド + let affected_md_files = find_markdown_files_using_template( + path, + &config.layouts, + &config.build.ignore_files, + )?; + + // テンプレートを再読み込み + let fresh_templates = load_templates(&config.layouts)?; + + for md_path in affected_md_files { + if let Ok(Some(metadata)) = + process_markdown_file(&md_path, &config, &fresh_templates) + { + all_posts.push(metadata); + } + } + need_regenerate_tags = true; + } + ChangedFile::Config => { + // 設定ファイル変更時はフルビルド + // この場合は run_build() を呼び出すべきなので、 + // 呼び出し側で処理する + return Err(anyhow::anyhow!( + "Config changed, full rebuild required" + )); + } + ChangedFile::Deleted(path) => { + // ファイル削除時は対応する出力ファイルを削除 + let path_str = path.to_string_lossy(); + + if path_str.ends_with(".md") { + // Markdown ファイルの削除 + delete_output_file(path, "", &config.dist, Some("html"))?; + need_regenerate_tags = true; + } else if path.starts_with(&config.r#static) { + // 静的ファイルの削除 + delete_output_file(path, &config.r#static, &config.dist, None)?; + } + } + } + } + + // タグページを再生成(Markdown の変更があった場合) + if need_regenerate_tags && config.build.tags.enable { + // 全 Markdown ファイルからメタデータを再収集してタグページを生成 + let md_files = find_files_with_glob("md").context("Failed to find markdown files")?; + let md_files: Vec = md_files + .into_iter() + .filter(|path| { + let path_str = path.to_string_lossy(); + !config + .build + .ignore_files + .iter() + .any(|ignore| path_str.contains(ignore)) + }) + .collect(); + + let mut tag_posts: Vec = Vec::new(); + for md_path in &md_files { + // frontmatter を読み取ってメタデータを収集 + if let Ok(content) = fs::read_to_string(md_path) { + if let Ok((frontmatter, _)) = parse_frontmatter(&content) { + if let Some(tags_vec) = &frontmatter.tags { + if !tags_vec.is_empty() { + let html_path = md_path.with_extension("html"); + let url = format!("/{}", html_path.to_string_lossy()); + tag_posts.push(PostMetadata { + title: frontmatter.title, + description: frontmatter.description, + author: frontmatter.author, + pub_datetime: frontmatter.pub_datetime, + url, + tags: Some(tags_vec.clone()), + }); + } + } + } + } + } + + if !tag_posts.is_empty() { + generate_tag_pages(tag_posts, &config, &templates)?; + } + } + + Ok(()) +} diff --git a/src/subcommand_serve.rs b/src/subcommand_serve.rs index 40fb286..be4835c 100644 --- a/src/subcommand_serve.rs +++ b/src/subcommand_serve.rs @@ -16,7 +16,7 @@ use std::{ }; use tower_http::services::ServeDir; -use crate::subcommand_build; +use crate::subcommand_build::{self, ChangedFile}; /// serve コマンドのエントリポイント pub fn run(mut args: noargs::RawArgs) -> noargs::Result<()> { @@ -123,8 +123,12 @@ async fn html_fallback_middleware(mut request: Request, next: Next) -> Response fn watch_files(config_path: &Path, _rebuild_flag: Arc>) -> Result<()> { let config_path_clone = config_path.to_path_buf(); - // dist ディレクトリのパスを取得(絶対パスに変換) - let dist_dir = get_dist_dir(config_path)?; + // 設定を取得 + let serve_config = get_serve_config(config_path)?; + let dist_dir = serve_config.dist.clone(); + let static_dir = serve_config.r#static.clone(); + let layouts_dir = serve_config.layouts.clone(); + let current_dir = std::env::current_dir().context("Failed to get current directory")?; let dist_path = current_dir.join(&dist_dir); @@ -134,21 +138,82 @@ fn watch_files(config_path: &Path, _rebuild_flag: Arc>) -> Result<() None, move |res: DebounceEventResult| match res { Ok(events) => { - let should_rebuild = events.iter().any(|event| { + // dist ディレクトリ外の変更されたファイルを収集 + let mut changed_files: Vec = Vec::new(); + let mut has_deletion = false; + + for event in &events { // Access イベント(ls による atime 更新など)は無視 if matches!(event.kind, EventKind::Access(_)) { - return false; + continue; } - // いずれかのパスが dist ディレクトリ外であれば再ビルド - event.paths.iter().any(|path| !path.starts_with(&dist_path)) - }); - - if should_rebuild { - println!("[INFO] File changed, rebuilding..."); - if let Err(e) = subcommand_build::run_build(&config_path_clone) { - eprintln!("[ERROR] Rebuild failed: {:#}", e); - } else { - println!("[INFO] Rebuild completed"); + + // 削除イベントかどうか判定 + let is_remove = matches!(event.kind, EventKind::Remove(_)); + + for path in &event.paths { + // dist ディレクトリ内は無視 + if path.starts_with(&dist_path) { + continue; + } + + if is_remove { + // 削除されたファイル + has_deletion = true; + changed_files.push(ChangedFile::Deleted(path.clone())); + } else if let Some(classified) = classify_changed_file( + path, + &config_path_clone, + &static_dir, + &layouts_dir, + ) { + // Config 変更の場合は即座にフルビルド + if matches!(classified, ChangedFile::Config) { + println!("[INFO] Config changed, running full rebuild..."); + if let Err(e) = subcommand_build::run_build(&config_path_clone) { + eprintln!("[ERROR] Full rebuild failed: {:#}", e); + } else { + println!("[INFO] Full rebuild completed"); + } + return; + } + changed_files.push(classified); + } + } + } + + if changed_files.is_empty() { + return; + } + + // 変更されたファイル数を表示 + let file_count = changed_files.len(); + let file_desc = if file_count == 1 { + format!("1 file") + } else { + format!("{} files", file_count) + }; + + println!("[INFO] {} changed, rebuilding...", file_desc); + + // 増分ビルドを試行 + match subcommand_build::run_incremental_build(&config_path_clone, &changed_files) { + Ok(()) => { + println!("[INFO] Incremental rebuild completed"); + } + Err(e) => { + // 増分ビルドが失敗した場合(Config 変更など)はフルビルドにフォールバック + let error_msg = format!("{:#}", e); + if error_msg.contains("full rebuild required") || has_deletion { + println!("[INFO] Falling back to full rebuild..."); + if let Err(e) = subcommand_build::run_build(&config_path_clone) { + eprintln!("[ERROR] Full rebuild failed: {:#}", e); + } else { + println!("[INFO] Full rebuild completed"); + } + } else { + eprintln!("[ERROR] Incremental rebuild failed: {:#}", e); + } } } } @@ -172,22 +237,84 @@ fn watch_files(config_path: &Path, _rebuild_flag: Arc>) -> Result<() /// 設定ファイルから dist ディレクトリのパスを取得 fn get_dist_dir(config_path: &Path) -> Result { + let serve_config = get_serve_config(config_path)?; + Ok(serve_config.dist) +} + +/// serve コマンド用の設定情報 +#[derive(Debug)] +struct ServeConfig { + dist: String, + r#static: String, + layouts: String, +} + +/// 設定ファイルから serve コマンドに必要な情報を取得 +fn get_serve_config(config_path: &Path) -> Result { use serde::Deserialize; #[derive(Debug, Deserialize)] struct Config { #[serde(default = "default_dist")] dist: String, + #[serde(default = "default_static")] + r#static: String, + #[serde(default = "default_layouts")] + layouts: String, } fn default_dist() -> String { "dist".to_string() } + fn default_static() -> String { + "static".to_string() + } + + fn default_layouts() -> String { + "layouts".to_string() + } + let content = std::fs::read_to_string(config_path) .with_context(|| format!("Failed to read config file: {}", config_path.display()))?; let config: Config = toml::from_str(&content) .with_context(|| format!("Failed to parse config file: {}", config_path.display()))?; - Ok(config.dist) + Ok(ServeConfig { + dist: config.dist, + r#static: config.r#static, + layouts: config.layouts, + }) +} + +/// 変更されたファイルを分類する +fn classify_changed_file( + path: &Path, + config_path: &Path, + static_dir: &str, + layouts_dir: &str, +) -> Option { + let path_str = path.to_string_lossy(); + + // 設定ファイルの変更 + if path == config_path { + return Some(ChangedFile::Config); + } + + // 静的ファイルの変更 + if path.starts_with(static_dir) || path_str.starts_with(&format!("./{}", static_dir)) { + return Some(ChangedFile::Static(path.to_path_buf())); + } + + // テンプレートファイルの変更 + if path.starts_with(layouts_dir) || path_str.starts_with(&format!("./{}", layouts_dir)) { + return Some(ChangedFile::Template(path.to_path_buf())); + } + + // Markdown ファイルの変更 + if path.extension().is_some_and(|ext| ext == "md") { + return Some(ChangedFile::Markdown(path.to_path_buf())); + } + + None } From cc0fd2729edd3985aa00f8581c721117ebce1edc Mon Sep 17 00:00:00 2001 From: zztkm <33755694+zztkm@users.noreply.github.com.> Date: Sat, 31 Jan 2026 05:08:02 +0000 Subject: [PATCH 2/4] =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88=E3=81=A8CI=E8=AD=A6?= =?UTF-8?q?=E5=91=8A=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 絶対パスを相対パスに変換する処理を追加(セキュリティ修正) - テンプレート削除時にフルビルドへフォールバック - Markdown ファイル削除時の出力ファイル削除を安全に修正 - load_templates の重複呼び出しを削除(パフォーマンス改善) - subcommand_serve.rs で subcommand_build::load_config を直接使用 - ServeConfig と get_serve_config を削除(コード重複削除) - collapsible if と useless format の clippy 警告を修正 Co-Authored-By: Claude Opus 4.5 --- src/subcommand_build.rs | 103 +++++++++++++++++++++++++--------------- src/subcommand_serve.rs | 62 ++++-------------------- 2 files changed, 74 insertions(+), 91 deletions(-) diff --git a/src/subcommand_build.rs b/src/subcommand_build.rs index 845f039..8173269 100644 --- a/src/subcommand_build.rs +++ b/src/subcommand_build.rs @@ -645,13 +645,18 @@ pub(crate) fn delete_output_file( /// 1. 完全一致: layouts/{md_path}.html /// 2. ディレクトリデフォルト: layouts/{dir}/default.html /// 3. ルートデフォルト: layouts/default.html +/// +/// テンプレートも一緒に返すことで、呼び出し側での再読み込みを避ける pub(crate) fn find_markdown_files_using_template( template_path: &Path, layouts_dir: &str, ignore_files: &[String], -) -> Result> { +) -> Result<(Vec, HashMap>)> { let mut affected_files = Vec::new(); + // テンプレートを読み込む + let templates = load_templates(layouts_dir)?; + // テンプレートの相対パス(layouts/ からの相対) let template_rel_path = template_path .strip_prefix(layouts_dir) @@ -659,7 +664,7 @@ pub(crate) fn find_markdown_files_using_template( .map(|p| p.to_string_lossy().to_string()); let Some(template_key) = template_rel_path else { - return Ok(affected_files); + return Ok((affected_files, templates)); }; // すべての Markdown ファイルを取得 @@ -674,9 +679,6 @@ pub(crate) fn find_markdown_files_using_template( }) .collect(); - // 全テンプレートを読み込んで、どの Markdown ファイルがどのテンプレートを使うか判定 - let templates = load_templates(layouts_dir)?; - for md_path in md_files { let html_path = md_path.with_extension("html"); let html_path_str = html_path.to_string_lossy().to_string(); @@ -685,14 +687,14 @@ pub(crate) fn find_markdown_files_using_template( let used_template_key = determine_template_key(&templates, &html_path_str); // 変更されたテンプレートを使用しているか確認 - if let Some(key) = used_template_key { - if key == template_key { - affected_files.push(md_path); - } + if let Some(key) = used_template_key + && key == template_key + { + affected_files.push(md_path); } } - Ok(affected_files) + Ok((affected_files, templates)) } /// Markdown ファイルが使用するテンプレートキーを決定する @@ -721,6 +723,17 @@ fn determine_template_key( None } +/// 絶対パスを相対パスに変換する +fn to_relative_path(path: &Path) -> PathBuf { + if path.is_absolute() + && let Ok(current_dir) = std::env::current_dir() + && let Ok(rel) = path.strip_prefix(¤t_dir) + { + return rel.to_path_buf(); + } + path.to_path_buf() +} + /// 増分ビルドのエントリポイント /// 変更されたファイルの種別に応じて最小限の再ビルドを行う pub fn run_incremental_build( @@ -737,11 +750,17 @@ pub fn run_incremental_build( let mut all_posts: Vec = Vec::new(); let mut need_regenerate_tags = false; + // カレントディレクトリを取得(パス正規化用) + let current_dir = std::env::current_dir().context("Failed to get current directory")?; + for changed_file in changed_files { match changed_file { ChangedFile::Markdown(path) => { + // 絶対パスを相対パスに変換 + let rel_path = to_relative_path(path); + // Markdown ファイルが ignore_files に含まれているかチェック - let path_str = path.to_string_lossy(); + let path_str = rel_path.to_string_lossy(); if config .build .ignore_files @@ -751,8 +770,8 @@ pub fn run_incremental_build( continue; } - // 単一の Markdown ファイルを処理 - if let Ok(Some(metadata)) = process_markdown_file(path, &config, &templates) { + // 単一の Markdown ファイルを処理(相対パスを使用) + if let Ok(Some(metadata)) = process_markdown_file(&rel_path, &config, &templates) { all_posts.push(metadata); } need_regenerate_tags = true; @@ -763,15 +782,13 @@ pub fn run_incremental_build( } ChangedFile::Template(path) => { // テンプレート変更時は、そのテンプレートを使用する全 Markdown を再ビルド - let affected_md_files = find_markdown_files_using_template( + // テンプレートも一緒に取得して再読み込みを避ける + let (affected_md_files, fresh_templates) = find_markdown_files_using_template( path, &config.layouts, &config.build.ignore_files, )?; - // テンプレートを再読み込み - let fresh_templates = load_templates(&config.layouts)?; - for md_path in affected_md_files { if let Ok(Some(metadata)) = process_markdown_file(&md_path, &config, &fresh_templates) @@ -795,11 +812,25 @@ pub fn run_incremental_build( if path_str.ends_with(".md") { // Markdown ファイルの削除 - delete_output_file(path, "", &config.dist, Some("html"))?; + // 絶対パスを相対パスに変換してから処理 + let rel_path = to_relative_path(path); + let html_path = rel_path.with_extension("html"); + let output_path = Path::new(&config.dist).join(&html_path); + if output_path.exists() { + fs::remove_file(&output_path).with_context(|| { + format!("Failed to delete output file: {}", output_path.display()) + })?; + println!("Deleted: {}", output_path.display()); + } need_regenerate_tags = true; - } else if path.starts_with(&config.r#static) { + } else if path.starts_with(current_dir.join(&config.r#static)) { // 静的ファイルの削除 - delete_output_file(path, &config.r#static, &config.dist, None)?; + delete_output_file(path, ¤t_dir.join(&config.r#static).to_string_lossy(), &config.dist, None)?; + } else if path.starts_with(current_dir.join(&config.layouts)) { + // テンプレートが削除された場合はフルビルドを要求 + return Err(anyhow::anyhow!( + "Template file deleted, full rebuild required" + )); } } } @@ -824,23 +855,21 @@ pub fn run_incremental_build( let mut tag_posts: Vec = Vec::new(); for md_path in &md_files { // frontmatter を読み取ってメタデータを収集 - if let Ok(content) = fs::read_to_string(md_path) { - if let Ok((frontmatter, _)) = parse_frontmatter(&content) { - if let Some(tags_vec) = &frontmatter.tags { - if !tags_vec.is_empty() { - let html_path = md_path.with_extension("html"); - let url = format!("/{}", html_path.to_string_lossy()); - tag_posts.push(PostMetadata { - title: frontmatter.title, - description: frontmatter.description, - author: frontmatter.author, - pub_datetime: frontmatter.pub_datetime, - url, - tags: Some(tags_vec.clone()), - }); - } - } - } + if let Ok(content) = fs::read_to_string(md_path) + && let Ok((frontmatter, _)) = parse_frontmatter(&content) + && let Some(tags_vec) = &frontmatter.tags + && !tags_vec.is_empty() + { + let html_path = md_path.with_extension("html"); + let url = format!("/{}", html_path.to_string_lossy()); + tag_posts.push(PostMetadata { + title: frontmatter.title, + description: frontmatter.description, + author: frontmatter.author, + pub_datetime: frontmatter.pub_datetime, + url, + tags: Some(tags_vec.clone()), + }); } } diff --git a/src/subcommand_serve.rs b/src/subcommand_serve.rs index be4835c..2e9c681 100644 --- a/src/subcommand_serve.rs +++ b/src/subcommand_serve.rs @@ -123,11 +123,11 @@ async fn html_fallback_middleware(mut request: Request, next: Next) -> Response fn watch_files(config_path: &Path, _rebuild_flag: Arc>) -> Result<()> { let config_path_clone = config_path.to_path_buf(); - // 設定を取得 - let serve_config = get_serve_config(config_path)?; - let dist_dir = serve_config.dist.clone(); - let static_dir = serve_config.r#static.clone(); - let layouts_dir = serve_config.layouts.clone(); + // 設定を取得(subcommand_build の load_config を直接使用) + let config = subcommand_build::load_config(config_path)?; + let dist_dir = config.dist.clone(); + let static_dir = config.r#static.clone(); + let layouts_dir = config.layouts.clone(); let current_dir = std::env::current_dir().context("Failed to get current directory")?; let dist_path = current_dir.join(&dist_dir); @@ -189,7 +189,7 @@ fn watch_files(config_path: &Path, _rebuild_flag: Arc>) -> Result<() // 変更されたファイル数を表示 let file_count = changed_files.len(); let file_desc = if file_count == 1 { - format!("1 file") + "1 file".to_string() } else { format!("{} files", file_count) }; @@ -237,54 +237,8 @@ fn watch_files(config_path: &Path, _rebuild_flag: Arc>) -> Result<() /// 設定ファイルから dist ディレクトリのパスを取得 fn get_dist_dir(config_path: &Path) -> Result { - let serve_config = get_serve_config(config_path)?; - Ok(serve_config.dist) -} - -/// serve コマンド用の設定情報 -#[derive(Debug)] -struct ServeConfig { - dist: String, - r#static: String, - layouts: String, -} - -/// 設定ファイルから serve コマンドに必要な情報を取得 -fn get_serve_config(config_path: &Path) -> Result { - use serde::Deserialize; - - #[derive(Debug, Deserialize)] - struct Config { - #[serde(default = "default_dist")] - dist: String, - #[serde(default = "default_static")] - r#static: String, - #[serde(default = "default_layouts")] - layouts: String, - } - - fn default_dist() -> String { - "dist".to_string() - } - - fn default_static() -> String { - "static".to_string() - } - - fn default_layouts() -> String { - "layouts".to_string() - } - - let content = std::fs::read_to_string(config_path) - .with_context(|| format!("Failed to read config file: {}", config_path.display()))?; - let config: Config = toml::from_str(&content) - .with_context(|| format!("Failed to parse config file: {}", config_path.display()))?; - - Ok(ServeConfig { - dist: config.dist, - r#static: config.r#static, - layouts: config.layouts, - }) + let config = subcommand_build::load_config(config_path)?; + Ok(config.dist) } /// 変更されたファイルを分類する From 9b4206381f3169d70a73240554915e5278c00190 Mon Sep 17 00:00:00 2001 From: zztkm <33755694+zztkm@users.noreply.github.com.> Date: Sat, 31 Jan 2026 05:09:28 +0000 Subject: [PATCH 3/4] =?UTF-8?q?cargo=20fmt=20=E3=82=92=E9=81=A9=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- src/subcommand_build.rs | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/subcommand_build.rs b/src/subcommand_build.rs index 8173269..a5d7143 100644 --- a/src/subcommand_build.rs +++ b/src/subcommand_build.rs @@ -204,7 +204,9 @@ fn markdown_to_html(markdown: &str, allow_dangerous_html: bool) -> Result Result>> { +pub(crate) fn load_templates( + layouts_dir: &str, +) -> Result>> { let mut templates = HashMap::new(); let pattern = format!("{}/**/*.html", layouts_dir); @@ -591,9 +593,8 @@ pub(crate) fn copy_single_static_file( // 親ディレクトリを作成 if let Some(parent) = dest_path.parent() { - fs::create_dir_all(parent).with_context(|| { - format!("Failed to create directory: {}", parent.display()) - })?; + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory: {}", parent.display()))?; } // ファイルをコピー @@ -736,10 +737,7 @@ fn to_relative_path(path: &Path) -> PathBuf { /// 増分ビルドのエントリポイント /// 変更されたファイルの種別に応じて最小限の再ビルドを行う -pub fn run_incremental_build( - config_path: &Path, - changed_files: &[ChangedFile], -) -> Result<()> { +pub fn run_incremental_build(config_path: &Path, changed_files: &[ChangedFile]) -> Result<()> { // 設定ファイルを読み込む let config = load_config(config_path)?; @@ -802,9 +800,7 @@ pub fn run_incremental_build( // 設定ファイル変更時はフルビルド // この場合は run_build() を呼び出すべきなので、 // 呼び出し側で処理する - return Err(anyhow::anyhow!( - "Config changed, full rebuild required" - )); + return Err(anyhow::anyhow!("Config changed, full rebuild required")); } ChangedFile::Deleted(path) => { // ファイル削除時は対応する出力ファイルを削除 @@ -825,7 +821,12 @@ pub fn run_incremental_build( need_regenerate_tags = true; } else if path.starts_with(current_dir.join(&config.r#static)) { // 静的ファイルの削除 - delete_output_file(path, ¤t_dir.join(&config.r#static).to_string_lossy(), &config.dist, None)?; + delete_output_file( + path, + ¤t_dir.join(&config.r#static).to_string_lossy(), + &config.dist, + None, + )?; } else if path.starts_with(current_dir.join(&config.layouts)) { // テンプレートが削除された場合はフルビルドを要求 return Err(anyhow::anyhow!( From 99e4e6c9ca266bc230442f49a2f574fc31ca71c4 Mon Sep 17 00:00:00 2001 From: zztkm <33755694+zztkm@users.noreply.github.com.> Date: Sat, 31 Jan 2026 08:19:42 +0000 Subject: [PATCH 4/4] =?UTF-8?q?=E3=82=BF=E3=82=B0=E5=90=8D=E3=81=AE?= =?UTF-8?q?=E3=83=91=E3=82=B9=E3=83=88=E3=83=A9=E3=83=90=E3=83=BC=E3=82=B5?= =?UTF-8?q?=E3=83=AB=E8=84=86=E5=BC=B1=E6=80=A7=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit タグ名に危険な文字列(.., /, \, null バイト)が含まれている場合、 タグページの生成をスキップして警告を表示するように修正。 これにより、悪意のあるタグ名による dist ディレクトリ外への ファイル書き込みを防止。 Co-Authored-By: Claude Opus 4.5 --- src/subcommand_build.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/subcommand_build.rs b/src/subcommand_build.rs index a5d7143..55b5eb7 100644 --- a/src/subcommand_build.rs +++ b/src/subcommand_build.rs @@ -107,6 +107,27 @@ fn default_layouts() -> String { "layouts".to_string() } +/// タグ名が安全かどうかを検証する +/// パストラバーサル攻撃を防ぐため、危険な文字列を含むタグ名を拒否する +fn is_safe_tag_name(tag: &str) -> bool { + // 空のタグ名は不正 + if tag.is_empty() { + return false; + } + + // パストラバーサルシーケンスを含む場合は不正 + if tag.contains("..") || tag.contains('/') || tag.contains('\\') { + return false; + } + + // null バイトを含む場合は不正 + if tag.contains('\0') { + return false; + } + + true +} + /// YAML frontmatter の構造 #[derive(Debug, Deserialize, Default)] struct FrontMatter { @@ -511,6 +532,15 @@ fn generate_tag_pages( // 各タグのページを生成 for (tag_name, posts) in tag_to_posts { + // タグ名の安全性を検証(パストラバーサル攻撃を防ぐ) + if !is_safe_tag_name(&tag_name) { + eprintln!( + "[WARN] Skipping unsafe tag name: {:?} (contains path traversal characters)", + tag_name + ); + continue; + } + let context = TagPageContext { site_title: config.site_title.clone(), site_description: config.site_description.clone(),