src/assets.rs (10280B)
1 //! 정적 에셋 수집과 콘텐츠 해시 기반 파일명.
2 //!
3 //! 파일명에 해시를 넣는다. 쿼리 문자열(`?v=…`) 방식은 두 가지가 나쁘다: 일부 CDN과
4 //! 프록시가 쿼리를 캐시 키에서 빼버리고, 실무에서 흔히 보이는 "빌드마다 하나의 스탬프를
5 //! 전 파일에 붙이는" 형태는 CSS 한 줄만 고쳐도 나머지 전부를 무효화한다.
6 //! 파일별 콘텐츠 해시는 바뀐 파일만 무효화되고, 모든 호스트에서 동일하게 동작한다.
7
8 use crate::config::Assets as AssetsConfig;
9 use anyhow::{Context, Result};
10 use std::collections::BTreeMap;
11 use std::path::Path;
12 use walkdir::WalkDir;
13
14 /// 해시를 붙일 확장자. 이미지·폰트는 대개 마크다운이나 CSS에서 문자열 경로로 직접
15 /// 참조되므로 이름을 바꾸면 그 참조가 깨진다. `CNAME`처럼 이름 자체가 계약인 파일도
16 /// 있어서, 해시는 `asset()`으로 조회하는 것들에만 적용한다.
17 const FINGERPRINTED: &[&str] = &["css", "js"];
18
19 pub const MANIFEST_PATH: &str = "asset-manifest.json";
20
21 #[derive(Debug, Default)]
22 pub struct Assets {
23 /// 출력 경로(상대) → 내용
24 pub files: BTreeMap<String, Vec<u8>>,
25 /// 논리 경로(`css/main.css`) → 출력 URL(`/css/main.a1b2c3d4.css`)
26 pub manifest: BTreeMap<String, String>,
27 /// 사이트가 도메인 루트가 아닐 때의 접두사. 페이지 URL과 같은 규칙을 따라야
28 /// `<link href>`와 내비게이션 링크가 같은 곳을 가리킨다.
29 base_path: String,
30 }
31
32 impl Assets {
33 /// `static/` 아래를 전부 읽어들인다.
34 pub fn collect(root: &Path, cfg: &AssetsConfig, base_path: &str) -> Result<Self> {
35 let mut out = Self {
36 base_path: base_path.to_string(),
37 ..Self::default()
38 };
39 let dir = root.join(&cfg.source_dir);
40 if !dir.is_dir() {
41 return Ok(out);
42 }
43
44 for entry in WalkDir::new(&dir).sort_by_file_name() {
45 let entry = entry.with_context(|| format!("{} 순회 실패", dir.display()))?;
46 if !entry.file_type().is_file() {
47 continue;
48 }
49 let path = entry.path();
50 let logical = path
51 .strip_prefix(&dir)
52 .unwrap_or(path)
53 .to_string_lossy()
54 .replace('\\', "/");
55 let bytes = std::fs::read(path)
56 .with_context(|| format!("{}을(를) 읽을 수 없습니다", path.display()))?;
57 out.insert(&logical, bytes, cfg.fingerprint);
58 }
59 Ok(out)
60 }
61
62 /// 빌드가 생성한 에셋(예: 하이라이트 스타일시트)을 같은 규칙으로 넣는다.
63 pub fn insert(&mut self, logical: &str, bytes: Vec<u8>, fingerprint: bool) {
64 let ext = Path::new(logical)
65 .extension()
66 .and_then(|e| e.to_str())
67 .unwrap_or("");
68 let out_path = if fingerprint && FINGERPRINTED.contains(&ext) {
69 hashed_name(logical, &bytes)
70 } else {
71 logical.to_string()
72 };
73 let base = &self.base_path;
74 self.manifest
75 .insert(logical.to_string(), format!("{base}/{out_path}"));
76 self.files.insert(out_path, bytes);
77 }
78
79 /// 논리 경로로 출력 URL을 찾는다.
80 pub fn url(&self, logical: &str) -> Option<&str> {
81 self.manifest
82 .get(logical.trim_start_matches('/'))
83 .map(|s| s.as_str())
84 }
85
86 /// 매니페스트를 출력에 포함시킨다. 서비스 워커나 외부 도구가 논리 이름과
87 /// 실제 파일을 맞출 수 있어야 한다.
88 pub fn write_manifest(&mut self) {
89 let json = serde_json::to_string_pretty(&self.manifest).unwrap_or_else(|_| "{}".into());
90 self.files
91 .insert(MANIFEST_PATH.to_string(), json.into_bytes());
92 }
93 }
94
95 /// `css/main.css` + 내용 → `css/main.a1b2c3d4.css`
96 ///
97 /// blake3를 쓴다. 캐시 무효화가 목적이라 암호학적 강도는 필요 없고, 속도가 중요하다.
98 fn hashed_name(logical: &str, bytes: &[u8]) -> String {
99 let hash = blake3::hash(bytes);
100 let short = &hash.to_hex()[..8];
101
102 let path = Path::new(logical);
103 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
104 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(logical);
105 let parent = path.parent().map(|p| p.to_string_lossy().into_owned());
106
107 let name = if ext.is_empty() {
108 format!("{stem}.{short}")
109 } else {
110 format!("{stem}.{short}.{ext}")
111 };
112 match parent.as_deref() {
113 Some("") | None => name,
114 Some(p) => format!("{p}/{name}"),
115 }
116 }
117
118 #[cfg(test)]
119 mod tests {
120 use super::*;
121
122 fn cfg() -> AssetsConfig {
123 AssetsConfig::default()
124 }
125
126 #[test]
127 fn hashes_go_before_the_extension() {
128 let n = hashed_name("css/main.css", b"body{}");
129 assert!(n.starts_with("css/main."), "실제: {n}");
130 assert!(n.ends_with(".css"), "실제: {n}");
131 // css/main.<8자>.css
132 assert_eq!(n.matches('.').count(), 2, "실제: {n}");
133 }
134
135 #[test]
136 fn different_content_gives_a_different_name() {
137 assert_ne!(
138 hashed_name("a.css", b"one"),
139 hashed_name("a.css", b"two"),
140 "내용이 다른데 이름이 같으면 캐시가 안 바뀐다"
141 );
142 }
143
144 #[test]
145 fn same_content_is_stable_across_builds() {
146 // 빌드마다 이름이 달라지면 재현 가능한 빌드가 깨진다.
147 assert_eq!(hashed_name("a.css", b"same"), hashed_name("a.css", b"same"));
148 }
149
150 #[test]
151 fn root_level_files_keep_a_flat_name() {
152 let n = hashed_name("main.css", b"x");
153 assert!(!n.contains('/'), "실제: {n}");
154 }
155
156 #[test]
157 fn only_css_and_js_are_fingerprinted() {
158 let mut a = Assets::default();
159 a.insert("css/main.css", b"body{}".to_vec(), true);
160 a.insert("js/app.js", b"//x".to_vec(), true);
161 a.insert("images/logo.png", b"\x89PNG".to_vec(), true);
162 // 이름 자체가 계약인 파일들 — 해시가 붙으면 기능이 깨진다
163 a.insert("CNAME", b"example.com".to_vec(), true);
164 a.insert("robots.txt", b"User-agent: *".to_vec(), true);
165
166 assert_ne!(a.url("css/main.css"), Some("/css/main.css"));
167 assert_ne!(a.url("js/app.js"), Some("/js/app.js"));
168 assert_eq!(a.url("images/logo.png"), Some("/images/logo.png"));
169 assert_eq!(a.url("CNAME"), Some("/CNAME"));
170 assert_eq!(a.url("robots.txt"), Some("/robots.txt"));
171 }
172
173 #[test]
174 fn fingerprinting_can_be_disabled() {
175 let mut a = Assets::default();
176 a.insert("css/main.css", b"body{}".to_vec(), false);
177 assert_eq!(a.url("css/main.css"), Some("/css/main.css"));
178 }
179
180 #[test]
181 fn lookup_tolerates_a_leading_slash() {
182 let mut a = Assets::default();
183 a.insert("css/main.css", b"x".to_vec(), false);
184 assert_eq!(a.url("/css/main.css"), a.url("css/main.css"));
185 }
186
187 #[test]
188 fn collects_from_disk_and_maps_logical_names() {
189 let root = std::env::temp_dir().join(format!("sqzass-assets-{}", std::process::id()));
190 let _ = std::fs::remove_dir_all(&root);
191 let sdir = root.join("static/css");
192 std::fs::create_dir_all(&sdir).unwrap();
193 std::fs::write(sdir.join("main.css"), "body{color:red}").unwrap();
194 std::fs::write(root.join("static/CNAME"), "example.com").unwrap();
195
196 let a = Assets::collect(&root, &cfg(), "").unwrap();
197 assert!(a.url("css/main.css").unwrap().starts_with("/css/main."));
198 assert_eq!(a.url("CNAME"), Some("/CNAME"));
199 assert_eq!(a.files.len(), 2);
200
201 // 서브경로에 올라가는 사이트는 에셋 URL도 같은 접두사를 가져야 한다.
202 // 페이지 링크만 접두사를 갖고 스타일시트는 안 갖는 상태가 가장 나쁘다:
203 // 사이트는 뜨는데 아무 스타일도 안 먹는다.
204 let sub = Assets::collect(&root, &cfg(), "/repo").unwrap();
205 assert!(
206 sub.url("css/main.css")
207 .unwrap()
208 .starts_with("/repo/css/main."),
209 "실제: {:?}",
210 sub.url("css/main.css")
211 );
212 assert_eq!(sub.url("CNAME"), Some("/repo/CNAME"));
213
214 std::fs::remove_dir_all(&root).ok();
215 }
216
217 /// 호스트별 설정 파일은 **이름이 곧 계약**이다. `_headers.9f8e7d6c`는 Netlify가
218 /// 영영 찾지 않을 파일이고, `.domains.abc`도 Codeberg가 찾지 않는다. 이게
219 /// 조용히 깨지면 배포는 성공하면서 도메인만 안 붙는다.
220 #[test]
221 fn host_config_files_pass_through_with_their_names() {
222 let root =
223 std::env::temp_dir().join(format!("sqzass-assets-passthru-{}", std::process::id()));
224 let _ = std::fs::remove_dir_all(&root);
225 let s = root.join("static");
226 std::fs::create_dir_all(s.join(".well-known")).unwrap();
227 for (name, body) in [
228 ("_headers", "/*\n X-Frame-Options: DENY\n"),
229 ("_redirects", "/old /new 301\n"),
230 (".domains", "example.com\n"),
231 ("CNAME", "example.com\n"),
232 ("robots.txt", "User-agent: *\n"),
233 ] {
234 std::fs::write(s.join(name), body).unwrap();
235 }
236 std::fs::write(s.join(".well-known/security.txt"), "Contact: x\n").unwrap();
237
238 let a = Assets::collect(&root, &cfg(), "").unwrap();
239 for name in [
240 "_headers",
241 "_redirects",
242 ".domains",
243 "CNAME",
244 "robots.txt",
245 ".well-known/security.txt",
246 ] {
247 assert_eq!(
248 a.url(name),
249 Some(format!("/{name}").as_str()),
250 "{name} 의 이름이 바뀌었다"
251 );
252 assert!(a.files.contains_key(name), "{name} 이 출력에 없다");
253 }
254 let _ = std::fs::remove_dir_all(&root);
255 }
256
257 #[test]
258 fn missing_static_dir_is_not_an_error() {
259 let root = std::env::temp_dir().join("sqzass-assets-nonexistent-xyz");
260 let a = Assets::collect(&root, &cfg(), "").unwrap();
261 assert!(a.files.is_empty());
262 }
263 }