1pub mod cookie_jar;
7pub mod info;
8pub mod lock;
9pub mod meta;
10pub mod paths;
11
12use std::path::{Path, PathBuf};
13use std::time::{Duration, SystemTime};
14
15use serde::{Deserialize, Serialize};
16
17use crate::shared::error::{Error, ErrorCode};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ProfileEntry {
22 pub backend: String,
23 pub name: String,
24 pub path: PathBuf,
25 pub size_bytes: u64,
26 pub metadata_present: bool,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub last_used_at_rfc3339: Option<String>,
29 pub locked: bool,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct DownloadEntry {
34 pub filename: String,
35 pub path: PathBuf,
36 pub size_bytes: u64,
37 pub state: String,
38}
39
40fn root_or_default(root: Option<&Path>) -> PathBuf {
41 root.map(Path::to_path_buf)
42 .unwrap_or_else(paths::default_root)
43}
44
45pub fn list(profile_root: Option<&Path>) -> Result<Vec<ProfileEntry>, Error> {
46 let root = root_or_default(profile_root);
47 if !root.exists() {
48 return Ok(Vec::new());
49 }
50 let mut out = Vec::new();
51 for backend in paths::KNOWN_BACKENDS {
52 let backend_dir = root.join(backend);
53 if !backend_dir.is_dir() {
54 continue;
55 }
56 let read = std::fs::read_dir(&backend_dir).map_err(|e| {
57 Error::new(
58 ErrorCode::ProfileRootUnavailable,
59 format!("read_dir({}): {e}", backend_dir.display()),
60 )
61 })?;
62 for entry in read.flatten() {
63 let path = entry.path();
64 if !path.is_dir() {
65 continue;
66 }
67 let name = match path.file_name().and_then(|s| s.to_str()) {
68 Some(n) => n.to_string(),
69 None => continue,
70 };
71 out.push(info_at(&path, backend, &name)?);
72 }
73 }
74 out.sort_by(|a, b| a.backend.cmp(&b.backend).then_with(|| a.name.cmp(&b.name)));
75 Ok(out)
76}
77
78pub fn info(
79 name: &str,
80 backend: Option<&str>,
81 profile_root: Option<&Path>,
82) -> Result<ProfileEntry, Error> {
83 paths::validate_name(name)?;
84 let root = root_or_default(profile_root);
85 let (backend, dir) = resolve_backend_scoped_profile(&root, name, backend)?;
86 info_at(&dir, &backend, name)
87}
88
89fn info_at(dir: &Path, backend: &str, name: &str) -> Result<ProfileEntry, Error> {
90 let meta_path = dir.join("afhttp-profile.json");
91 let (metadata_present, last_used_at) = if meta_path.exists() {
92 let m = read_profile_meta(&meta_path)?;
93 validate_profile_meta(&m, backend, name, dir)?;
94 (true, Some(m.last_used_at_rfc3339))
95 } else {
96 (false, None)
97 };
98 let size_bytes = dir_size(dir).unwrap_or(0);
99 let locked = lock::probe(dir);
100 Ok(ProfileEntry {
101 backend: backend.to_string(),
102 name: name.to_string(),
103 path: dir.to_path_buf(),
104 size_bytes,
105 metadata_present,
106 last_used_at_rfc3339: last_used_at,
107 locked,
108 })
109}
110
111pub fn lock_status(
112 name: &str,
113 backend: Option<&str>,
114 profile_root: Option<&Path>,
115) -> Result<lock::LockStatus, Error> {
116 paths::validate_name(name)?;
117 let root = root_or_default(profile_root);
118 let (_, dir) = resolve_backend_scoped_profile(&root, name, backend)?;
119 Ok(lock::status(&dir))
120}
121
122pub fn downloads(
123 name: &str,
124 backend: Option<&str>,
125 profile_root: Option<&Path>,
126) -> Result<Vec<DownloadEntry>, Error> {
127 let entry = info(name, backend, profile_root)?;
128 downloads_at(&entry.path)
129}
130
131fn downloads_at(profile_dir: &Path) -> Result<Vec<DownloadEntry>, Error> {
132 let dir = profile_dir.join("downloads");
133 if !dir.exists() {
134 return Ok(Vec::new());
135 }
136 let read = std::fs::read_dir(&dir).map_err(|e| {
137 Error::new(
138 ErrorCode::IoError,
139 format!("read_dir({}): {e}", dir.display()),
140 )
141 })?;
142 let mut out = Vec::new();
143 for entry in read.flatten() {
144 let path = entry.path();
145 let Ok(meta) = entry.metadata() else {
146 continue;
147 };
148 if !meta.is_file() {
149 continue;
150 }
151 let Some(filename) = path.file_name().and_then(|name| name.to_str()) else {
152 continue;
153 };
154 let filename = filename.to_string();
155 let state = if path.extension().and_then(|ext| ext.to_str()) == Some("crdownload") {
156 "in_progress"
157 } else {
158 "completed"
159 };
160 let path = path.canonicalize().unwrap_or(path);
161 out.push(DownloadEntry {
162 filename,
163 path,
164 size_bytes: meta.len(),
165 state: state.to_string(),
166 });
167 }
168 out.sort_by(|a, b| a.filename.cmp(&b.filename));
169 Ok(out)
170}
171
172pub fn delete(
173 name: &str,
174 confirm: &str,
175 backend: Option<&str>,
176 profile_root: Option<&Path>,
177) -> Result<(), Error> {
178 paths::validate_name(name)?;
179 if name != confirm {
180 return Err(Error::new(
181 ErrorCode::InvalidArgument,
182 format!("profile delete: --confirm must match name (got {confirm:?})"),
183 ));
184 }
185 let root = root_or_default(profile_root);
186 let (backend, dir) = resolve_backend_scoped_profile(&root, name, backend)?;
187 if lock::probe(&dir) {
188 return Err(Error::new(
189 ErrorCode::ProfileDeleteLocked,
190 format!("profile {backend}/{name:?} is locked; cannot delete"),
191 ));
192 }
193 std::fs::remove_dir_all(&dir).map_err(|e| {
194 Error::new(
195 ErrorCode::IoError,
196 format!("remove_dir_all({}): {e}", dir.display()),
197 )
198 })
199}
200
201fn resolve_backend_scoped_profile(
202 root: &Path,
203 name: &str,
204 backend: Option<&str>,
205) -> Result<(String, PathBuf), Error> {
206 if let Some(backend) = backend {
207 paths::validate_backend_key(backend)?;
208 let dir = paths::join_root_backend_name(root, backend, name)?;
209 if dir.exists() {
210 return Ok((backend.to_string(), dir));
211 }
212 return Err(Error::new(
213 ErrorCode::ProfileNotFound,
214 format!(
215 "profile {name:?} not found for backend {backend:?} at {}",
216 dir.display()
217 ),
218 ));
219 }
220
221 let mut candidates = Vec::new();
222 for backend in paths::KNOWN_BACKENDS {
223 let dir = root.join(backend).join(name);
224 if dir.exists() {
225 candidates.push(((*backend).to_string(), dir));
226 }
227 }
228 match candidates.len() {
229 0 => Err(Error::new(
230 ErrorCode::ProfileNotFound,
231 format!(
232 "profile {name:?} not found under backend-scoped root {}",
233 root.display()
234 ),
235 )),
236 1 => Ok(candidates.remove(0)),
237 _ => Err(Error::new(
238 ErrorCode::InvalidArgument,
239 format!(
240 "profile {name:?} exists under multiple backends ({}); pass --backend <backend>",
241 candidates
242 .iter()
243 .map(|(backend, _)| backend.as_str())
244 .collect::<Vec<_>>()
245 .join(", ")
246 ),
247 )),
248 }
249}
250
251pub fn read_profile_meta(path: &Path) -> Result<meta::ProfileMeta, Error> {
252 let s = std::fs::read_to_string(path).map_err(|e| {
253 Error::new(
254 ErrorCode::IoError,
255 format!("read profile metadata {}: {e}", path.display()),
256 )
257 })?;
258 serde_json::from_str::<meta::ProfileMeta>(&s).map_err(|e| {
259 Error::new(
260 ErrorCode::ProfileInvalidName,
261 format!(
262 "profile metadata {} is not schema v{}: {e}; delete/recreate this profile",
263 path.display(),
264 meta::ProfileMeta::SCHEMA_VERSION
265 ),
266 )
267 })
268}
269
270pub fn validate_profile_meta(
271 meta: &meta::ProfileMeta,
272 backend: &str,
273 name: &str,
274 dir: &Path,
275) -> Result<(), Error> {
276 if meta.schema_version != meta::ProfileMeta::SCHEMA_VERSION {
277 return Err(Error::new(
278 ErrorCode::ProfileInvalidName,
279 format!(
280 "profile metadata at {} has schema_version {}; expected {}; delete/recreate this profile",
281 dir.join("afhttp-profile.json").display(),
282 meta.schema_version,
283 meta::ProfileMeta::SCHEMA_VERSION
284 ),
285 ));
286 }
287 if meta.backend != backend {
288 return Err(Error::new(
289 ErrorCode::ProfileInvalidName,
290 format!(
291 "profile metadata backend {:?} does not match path backend {:?} at {}; delete/recreate this profile",
292 meta.backend,
293 backend,
294 dir.display()
295 ),
296 ));
297 }
298 if meta.name != name {
299 return Err(Error::new(
300 ErrorCode::ProfileInvalidName,
301 format!(
302 "profile metadata name {:?} does not match path name {:?} at {}; delete/recreate this profile",
303 meta.name,
304 name,
305 dir.display()
306 ),
307 ));
308 }
309 Ok(())
310}
311
312pub fn prune(
313 older_than: Duration,
314 dry_run: bool,
315 profile_root: Option<&Path>,
316) -> Result<Vec<ProfileEntry>, Error> {
317 let entries = list(profile_root)?;
318 let now = SystemTime::now();
319 let mut removed = Vec::new();
320 for entry in entries {
321 if entry.locked {
322 continue;
323 }
324 let too_old = match std::fs::metadata(&entry.path) {
325 Ok(m) => match m.modified() {
326 Ok(t) => match now.duration_since(t) {
327 Ok(age) => age >= older_than,
328 Err(_) => false,
329 },
330 Err(_) => false,
331 },
332 Err(_) => false,
333 };
334 if !too_old {
335 continue;
336 }
337 if !dry_run {
338 std::fs::remove_dir_all(&entry.path).map_err(|e| {
339 Error::new(
340 ErrorCode::IoError,
341 format!("remove_dir_all({}): {e}", entry.path.display()),
342 )
343 })?;
344 }
345 removed.push(entry);
346 }
347 Ok(removed)
348}
349
350fn dir_size(dir: &Path) -> std::io::Result<u64> {
351 let mut total: u64 = 0;
352 let mut stack = vec![dir.to_path_buf()];
353 while let Some(p) = stack.pop() {
354 for entry in std::fs::read_dir(&p)?.flatten() {
355 let path = entry.path();
356 let md = match entry.metadata() {
357 Ok(m) => m,
358 Err(_) => continue,
359 };
360 if md.is_file() {
361 total = total.saturating_add(md.len());
362 } else if md.is_dir() {
363 stack.push(path);
364 }
365 }
366 }
367 Ok(total)
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 fn touch_profile(root: &Path, backend: &str, name: &str) -> PathBuf {
375 let dir = root.join(backend).join(name);
376 std::fs::create_dir_all(&dir).unwrap();
377 let meta = meta::ProfileMeta::new(name, backend);
378 std::fs::write(
379 dir.join("afhttp-profile.json"),
380 serde_json::to_string(&meta).unwrap(),
381 )
382 .unwrap();
383 dir
384 }
385
386 #[test]
387 fn lists_existing_profiles() {
388 let tmp = tempfile::tempdir().unwrap();
389 touch_profile(tmp.path(), "brave", "work");
390 touch_profile(tmp.path(), "chromium", "alpha");
391 std::fs::create_dir_all(tmp.path().join("old-layout")).unwrap();
392 let entries = list(Some(tmp.path())).unwrap();
393 assert_eq!(entries.len(), 2);
394 assert_eq!(entries[0].backend, "brave"); assert_eq!(entries[0].name, "work");
396 assert_eq!(entries[1].backend, "chromium");
397 assert_eq!(entries[1].name, "alpha");
398 assert!(entries[0].metadata_present);
399 }
400
401 #[test]
402 fn info_returns_profile_details() {
403 let tmp = tempfile::tempdir().unwrap();
404 touch_profile(tmp.path(), "brave", "work");
405 let entry = info("work", Some("brave"), Some(tmp.path())).unwrap();
406 assert_eq!(entry.name, "work");
407 assert_eq!(entry.backend, "brave");
408 assert!(entry.metadata_present);
409 assert!(!entry.locked);
410 }
411
412 #[test]
413 fn downloads_lists_profile_download_artifacts() {
414 let tmp = tempfile::tempdir().unwrap();
415 let profile = touch_profile(tmp.path(), "brave", "work");
416 let download_dir = profile.join("downloads");
417 std::fs::create_dir_all(&download_dir).unwrap();
418 std::fs::write(download_dir.join("report.csv"), "abc").unwrap();
419 std::fs::write(download_dir.join("pending.bin.crdownload"), "partial").unwrap();
420
421 let entries = downloads("work", Some("brave"), Some(tmp.path())).unwrap();
422 assert_eq!(entries.len(), 2);
423 assert_eq!(entries[0].filename, "pending.bin.crdownload");
424 assert_eq!(entries[0].state, "in_progress");
425 assert_eq!(entries[1].filename, "report.csv");
426 assert_eq!(entries[1].size_bytes, 3);
427 assert_eq!(entries[1].state, "completed");
428 }
429
430 #[test]
431 fn info_missing_profile_returns_not_found() {
432 let tmp = tempfile::tempdir().unwrap();
433 let err = info("nope", Some("brave"), Some(tmp.path())).err().unwrap();
434 assert_eq!(err.error_code, ErrorCode::ProfileNotFound);
435 }
436
437 #[test]
438 fn delete_requires_matching_confirm() {
439 let tmp = tempfile::tempdir().unwrap();
440 touch_profile(tmp.path(), "brave", "work");
441 let err = delete("work", "different", Some("brave"), Some(tmp.path()))
442 .err()
443 .unwrap();
444 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
445 }
446
447 #[test]
448 fn delete_removes_profile() {
449 let tmp = tempfile::tempdir().unwrap();
450 let dir = touch_profile(tmp.path(), "brave", "work");
451 delete("work", "work", Some("brave"), Some(tmp.path())).unwrap();
452 assert!(!dir.exists());
453 }
454
455 #[test]
456 fn delete_missing_returns_not_found() {
457 let tmp = tempfile::tempdir().unwrap();
458 let err = delete("nope", "nope", Some("brave"), Some(tmp.path()))
459 .err()
460 .unwrap();
461 assert_eq!(err.error_code, ErrorCode::ProfileNotFound);
462 }
463
464 #[test]
465 fn delete_locked_refuses() {
466 let tmp = tempfile::tempdir().unwrap();
467 let dir = touch_profile(tmp.path(), "brave", "work");
468 let _g = lock::Guard::acquire(&dir).unwrap();
470 let err = delete("work", "work", Some("brave"), Some(tmp.path()))
471 .err()
472 .unwrap();
473 assert_eq!(err.error_code, ErrorCode::ProfileDeleteLocked);
474 }
475
476 #[test]
477 fn prune_dry_run_does_not_delete() {
478 let tmp = tempfile::tempdir().unwrap();
479 let dir = touch_profile(tmp.path(), "brave", "work");
480 let two_hours = SystemTime::now() - Duration::from_secs(7200);
482 filetime::set_file_mtime(&dir, filetime::FileTime::from_system_time(two_hours)).ok();
483 let removed = prune(Duration::from_secs(3600), true, Some(tmp.path())).unwrap();
484 assert_eq!(removed.len(), 1);
485 assert!(dir.exists());
486 }
487
488 #[test]
489 fn prune_removes_old_profiles() {
490 let tmp = tempfile::tempdir().unwrap();
491 let dir = touch_profile(tmp.path(), "brave", "work");
492 let two_hours = SystemTime::now() - Duration::from_secs(7200);
493 filetime::set_file_mtime(&dir, filetime::FileTime::from_system_time(two_hours)).ok();
494 let removed = prune(Duration::from_secs(3600), false, Some(tmp.path())).unwrap();
495 assert_eq!(removed.len(), 1);
496 assert!(!dir.exists());
497 }
498
499 #[test]
500 fn same_name_multiple_backends_requires_backend() {
501 let tmp = tempfile::tempdir().unwrap();
502 touch_profile(tmp.path(), "brave", "work");
503 touch_profile(tmp.path(), "chromium", "work");
504 let err = info("work", None, Some(tmp.path())).err().unwrap();
505 assert_eq!(err.error_code, ErrorCode::InvalidArgument);
506 assert!(err.detail.contains("--backend"));
507
508 let chromium = info("work", Some("chromium"), Some(tmp.path())).unwrap();
509 assert_eq!(chromium.backend, "chromium");
510 }
511
512 #[test]
513 fn same_name_different_backends_have_independent_locks_and_downloads() {
514 let tmp = tempfile::tempdir().unwrap();
515 let brave = touch_profile(tmp.path(), "brave", "work");
516 let chromium = touch_profile(tmp.path(), "chromium", "work");
517 let _guard = lock::Guard::acquire(&brave).unwrap();
518
519 assert!(
520 lock_status("work", Some("brave"), Some(tmp.path()))
521 .unwrap()
522 .locked
523 );
524 assert!(
525 !lock_status("work", Some("chromium"), Some(tmp.path()))
526 .unwrap()
527 .locked
528 );
529
530 std::fs::create_dir_all(brave.join("downloads")).unwrap();
531 std::fs::create_dir_all(chromium.join("downloads")).unwrap();
532 std::fs::write(brave.join("downloads").join("brave.txt"), "a").unwrap();
533 std::fs::write(chromium.join("downloads").join("chromium.txt"), "b").unwrap();
534 let brave_downloads = downloads("work", Some("brave"), Some(tmp.path())).unwrap();
535 let chromium_downloads = downloads("work", Some("chromium"), Some(tmp.path())).unwrap();
536 assert_eq!(brave_downloads[0].filename, "brave.txt");
537 assert_eq!(chromium_downloads[0].filename, "chromium.txt");
538 }
539
540 #[test]
541 fn metadata_backend_mismatch_is_rejected() {
542 let tmp = tempfile::tempdir().unwrap();
543 let dir = tmp.path().join("brave").join("work");
544 std::fs::create_dir_all(&dir).unwrap();
545 let meta = meta::ProfileMeta::new("work", "chromium");
546 std::fs::write(
547 dir.join("afhttp-profile.json"),
548 serde_json::to_string(&meta).unwrap(),
549 )
550 .unwrap();
551 let err = info("work", Some("brave"), Some(tmp.path())).err().unwrap();
552 assert_eq!(err.error_code, ErrorCode::ProfileInvalidName);
553 assert!(err.detail.contains("backend"));
554 }
555}