@@ -170,9 +170,12 @@ import_schema_test() ->
170170 Types ).
171171
172172import_nested_idl_test () ->
173- % % subdir/submodule.avdl imports ../foo.avdl (relative to its own dir).
174- % % Verifies that import paths are resolved relative to the importing file,
175- % % not relative to the top-level caller's cwd.
173+ % % subdir/submodule.avdl imports ../foo.avdl. The top-level caller's
174+ % % cwd is "test/data" which is also the default rootdir, so the ".."
175+ % % from subdir/ stays inside the rooted tree and the import resolves
176+ % % to test/data/foo.avdl. Also verifies that import paths are resolved
177+ % % relative to the importing file, not the top-level cwd (foo.avdl is
178+ % % not at "test/data/subdir/../foo.avdl" unless we recurse correctly).
176179 Proto = avro_idl :str_to_avpr (
177180 " protocol P { import idl \" subdir/submodule.avdl\" ; }" ,
178181 " test/data" ),
@@ -183,6 +186,36 @@ import_nested_idl_test() ->
183186 #{<<" name" >> := <<" SubRecord" >>, <<" type" >> := ? AVRO_RECORD }],
184187 Types ).
185188
189+ import_with_rootdir_allows_dotdot_within_tree_test () ->
190+ % % Loading subdir/submodule.avdl directly: its cwd becomes subdir/, so
191+ % % "../foo.avdl" would escape if rootdir defaulted to that. Passing the
192+ % % wider rootdir explicitly lets the import resolve while keeping the
193+ % % confinement boundary.
194+ SubmoduleFile = test_data (" subdir/submodule.avdl" ),
195+ {ok , Bin } = file :read_file (SubmoduleFile ),
196+ RootDir = test_data (" " ),
197+ Proto = avro_idl :str_to_avpr (
198+ binary_to_list (Bin ),
199+ filename :dirname (SubmoduleFile ),
200+ [{rootdir , RootDir }]),
201+ #{<<" types" >> := Types } = Proto ,
202+ ? assertMatch (
203+ [#{<<" name" >> := <<" FooRecord" >>, <<" type" >> := ? AVRO_RECORD },
204+ #{<<" name" >> := <<" FooEnum" >>, <<" type" >> := ? AVRO_ENUM },
205+ #{<<" name" >> := <<" SubRecord" >>, <<" type" >> := ? AVRO_RECORD }],
206+ Types ).
207+
208+ import_with_rootdir_blocks_escape_test () ->
209+ % % rootdir set to subdir/ — ../foo.avdl escapes the rooted tree and
210+ % % must be refused even though the file exists.
211+ SubmoduleFile = test_data (" subdir/submodule.avdl" ),
212+ {ok , Bin } = file :read_file (SubmoduleFile ),
213+ Cwd = filename :dirname (SubmoduleFile ),
214+ ? assertError (
215+ {badmatch , {error , {import_outside_root , " ../foo.avdl" }}},
216+ avro_idl :str_to_avpr (
217+ binary_to_list (Bin ), Cwd , [{rootdir , Cwd }])).
218+
186219import_with_read_fun_test () ->
187220 % % All schemas kept in memory; no filesystem access.
188221 % % dep.avdl transitively imports transitive.avdl, verifying read_fun
@@ -212,6 +245,88 @@ import_with_read_fun_test() ->
212245 #{<<" name" >> := <<" DepRecord" >>, <<" type" >> := ? AVRO_RECORD }],
213246 Types ).
214247
248+ import_outside_root_rejected_test_ () ->
249+ % % The default read_fun resolves imports relative to the importing
250+ % % file's directory and refuses paths that point outside it, both
251+ % % absolute paths and relative paths that escape through "..".
252+ AttackPaths = [" /etc/passwd" , " ../../etc/passwd" ],
253+ Variants = [" idl" , " protocol" , " schema" ],
254+ [? _assertError (
255+ {badmatch , {error , {import_outside_root , _ }}},
256+ avro_idl :str_to_avpr (
257+ " protocol P { import " ++ Kind ++ " \" " ++ AP ++ " \" ; }" ,
258+ " test/data" ))
259+ || Kind <- Variants , AP <- AttackPaths ].
260+
261+ import_absolute_path_under_absolute_root_test () ->
262+ % % An absolute import path is accepted when rootdir is also absolute
263+ % % and the path lies under it.
264+ AbsRoot = test_data (" " ),
265+ AbsFoo = test_data (" foo.avdl" ),
266+ Proto = avro_idl :str_to_avpr (
267+ " protocol P { import idl \" " ++ AbsFoo ++ " \" ; }" ,
268+ AbsRoot ,
269+ [{rootdir , AbsRoot }]),
270+ #{<<" types" >> := Types } = Proto ,
271+ ? assertMatch (
272+ [#{<<" name" >> := <<" FooRecord" >>, <<" type" >> := ? AVRO_RECORD },
273+ #{<<" name" >> := <<" FooEnum" >>, <<" type" >> := ? AVRO_ENUM }],
274+ Types ).
275+
276+ import_absolute_path_outside_absolute_root_test () ->
277+ % % Absolute path with absolute rootdir is rejected when the path is
278+ % % not under the root. Exercises the prefix check in the
279+ % % absolute-vs-absolute case (the existing /etc/passwd test uses a
280+ % % relative root, so the abs-vs-rel mismatch alone makes it fail).
281+ AbsRoot = test_data (" subdir" ),
282+ AbsFoo = test_data (" foo.avdl" ),
283+ ? assertError (
284+ {badmatch , {error , {import_outside_root , _ }}},
285+ avro_idl :str_to_avpr (
286+ " protocol P { import idl \" " ++ AbsFoo ++ " \" ; }" ,
287+ AbsRoot ,
288+ [{rootdir , AbsRoot }])).
289+
290+ import_rootdir_with_dotdot_segments_test () ->
291+ % % rootdir with embedded `..' segments is normalized once at
292+ % % setup, so the prefix check still works against canonical paths.
293+ DenormRoot = test_data (" subdir/.." ),
294+ Proto = avro_idl :str_to_avpr (
295+ " protocol P { import idl \" subdir/submodule.avdl\" ; }" ,
296+ DenormRoot ,
297+ [{rootdir , DenormRoot }]),
298+ #{<<" types" >> := Types } = Proto ,
299+ ? assertMatch (
300+ [#{<<" name" >> := <<" FooRecord" >>, <<" type" >> := ? AVRO_RECORD },
301+ #{<<" name" >> := <<" FooEnum" >>, <<" type" >> := ? AVRO_ENUM },
302+ #{<<" name" >> := <<" SubRecord" >>, <<" type" >> := ? AVRO_RECORD }],
303+ Types ).
304+
305+ import_dot_segments_collapsed_test () ->
306+ % % `.' segments in the import path are collapsed by normalize/1.
307+ Proto = avro_idl :str_to_avpr (
308+ " protocol P { import idl \" ./foo.avdl\" ; }" ,
309+ " test/data" ),
310+ #{<<" types" >> := Types } = Proto ,
311+ ? assertMatch (
312+ [#{<<" name" >> := <<" FooRecord" >>, <<" type" >> := ? AVRO_RECORD },
313+ #{<<" name" >> := <<" FooEnum" >>, <<" type" >> := ? AVRO_ENUM }],
314+ Types ).
315+
316+ import_outside_root_with_read_fun_override_test () ->
317+ % % The strict default can be bypassed by supplying a custom read_fun;
318+ % % verify the option still takes effect and the default is not applied
319+ % % on top of it.
320+ ReadFun = fun (_Cwd , _Path ) ->
321+ {ok , <<" protocol Bypassed { record R { int n; } }" >>}
322+ end ,
323+ Proto = avro_idl :str_to_avpr (
324+ " protocol P { import idl \" /anywhere/on/disk.avdl\" ; }" ,
325+ " test/data" ,
326+ [{read_fun , ReadFun }]),
327+ #{<<" types" >> := Types } = Proto ,
328+ ? assertMatch ([#{<<" name" >> := <<" R" >>}], Types ).
329+
215330duplicate_annotation_avpr_test () ->
216331 ? assertError (
217332 {duplicate_annotation , " my_decorator" , _ , _ },
@@ -241,10 +356,12 @@ nested_complex_types_avr_test() ->
241356 ).
242357
243358encode_decode_test () ->
244- % % subdir/submodule.avdl imports ../foo.avdl (idl); verify types from both
245- % % files can be encoded/decoded after loading via schema store
359+ % % subdir/submodule.avdl imports ../foo.avdl (idl); verify types from
360+ % % both files can be encoded/decoded after loading via schema store.
361+ % % rootdir is the test/data tree so the "../" import resolves inside it.
246362 SubmoduleFile = test_data (" subdir/submodule.avdl" ),
247- Store1 = avro_schema_store :new ([], [SubmoduleFile ]),
363+ Store1 = avro_schema_store :new (
364+ [], [SubmoduleFile ], [{rootdir , test_data (" " )}]),
248365 LookupFun1 = avro_schema_store :to_lookup_fun (Store1 ),
249366 Encoder1 = avro :make_encoder (LookupFun1 , []),
250367 Decoder1 = avro :make_decoder (LookupFun1 , []),
0 commit comments