Skip to content

Commit 1dea159

Browse files
dlreevesmeta-codesync[bot]
authored andcommitted
Require an implicit package member directory to be a valid identifier
Summary: # Context Each direct child directory `D` of an implicit package family's `path` denotes a synthesized member package named `F.D`. Any directory name was accepted, which means a name that cannot work as a package name still produced one. Dotted names are the case that actually breaks. A directory `proto.v1` yields the member name `prototypes.proto.v1`, and resolving a name back to a family and member splits on `.`, so the name does not round-trip cleanly. # Solution Require a member directory name to be a valid Hack identifier. A directory that is not one denotes no member package, and a file inside it is reported with a new `Naming[2136]`. The predicate comes from the `hack_name` crate added earlier in this stack, so the check shares the lexer's definition of a name rather than carrying a copy that can drift. Reviewed By: madgen Differential Revision: D114386952 fbshipit-source-id: d94136b79671d51f2ecf27291a7950f522c1b593
1 parent 3226730 commit 1dea159

17 files changed

Lines changed: 297 additions & 34 deletions

hphp/hack/src/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

hphp/hack/src/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ members = [
9191
"utils/escaper",
9292
"utils/ffi_cbindgen",
9393
"utils/find_utils",
94+
"utils/hack_name/cargo",
9495
"utils/hash",
9596
"utils/hh24_types",
9697
"utils/html_entities",

hphp/hack/src/diagnostics/error_codes.ml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ module Naming = struct
174174
| NamedVariadicTypeDisallowed [@value 2133]
175175
| VariadicNamedParameterDisallowed [@value 2134]
176176
| ImplicitPackageFileDirectlyUnderPath [@value 2135]
177+
| ImplicitPackageInvalidMemberDir [@value 2136]
177178
(* Add new Naming codes here! Comment out when deprecating. *)
178179
[@@deriving enum, show { with_path = false }]
179180

hphp/hack/src/oxidized/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ bstr = { version = "1.13.0", features = ["serde", "std", "unicode"] }
2020
bumpalo = { version = "3.20.3", features = ["allocator_api", "collections"] }
2121
eq_modulo_pos = { path = "../utils/eq_modulo_pos" }
2222
file_info = { path = "../deps/rust/file_info" }
23+
hack_name = { path = "../utils/hack_name/cargo" }
2324
hash = { path = "../utils/hash" }
2425
hh24_types = { path = "../utils/hh24_types" }
2526
hh_autoimport_rust = { path = "../parser/cargo/hh_autoimport" }

hphp/hack/src/oxidized/gen/error_codes.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// This source code is licensed under the MIT license found in the
44
// LICENSE file in the "hack" directory of this source tree.
55
//
6-
// @generated SignedSource<<aa25f2a5e885275c1761709806ae1743>>
6+
// @generated SignedSource<<e4f2726af84f2f8b5e71924505052188>>
77
//
88
// To regenerate this file, run:
99
// buck run @fbcode//mode/dev-nosan-lg fbcode//hphp/hack/src:oxidized_regen
@@ -148,6 +148,7 @@ pub enum Naming {
148148
NamedVariadicTypeDisallowed = 2133,
149149
VariadicNamedParameterDisallowed = 2134,
150150
ImplicitPackageFileDirectlyUnderPath = 2135,
151+
ImplicitPackageInvalidMemberDir = 2136,
151152
}
152153
impl TrivialDrop for Naming {}
153154
arena_deserializer::impl_deserialize_in_arena!(Naming);

hphp/hack/src/oxidized/manual/diagnostics_impl.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,38 @@ impl Naming {
174174
)
175175
}
176176

