···1919 ; data_dirs : Eio.Fs.dir_ty Eio.Path.t list
2020 }
21212222-let ensure_dir path = Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 path
2222+let ensure_dir ?(perm = 0o755) path = Eio.Path.mkdirs ~exists_ok:true ~perm path
2323+2424+let validate_runtime_base_dir base_path =
2525+ (* Validate the base XDG_RUNTIME_DIR has correct permissions per spec *)
2626+ try
2727+ let path_str = Eio.Path.native_exn base_path in
2828+ let stat = Eio.Path.stat ~follow:true base_path in
2929+ let current_perm = stat.perm land 0o777 in
3030+ if current_perm <> 0o700 then
3131+ failwith
3232+ (Printf.sprintf
3333+ "XDG_RUNTIME_DIR base directory %s has incorrect permissions: %o (must be 0700)"
3434+ path_str
3535+ current_perm);
3636+ (* Check ownership - directory should be owned by current user *)
3737+ let uid = Unix.getuid () in
3838+ if stat.uid <> Int64.of_int uid then
3939+ failwith
4040+ (Printf.sprintf
4141+ "XDG_RUNTIME_DIR base directory %s not owned by current user (uid %d, owner %Ld)"
4242+ path_str
4343+ uid
4444+ stat.uid);
4545+ (* TODO: Check that directory is on local filesystem (not networked).
4646+ This would require filesystem type detection which is OS-specific. *)
4747+ with
4848+ | exn -> failwith (Printf.sprintf "Cannot validate XDG_RUNTIME_DIR: %s" (Printexc.to_string exn))
4949+5050+let ensure_runtime_dir _fs app_runtime_path =
5151+ (* Base directory validation is done in resolve_runtime_dir,
5252+ so we just create the app subdirectory *)
5353+ ensure_dir app_runtime_path
23542455let get_home_dir fs =
2556 let home_str =
···3667 | _ -> failwith "Cannot determine home directory"))
3768 in
3869 Eio.Path.(fs / home_str)
3939-;;
40704171let make_env_var_name app_name suffix = String.uppercase_ascii app_name ^ "_" ^ suffix
42727373+exception Invalid_xdg_path of string
7474+7575+let validate_absolute_path context path =
7676+ if Filename.is_relative path then
7777+ raise (Invalid_xdg_path
7878+ (Printf.sprintf "%s must be an absolute path, got: %s" context path))
7979+4380let resolve_path fs home_path base_path =
4481 if Filename.is_relative base_path
4582 then Eio.Path.(home_path / base_path)
4683 else Eio.Path.(fs / base_path)
4747-;;
48844985(* Helper to resolve system directories (config_dirs or data_dirs) *)
5050-let resolve_system_dirs fs home_path app_name override_suffix xdg_var default_paths =
8686+let resolve_system_dirs fs _home_path app_name override_suffix xdg_var default_paths =
5187 let override_var = make_env_var_name app_name override_suffix in
5288 match Sys.getenv_opt override_var with
5389 | Some dirs when dirs <> "" ->
5490 String.split_on_char ':' dirs
5591 |> List.filter (fun s -> s <> "")
5656- |> List.map (fun path -> Eio.Path.(resolve_path fs home_path path / app_name))
9292+ |> List.filter_map (fun path ->
9393+ try
9494+ validate_absolute_path override_var path;
9595+ Some (Eio.Path.(fs / path / app_name))
9696+ with Invalid_xdg_path _ -> None)
5797 | Some _ | None ->
5898 (match Sys.getenv_opt xdg_var with
5999 | Some dirs when dirs <> "" ->
60100 String.split_on_char ':' dirs
61101 |> List.filter (fun s -> s <> "")
6262- |> List.map (fun path -> Eio.Path.(resolve_path fs home_path path / app_name))
102102+ |> List.filter_map (fun path ->
103103+ try
104104+ validate_absolute_path xdg_var path;
105105+ Some (Eio.Path.(fs / path / app_name))
106106+ with Invalid_xdg_path _ -> None)
63107 | Some _ | None ->
64108 List.map (fun path -> Eio.Path.(fs / path / app_name)) default_paths)
6565-;;
6610967110(* Helper to resolve a user directory with override precedence *)
6868-let resolve_user_dir fs home_path app_name xdg_ctx xdg_getter override_suffix =
111111+let resolve_user_dir fs _home_path app_name xdg_ctx xdg_getter override_suffix =
69112 let override_var = make_env_var_name app_name override_suffix in
70113 match Sys.getenv_opt override_var with
7171- | Some dir when dir <> "" -> resolve_path fs home_path dir, Env override_var
114114+ | Some dir when dir <> "" ->
115115+ validate_absolute_path override_var dir;
116116+ Eio.Path.(fs / dir / app_name), Env override_var
72117 | Some _ | None -> Eio.Path.(fs / xdg_getter xdg_ctx / app_name), Default
7373-;;
7411875119(* Helper to resolve runtime directory (special case since it can be None) *)
7676-let resolve_runtime_dir fs home_path app_name xdg_ctx =
120120+let resolve_runtime_dir fs _home_path app_name xdg_ctx =
77121 let override_var = make_env_var_name app_name "RUNTIME_DIR" in
78122 match Sys.getenv_opt override_var with
7979- | Some dir when dir <> "" -> Some (resolve_path fs home_path dir), Env override_var
123123+ | Some dir when dir <> "" ->
124124+ validate_absolute_path override_var dir;
125125+ (* Validate the base runtime directory has correct permissions *)
126126+ let base_runtime_dir = Eio.Path.(fs / dir) in
127127+ validate_runtime_base_dir base_runtime_dir;
128128+ Some (Eio.Path.(fs / dir / app_name)), Env override_var
80129 | Some _ | None ->
8181- ( Option.map (fun base -> Eio.Path.(fs / base / app_name)) (Xdg.runtime_dir xdg_ctx)
8282- , Default )
8383-;;
130130+ (match Xdg.runtime_dir xdg_ctx with
131131+ | Some base ->
132132+ (* Validate the base runtime directory has correct permissions *)
133133+ let base_runtime_dir = Eio.Path.(fs / base) in
134134+ validate_runtime_base_dir base_runtime_dir;
135135+ Some (Eio.Path.(fs / base / app_name))
136136+ | None -> None), Default
137137+138138+let validate_standard_xdg_vars () =
139139+ (* Validate standard XDG environment variables for absolute paths *)
140140+ let xdg_vars = [
141141+ "XDG_CONFIG_HOME";
142142+ "XDG_DATA_HOME";
143143+ "XDG_CACHE_HOME";
144144+ "XDG_STATE_HOME";
145145+ "XDG_RUNTIME_DIR";
146146+ "XDG_CONFIG_DIRS";
147147+ "XDG_DATA_DIRS";
148148+ ] in
149149+ List.iter (fun var ->
150150+ match Sys.getenv_opt var with
151151+ | Some value when value <> "" ->
152152+ if String.contains value ':' then
153153+ (* Colon-separated list - validate each part *)
154154+ String.split_on_char ':' value
155155+ |> List.filter (fun s -> s <> "")
156156+ |> List.iter (fun path -> validate_absolute_path var path)
157157+ else
158158+ (* Single path *)
159159+ validate_absolute_path var value
160160+ | _ -> ()
161161+ ) xdg_vars
8416285163let create fs app_name =
86164 let fs = fs in
87165 let home_path = get_home_dir fs in
166166+ (* First validate all standard XDG environment variables *)
167167+ validate_standard_xdg_vars ();
88168 let xdg_ctx = Xdg.create ~env:Sys.getenv_opt () in
89169 (* User directories *)
90170 let config_dir, config_dir_source =
···126206 ensure_dir data_dir;
127207 ensure_dir cache_dir;
128208 ensure_dir state_dir;
129129- Option.iter ensure_dir runtime_dir;
209209+ Option.iter (ensure_runtime_dir fs) runtime_dir;
130210 { app_name
131211 ; config_dir
132212 ; config_dir_source
···141221 ; config_dirs
142222 ; data_dirs
143223 }
144144-;;
145224146225let app_name t = t.app_name
147226let config_dir t = t.config_dir
···152231let config_dirs t = t.config_dirs
153232let data_dirs t = t.data_dirs
154233234234+(* File search following XDG specification *)
235235+let find_file_in_dirs dirs filename =
236236+ let rec search_dirs = function
237237+ | [] -> None
238238+ | dir :: remaining_dirs ->
239239+ let file_path = Eio.Path.(dir / filename) in
240240+ (try
241241+ (* Try to check if file exists and is readable *)
242242+ let _ = Eio.Path.stat ~follow:true file_path in
243243+ Some file_path
244244+ with
245245+ | _ ->
246246+ (* File is inaccessible (non-existent, permissions, etc.)
247247+ Skip and continue with next directory per XDG spec *)
248248+ search_dirs remaining_dirs)
249249+ in
250250+ search_dirs dirs
251251+252252+let find_config_file t filename =
253253+ (* Search user config dir first, then system config dirs *)
254254+ find_file_in_dirs (t.config_dir :: t.config_dirs) filename
255255+256256+let find_data_file t filename =
257257+ (* Search user data dir first, then system data dirs *)
258258+ find_file_in_dirs (t.data_dir :: t.data_dirs) filename
155259156260let pp ?(brief = false) ?(sources = false) ppf t =
157261 let pp_source ppf = function
···257361 "data_dirs:"
258362 pp_paths
259363 t.data_dirs)
260260-;;
261364262365module Cmd = struct
263366 type xdg_t = t
···399502 ensure_dir data_dir;
400503 ensure_dir cache_dir;
401504 ensure_dir state_dir;
402402- Option.iter ensure_dir runtime_dir;
505505+ Option.iter (ensure_runtime_dir fs) runtime_dir;
403506 { app_name
404507 ; config_dir
405508 ; config_dir_source
···419522 $ cache_dir
420523 $ state_dir
421524 $ runtime_dir)
422422- ;;
423423-424525425526 let env_docs app_name =
426527 let app_upper = String.uppercase_ascii app_name in
···473574 app_name
474575 app_name
475576 app_name
476476- ;;
477577478578 let pp ppf config =
479579 let pp_source ppf = function
···520620 config.state_dir
521621 (pp_with_source "runtime_dir")
522622 config.runtime_dir
523523- ;;
524524-end
623623+end
+66-7
xdg-eio/lib/xdge.mli
···4242 Eio filesystem. *)
4343type t
44444545+(** {1 Exceptions} *)
4646+4747+(** Exception raised when XDG environment variables contain invalid paths.
4848+4949+ The XDG specification requires all paths in environment variables to be
5050+ absolute. This exception is raised when a relative path is found. *)
5151+exception Invalid_xdg_path of string
5252+4553(** {1 Construction} *)
46544755(** [create fs app_name] creates an XDG context for the given application.
···62706371 {b Example:}
6472 {[
6565- let xdg = Xdg_eio.create env#fs "myapp" in
6666- let config = Xdg_eio.config_dir xdg in
7373+ let xdg = Xdge.create env#fs "myapp" in
7474+ let config = Xdge.config_dir xdg in
6775 (* config is now <fs:$HOME/.config/myapp> or the overridden path *)
6876 ]}
69777078 {b Note:} All directories are created with permissions 0o755 if they don't exist,
7171- except for runtime directories which follow stricter requirements. *)
7979+ except for runtime directories which are created with 0o700 permissions and
8080+ validated according to the XDG specification.
8181+8282+ @raise Invalid_xdg_path if any environment variable contains a relative path *)
7283val create : Eio.Fs.dir_ty Eio.Path.t -> string -> t
73847485(** {1 Accessors} *)
···250261 @see <https://specifications.freedesktop.org/basedir-spec/latest/#variables> XDG_DATA_DIRS specification *)
251262val data_dirs : t -> Eio.Fs.dir_ty Eio.Path.t list
252263264264+(** {1 File Search} *)
265265+266266+(** [find_config_file t filename] searches for a configuration file following XDG precedence.
267267+268268+ This function searches for the given filename in the user configuration directory
269269+ first, then in system configuration directories in order of preference.
270270+ Files that are inaccessible (due to permissions, non-existence, etc.) are
271271+ silently skipped as per the XDG specification.
272272+273273+ @param t The XDG context
274274+ @param filename The name of the file to search for
275275+ @return [Some path] if found, [None] if not found in any directory
276276+277277+ {b Search Order:}
278278+ 1. User config directory ({!config_dir})
279279+ 2. System config directories ({!config_dirs}) in preference order
280280+281281+ {b Example:}
282282+ {[
283283+ match Xdge.find_config_file xdg "myapp.conf" with
284284+ | Some path -> Printf.printf "Found config at: %s\n" (Eio.Path.native_exn path)
285285+ | None -> Printf.printf "No config file found\n"
286286+ ]} *)
287287+val find_config_file : t -> string -> Eio.Fs.dir_ty Eio.Path.t option
288288+289289+(** [find_data_file t filename] searches for a data file following XDG precedence.
290290+291291+ This function searches for the given filename in the user data directory
292292+ first, then in system data directories in order of preference.
293293+ Files that are inaccessible (due to permissions, non-existence, etc.) are
294294+ silently skipped as per the XDG specification.
295295+296296+ @param t The XDG context
297297+ @param filename The name of the file to search for
298298+ @return [Some path] if found, [None] if not found in any directory
299299+300300+ {b Search Order:}
301301+ 1. User data directory ({!data_dir})
302302+ 2. System data directories ({!data_dirs}) in preference order
303303+304304+ {b Example:}
305305+ {[
306306+ match Xdge.find_data_file xdg "templates/default.txt" with
307307+ | Some path -> (* read from path *)
308308+ | None -> (* use built-in default *)
309309+ ]} *)
310310+val find_data_file : t -> string -> Eio.Fs.dir_ty Eio.Path.t option
311311+253312(** {1 Pretty Printing} *)
254313255314(** [pp ?brief ?sources ppf t] pretty prints the XDG directory configuration.
···269328 {b Example:}
270329 {[
271330 (* Normal output *)
272272- Format.printf "%a" Xdg_eio.pp xdg
331331+ Format.printf "%a" Xdge.pp xdg
273332274333 (* Brief output *)
275275- Format.printf "%a" (Xdg_eio.pp ~brief:true) xdg
334334+ Format.printf "%a" (Xdge.pp ~brief:true) xdg
276335277336 (* Show sources *)
278278- Format.printf "%a" (Xdg_eio.pp ~sources:true) xdg
337337+ Format.printf "%a" (Xdge.pp ~sources:true) xdg
279338 ]} *)
280339val pp : ?brief:bool -> ?sources:bool -> Format.formatter -> t -> unit
281340···328387 {b Example:}
329388 {[
330389 let open Cmdliner in
331331- let main xdg =
390390+ let main (xdg, _config) =
332391 (* use xdg directly *)
333392 in
334393 let xdg_term = Cmd.term "myapp" env#fs in
+114-34
xdg-eio/test/test_paths.ml
···11-let () =
11+let test_path_validation () =
22+ Printf.printf "Testing XDG path validation...\n";
33+44+ (* Test absolute path validation for environment variables *)
55+ let test_relative_path_rejection env_var relative_path =
66+ Printf.printf "Testing rejection of relative path in %s...\n" env_var;
77+ Unix.putenv env_var relative_path;
88+ try
99+ Eio_main.run @@ fun env ->
1010+ let _ = Xdge.create env#fs "test_validation" in
1111+ Printf.printf "ERROR: Should have rejected relative path\n";
1212+ false
1313+ with
1414+ | Xdge.Invalid_xdg_path msg ->
1515+ Printf.printf "SUCCESS: Correctly rejected relative path: %s\n" msg;
1616+ true
1717+ | exn ->
1818+ Printf.printf "ERROR: Wrong exception: %s\n" (Printexc.to_string exn);
1919+ false
2020+ in
2121+2222+ let old_config_home = Sys.getenv_opt "XDG_CONFIG_HOME" in
2323+ let old_data_dirs = Sys.getenv_opt "XDG_DATA_DIRS" in
2424+2525+ let success1 = test_relative_path_rejection "XDG_CONFIG_HOME" "relative/path" in
2626+ let success2 = test_relative_path_rejection "XDG_DATA_DIRS" "rel1:rel2:/abs/path" in
2727+2828+ (* Restore original env vars *)
2929+ (match old_config_home with
3030+ | Some v -> Unix.putenv "XDG_CONFIG_HOME" v
3131+ | None -> (try Unix.putenv "XDG_CONFIG_HOME" ""; with _ -> ()));
3232+ (match old_data_dirs with
3333+ | Some v -> Unix.putenv "XDG_DATA_DIRS" v
3434+ | None -> (try Unix.putenv "XDG_DATA_DIRS" ""; with _ -> ()));
3535+3636+ success1 && success2
3737+3838+let test_file_search () =
3939+ Printf.printf "\nTesting XDG file search...\n";
4040+241 Eio_main.run @@ fun env ->
33- let xdg = Xdge.create env#fs "path_test" in
4242+ let xdg = Xdge.create env#fs "search_test" in
44355- (* Test config subdirectory *)
66- let profiles_path = Eio.Path.(Xdge.config_dir xdg / "profiles") in
77- let profile_file = Eio.Path.(profiles_path / "default.json") in
88- (try
99- let content = Eio.Path.load profile_file in
1010- Printf.printf "config file content: %s" (String.trim content)
1111- with
1212- | exn -> Printf.printf "config file error: %s" (Printexc.to_string exn));
4444+ (* Create test files *)
4545+ let config_file = Eio.Path.(Xdge.config_dir xdg / "test.conf") in
4646+ let data_file = Eio.Path.(Xdge.data_dir xdg / "test.dat") in
13471414- (* Test data subdirectory *)
1515- let db_path = Eio.Path.(Xdge.data_dir xdg / "databases") in
1616- let db_file = Eio.Path.(db_path / "main.db") in
1717- (try
1818- let content = Eio.Path.load db_file in
1919- Printf.printf "\ndata file content: %s" (String.trim content)
2020- with
2121- | exn -> Printf.printf "\ndata file error: %s" (Printexc.to_string exn));
4848+ Eio.Path.save ~create:(`Or_truncate 0o644) config_file "config content";
4949+ Eio.Path.save ~create:(`Or_truncate 0o644) data_file "data content";
22502323- (* Test cache subdirectory *)
2424- let cache_path = Eio.Path.(Xdge.cache_dir xdg / "thumbnails") in
2525- let cache_file = Eio.Path.(cache_path / "thumb1.png") in
2626- (try
2727- let content = Eio.Path.load cache_file in
2828- Printf.printf "\ncache file content: %s" (String.trim content)
2929- with
3030- | exn -> Printf.printf "\ncache file error: %s" (Printexc.to_string exn));
5151+ (* Test finding existing files *)
5252+ (match Xdge.find_config_file xdg "test.conf" with
5353+ | Some path ->
5454+ let content = Eio.Path.load path in
5555+ Printf.printf "Found config file: %s\n" (String.trim content)
5656+ | None -> Printf.printf "ERROR: Config file not found\n");
31573232- (* Test state subdirectory *)
3333- let logs_path = Eio.Path.(Xdge.state_dir xdg / "logs") in
3434- let log_file = Eio.Path.(logs_path / "app.log") in
3535- (try
3636- let content = Eio.Path.load log_file in
3737- Printf.printf "\nstate file content: %s\n" (String.trim content)
3838- with
3939- | exn -> Printf.printf "\nstate file error: %s\n" (Printexc.to_string exn))5858+ (match Xdge.find_data_file xdg "test.dat" with
5959+ | Some path ->
6060+ let content = Eio.Path.load path in
6161+ Printf.printf "Found data file: %s\n" (String.trim content)
6262+ | None -> Printf.printf "ERROR: Data file not found\n");
6363+6464+ (* Test non-existent file *)
6565+ (match Xdge.find_config_file xdg "nonexistent.conf" with
6666+ | Some _ -> Printf.printf "ERROR: Should not have found nonexistent file\n"
6767+ | None -> Printf.printf "Correctly handled nonexistent file\n")
6868+6969+let () =
7070+ (* Check if we should run validation tests *)
7171+ if Array.length Sys.argv > 1 && Sys.argv.(1) = "--validate" then (
7272+ let validation_success = test_path_validation () in
7373+ test_file_search ();
7474+7575+ if validation_success then
7676+ Printf.printf "\nAll path validation tests passed!\n"
7777+ else
7878+ Printf.printf "\nSome validation tests failed!\n"
7979+ ) else (
8080+ (* Run original simple functionality test *)
8181+ Eio_main.run @@ fun env ->
8282+ let xdg = Xdge.create env#fs "path_test" in
8383+8484+ (* Test config subdirectory *)
8585+ let profiles_path = Eio.Path.(Xdge.config_dir xdg / "profiles") in
8686+ let profile_file = Eio.Path.(profiles_path / "default.json") in
8787+ (try
8888+ let content = Eio.Path.load profile_file in
8989+ Printf.printf "config file content: %s" (String.trim content)
9090+ with
9191+ | exn -> Printf.printf "config file error: %s" (Printexc.to_string exn));
9292+9393+ (* Test data subdirectory *)
9494+ let db_path = Eio.Path.(Xdge.data_dir xdg / "databases") in
9595+ let db_file = Eio.Path.(db_path / "main.db") in
9696+ (try
9797+ let content = Eio.Path.load db_file in
9898+ Printf.printf "\ndata file content: %s" (String.trim content)
9999+ with
100100+ | exn -> Printf.printf "\ndata file error: %s" (Printexc.to_string exn));
101101+102102+ (* Test cache subdirectory *)
103103+ let cache_path = Eio.Path.(Xdge.cache_dir xdg / "thumbnails") in
104104+ let cache_file = Eio.Path.(cache_path / "thumb1.png") in
105105+ (try
106106+ let content = Eio.Path.load cache_file in
107107+ Printf.printf "\ncache file content: %s" (String.trim content)
108108+ with
109109+ | exn -> Printf.printf "\ncache file error: %s" (Printexc.to_string exn));
110110+111111+ (* Test state subdirectory *)
112112+ let logs_path = Eio.Path.(Xdge.state_dir xdg / "logs") in
113113+ let log_file = Eio.Path.(logs_path / "app.log") in
114114+ (try
115115+ let content = Eio.Path.load log_file in
116116+ Printf.printf "\nstate file content: %s\n" (String.trim content)
117117+ with
118118+ | exn -> Printf.printf "\nstate file error: %s\n" (Printexc.to_string exn))
119119+ )