177+
/// A file under an implicit package family sits in a directory whose name
178+
/// is not a valid Hack identifier, so that directory can name no member
179+
/// package.
180+
///
181+
/// `family_pos` points at the family's declaration in PACKAGES.toml.
182+
pub fn implicit_package_invalid_member_dir(
183+
pos: Pos,
184+
family: &str,
185+
family_pos: Pos,
186+
member_dir: &str,
187+
) -> Diagnostic {
188+
UserDiagnostic::new(
189+
Severity::Err,
190+
Self::ImplicitPackageInvalidMemberDir as isize,
191+
Message(
192+
pos,
193+
format!(
194+
"The directory `{}` cannot name a member of the implicit package family `{}`; an implicit package member directory must be a valid identifier",
195+
member_dir, family
196+
)
197+
.into(),
198+
),
199+
vec![Message(
200+
family_pos,
201+
format!("The implicit package family `{}` is declared here", family).into(),
202+
)],
203+
Explanation::Empty,
204+
vec![],
205+
vec![],
206+
)
207+
}
208+
177209
pub fn bad_builtin_type(p: Pos, name: &str, correct_name: &str) -> Diagnostic {
178210
UserDiagnostic::new(
179211
Severity::Err,

hphp/hack/src/oxidized/manual/package_info_impl.rs

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,19 @@ static MULTIFILE_PREFIX: LazyLock<regex::Regex> = LazyLock::new(|| {
168168
regex::Regex::new(r"^.*--").expect("should compile: the pattern is a literal")
169169
});
170170

171+
/// Where a file sits relative to the implicit package families. The illegal
172+
/// placements carry the family's positioned name, so the caller can point at its
173+
/// declaration in PACKAGES.toml.
174+
pub enum ImplicitFamilyPlacement<'a> {
175+
/// Belongs to a member package, or is not under any family at all.
176+
Valid,
177+
/// Lies directly under a family `path` with no member directory in between.
178+
DirectlyUnderFamily(&'a PosId),
179+
/// Lies under a directory whose name is not a valid Hack identifier, so that
180+
/// directory names no member package. Carries the offending name.
181+
InvalidMemberDir(&'a PosId, String),
182+
}
183+
171184
/// The path rewriting every package lookup applies before prefix-matching. All
172185
/// lookups must go through this, so they prefix-match the same string.
173186
fn normalize(support_multifile_tests: bool, path: &str) -> Cow<'_, str> {
@@ -209,25 +222,32 @@ impl PackageInfo {
209222
}
210223
}
211224

212-
/// The implicit family (its positioned name) if `path` lies directly under
213-
/// that family's `path`, with no member directory in between -- e.g.
214-
/// `//prototypes/loose.php` under a family declared at `//prototypes/`.
225+
/// Where `path` sits relative to the implicit package families.
215226
///
216-
/// `None` means the file is legally placed: either it belongs to a member,
217-
/// or it is under no family at all.
218-
pub fn file_directly_under_implicit_family(
227+
/// - `Valid` -- belongs to a member package, or lies under no family at all.
228+
/// - `DirectlyUnderFamily` -- lies directly under a family `path` with no
229+
/// member directory in between, e.g. `prototypes/loose.php` under a family
230+
/// declared at `prototypes/`.
231+
/// - `InvalidMemberDir` -- the member directory is not a valid Hack
232+
/// identifier, so it names no member package.
233+
pub fn check_implicit_family_placement(
219234
&self,
220235
support_multifile_tests: bool,
221236
path: &str,
222-
) -> Option<&PosId> {
237+
) -> ImplicitFamilyPlacement<'_> {
223238
let path = normalize(support_multifile_tests, path);
224-
let (package, remainder) = self.match_include_path(&path)?;
239+
let Some((package, remainder)) = self.match_include_path(&path) else {
240+
return ImplicitFamilyPlacement::Valid;
241+
};
225242
if !package.is_implicit {
226-
return None;
243+
return ImplicitFamilyPlacement::Valid;
227244
}
228245
match remainder.split_once('/') {
229-
Some((dir, _)) if !dir.is_empty() => None,
230-
_ => Some(&package.name),
246+
Some((dir, _)) if hack_name::is_valid_identifier(dir) => ImplicitFamilyPlacement::Valid,
247+
Some((dir, _)) if !dir.is_empty() => {
248+
ImplicitFamilyPlacement::InvalidMemberDir(&package.name, dir.to_owned())
249+
}
250+
_ => ImplicitFamilyPlacement::DirectlyUnderFamily(&package.name),
231251
}
232252
}
233253
}
@@ -292,6 +312,57 @@ mod test {
292312

293313
// A file outside the family path belongs to no package.
294314
assert!(info.get_package_for_file(false, "other/x.php").is_none());
315+
316+
// Resolution does NOT reject a badly-named member directory; it
317+
// synthesizes a member like any other.
318+
for bad in [
319+
"www/prototypes/proto.v1/a.php",
320+
"www/prototypes/1bad/a.php",
321+
"www/prototypes/:xhp/a.php",
322+
] {
323+
assert!(
324+
info.get_package_for_file(false, bad).is_some(),
325+
"{bad} should still resolve; rejecting it is the placement check's job"
326+
);
327+
}
328+
}
329+
330+
// Identifier validity is decided only by the placement classifier.
331+
#[test]
332+
fn placement_rejects_badly_named_member_dirs() {
333+
let family = Package {
334+
name: pos_id("prototypes"),
335+
includes: vec![],
336+
soft_includes: vec![],
337+
include_paths: vec![pos_id("www/prototypes/")],
338+
enable_strict_isolation: true,
339+
is_implicit: true,
340+
};
341+
let info = PackageInfo {
342+
existing_packages: Default::default(),
343+
include_path_to_package_map: vec![("www/prototypes/".to_string(), family)],
344+
};
345+
346+
for (path, expected_dir) in [
347+
("www/prototypes/proto.v1/a.php", "proto.v1"),
348+
("www/prototypes/1bad/a.php", "1bad"),
349+
("www/prototypes/:xhp/a.php", ":xhp"),
350+
] {
351+
match info.check_implicit_family_placement(false, path) {
352+
ImplicitFamilyPlacement::InvalidMemberDir(_, dir) => {
353+
assert_eq!(dir, expected_dir, "wrong directory reported for {path}")
354+
}
355+
_ => panic!("{path} should be reported as a badly-named member directory"),
356+
}
357+
}
358+
359+
// A validly-named directory, and a file outside any family, are fine.
360+
for ok in ["www/prototypes/alpha/a.php", "other/x.php"] {
361+
assert!(matches!(
362+
info.check_implicit_family_placement(false, ok),
363+
ImplicitFamilyPlacement::Valid
364+
));
365+
}
295366
}
296367

297368
// A container path with directories in it must be dropped whole; a

hphp/hack/src/packages/package_info.ml

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,14 @@ let synthesize_member (family : Package.t) (member_dir : string) : Package.t =
5252
Package.is_implicit = true;
5353
}
5454

55-
(* Splits a (possibly synthesized) name [F.D] into family [F] and member [D],
56-
* splitting on the *first* [.]. The member [D] is a single directory name that
57-
* may itself contain [.] (e.g. [proto.v1]), so we keep the remainder after the
58-
* first [.] as the member. Family names are forbidden from containing [.] (see
59-
* the family-name validation in config.rs), so this split is unambiguous.
60-
* Returns None unless both the family and the member are non-empty. *)
55+
(* Splits a (possibly synthesized) name [F.D] into family [F] and member [D].
56+
* In a well-formed repo a member directory is a valid Hack identifier and so
57+
* contains no [.], and family names are forbidden from containing [.] (see the
58+
* family-name validation in config.rs), so [F.D] contains exactly one [.].
59+
* Resolution does not re-verify that: a directory that is not a valid identifier
60+
* is reported by the lowerer's placement check, and this code is only meaningful
61+
* for a repo that type-checks clean. Returns None unless both sides are
62+
* non-empty. *)
6163
let split_member_name (pkg : string) : (string * string) option =
6264
match String.lsplit2 pkg ~on:'.' with
6365
| Some (family, member)

hphp/hack/src/parser/lowerer/lowerer.rs

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6266,25 +6266,30 @@ fn get_current_package<'a>(env: &mut Env<'a>, node: S<'a>) -> Option<PackageMemb
62666266
/// subdirectory; one placed directly under the family path belongs to no member
62676267
/// package, so it is illegal.
62686268
fn check_implicit_package_placement(env: &mut Env<'_>) {
6269+
use oxidized::package_info_impl::ImplicitFamilyPlacement;
6270+
62696271
let parser_options = env.parser_options;
62706272
let file_path = env.source_text().file_path_rc();
6271-
let Some(family) = parser_options
6272-
.package_info
6273-
.file_directly_under_implicit_family(
6274-
parser_options.package_support_multifile_tests,
6275-
file_path.path_str(),
6276-
)
6277-
else {
6278-
return;
6279-
};
6280-
let (family_pos, family_name) = (family.0.clone(), family.1.clone());
6281-
// Zero-width, at the start of the file: nothing the file contains is at
6282-
// fault, and a `.hack` file has no `<?hh` pragma to point at.
6283-
let file_top_pos = Pos::from_lnum_bol_offset(file_path, (1, 0, 0), (1, 0, 0));
6284-
raise_hh_error(
6285-
env,
6286-
Naming::implicit_package_file_directly_under_path(file_top_pos, &family_name, family_pos),
6273+
let placement = parser_options.package_info.check_implicit_family_placement(
6274+
parser_options.package_support_multifile_tests,
6275+
file_path.path_str(),
62876276
);
6277+
// Both errors are anchored at a zero-width position at the start of the
6278+
// file: a `.hack` file has no `<?hh` pragma to point at.
6279+
let error = match placement {
6280+
ImplicitFamilyPlacement::Valid => return,
6281+
ImplicitFamilyPlacement::DirectlyUnderFamily(family) => {
6282+
let (family_pos, family_name) = (family.0.clone(), family.1.clone());
6283+
let pos = Pos::from_lnum_bol_offset(file_path, (1, 0, 0), (1, 0, 0));
6284+
Naming::implicit_package_file_directly_under_path(pos, &family_name, family_pos)
6285+
}
6286+
ImplicitFamilyPlacement::InvalidMemberDir(family, member_dir) => {
6287+
let (family_pos, family_name) = (family.0.clone(), family.1.clone());
6288+
let pos = Pos::from_lnum_bol_offset(file_path, (1, 0, 0), (1, 0, 0));
6289+
Naming::implicit_package_invalid_member_dir(pos, &family_name, family_pos, &member_dir)
6290+
}
6291+
};
6292+
raise_hh_error(env, error);
62886293
}
62896294

62906295
fn p_def<'a>(node: S<'a>, env: &mut Env<'a>) -> Result<Vec<ast::Def>> {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//// prototypes/loose.php
2+
<?hh
3+
// Directly under the family path: reported by check_directly_under_family.
4+
const int LOOSE_C = 1;
5+
6+
//// prototypes/1bad/b.php
7+
<?hh
8+
// Under a directory that cannot name a member: reported by
9+
// check_invalid_member_dir.
10+
const int BAD_C = 1;
11+
12+
//// prototypes/ok/o.php
13+
<?hh
14+
// Correctly placed, so neither check fires for it.
15+
const int OK_C = 1;

0 commit comments

Comments
 (0)