[racerd][3/n] improve domain interface and consolidate access validity checking

Summary:
For historical reasons, the record of an access is a three-level record:
1. `AccessSnapshot`, a record with info such as ownership and lock status, including
2. `TraceElem`, a record with a trace and an element which is
3. Access, the abstract addressed accessed and the type of access.

This stack flips the order to 2, 1, 3, leading up to the possibility of merging 1 and 3.

This diff improves the domain interface and consolidates all the various validity invariant checking for accesses inside their constructors.

Reviewed By: skcho

Differential Revision: D20668611

fbshipit-source-id: 45806d40d
master
Nikos Gorogiannis 5 years ago committed by Facebook GitHub Bot
parent b194d70860
commit f7d6961177

@ -163,16 +163,13 @@ module TransferFunctions (CFG : ProcCfg.S) = struct
in in
if is_box callee_pname then if is_box callee_pname then
match actuals with match actuals with
| HilExp.AccessExpression actual_access_expr :: _ -> | HilExp.AccessExpression actual_access_expr :: _
if AttributeMapDomain.has_attribute actual_access_expr Functional astate.attribute_map when AttributeMapDomain.is_functional astate.attribute_map actual_access_expr ->
then (* TODO: check for constants, which are functional? *)
(* TODO: check for constants, which are functional? *) let attribute_map =
let attribute_map = AttributeMapDomain.add (AccessExpression.base ret_base) Functional astate.attribute_map
AttributeMapDomain.add (AccessExpression.base ret_base) Functional in
astate.attribute_map {astate with attribute_map}
in
{astate with attribute_map}
else astate
| _ -> | _ ->
astate astate
else if should_assume_returns_ownership callee_pname call_flags actuals then else if should_assume_returns_ownership callee_pname call_flags actuals then
@ -264,11 +261,11 @@ module TransferFunctions (CFG : ProcCfg.S) = struct
match get_lock_effect callee_pname actuals with match get_lock_effect callee_pname actuals with
| Lock _ | GuardLock _ | GuardConstruct {acquire_now= true} -> | Lock _ | GuardLock _ | GuardConstruct {acquire_now= true} ->
{ astate with { astate with
locks= LocksDomain.acquire_lock astate.locks locks= LockDomain.acquire_lock astate.locks
; threads= update_for_lock_use astate.threads } ; threads= update_for_lock_use astate.threads }
| Unlock _ | GuardDestroy _ | GuardUnlock _ -> | Unlock _ | GuardDestroy _ | GuardUnlock _ ->
{ astate with { astate with
locks= LocksDomain.release_lock astate.locks locks= LockDomain.release_lock astate.locks
; threads= update_for_lock_use astate.threads } ; threads= update_for_lock_use astate.threads }
| LockedIfTrue _ | GuardLockedIfTrue _ -> | LockedIfTrue _ | GuardLockedIfTrue _ ->
let attribute_map = let attribute_map =
@ -290,7 +287,7 @@ module TransferFunctions (CFG : ProcCfg.S) = struct
match rebased_summary_opt with match rebased_summary_opt with
| Some {threads; locks; accesses; return_ownership; return_attribute} -> | Some {threads; locks; accesses; return_ownership; return_attribute} ->
let locks = let locks =
LocksDomain.integrate_summary ~caller_astate:astate.locks ~callee_astate:locks LockDomain.integrate_summary ~caller_astate:astate.locks ~callee_astate:locks
in in
let accesses = let accesses =
add_callee_accesses extras astate accesses locks threads actuals callee_pname loc add_callee_accesses extras astate accesses locks threads actuals callee_pname loc
@ -337,8 +334,7 @@ module TransferFunctions (CFG : ProcCfg.S) = struct
let rhs_access_exprs = HilExp.get_access_exprs rhs_exp in let rhs_access_exprs = HilExp.get_access_exprs rhs_exp in
let is_functional = let is_functional =
(not (List.is_empty rhs_access_exprs)) (not (List.is_empty rhs_access_exprs))
&& List.for_all rhs_access_exprs ~f:(fun access_exp -> && List.for_all rhs_access_exprs ~f:(AttributeMapDomain.is_functional astate.attribute_map)
AttributeMapDomain.has_attribute access_exp Functional astate.attribute_map )
&& &&
match AccessExpression.get_typ lhs_access_exp tenv with match AccessExpression.get_typ lhs_access_exp tenv with
| Some {Typ.desc= Typ.Tint ILong | Tfloat FDouble} -> | Some {Typ.desc= Typ.Tint ILong | Tfloat FDouble} ->
@ -370,8 +366,8 @@ module TransferFunctions (CFG : ProcCfg.S) = struct
let apply_choice bool_value (acc : Domain.t) = function let apply_choice bool_value (acc : Domain.t) = function
| Attribute.LockHeld -> | Attribute.LockHeld ->
let locks = let locks =
if bool_value then LocksDomain.acquire_lock acc.locks if bool_value then LockDomain.acquire_lock acc.locks
else LocksDomain.release_lock acc.locks else LockDomain.release_lock acc.locks
in in
{acc with locks} {acc with locks}
| Attribute.OnMainThread -> | Attribute.OnMainThread ->
@ -444,8 +440,8 @@ let analyze_procedure {Callbacks.exe_env; summary} =
let proc_data = ProcData.make summary tenv (FormalMap.make proc_desc) in let proc_data = ProcData.make summary tenv (FormalMap.make proc_desc) in
let initial = let initial =
let locks = let locks =
if Procdesc.is_java_synchronized proc_desc then LocksDomain.(acquire_lock bottom) if Procdesc.is_java_synchronized proc_desc then LockDomain.(acquire_lock bottom)
else LocksDomain.bottom else LockDomain.bottom
in in
let threads = let threads =
if if
@ -521,7 +517,7 @@ let analyze_procedure {Callbacks.exe_env; summary} =
let return_attribute = AttributeMapDomain.find return_var_exp attribute_map in let return_attribute = AttributeMapDomain.find return_var_exp attribute_map in
let locks = let locks =
(* if method is [synchronized] released the lock once. *) (* if method is [synchronized] released the lock once. *)
if Procdesc.is_java_synchronized proc_desc then LocksDomain.release_lock locks else locks if Procdesc.is_java_synchronized proc_desc then LockDomain.release_lock locks else locks
in in
let post = {threads; locks; accesses; return_ownership; return_attribute} in let post = {threads; locks; accesses; return_ownership; return_attribute} in
Payload.update_summary post summary Payload.update_summary post summary
@ -746,20 +742,6 @@ let empty_reported =
{reported_sites; reported_reads; reported_writes; reported_unannotated_calls} {reported_sites; reported_reads; reported_writes; reported_unannotated_calls}
(* decide if we should throw away an access before doing safety analysis
for now, just check for whether the access is within a switch-map
that is auto-generated by Java. *)
let should_filter_access exp_opt =
let check_access = function
| HilExp.Access.FieldAccess fld ->
String.is_substring ~substring:"$SwitchMap" (Fieldname.to_string fld)
| _ ->
false
in
Option.exists exp_opt ~f:(fun exp ->
AccessExpression.to_accesses exp |> snd |> List.exists ~f:check_access )
(** Map containing reported accesses, which groups them in lists, by abstract location. The (** Map containing reported accesses, which groups them in lists, by abstract location. The
equivalence relation used for grouping them is equality of access paths. This is slightly equivalence relation used for grouping them is equality of access paths. This is slightly
complicated because local variables contain the pname of the function declaring them. Here we complicated because local variables contain the pname of the function declaring them. Here we
@ -806,10 +788,8 @@ end = struct
let add (rep : reported_access) map = let add (rep : reported_access) map =
let access = rep.snapshot.elem.access in let access = rep.snapshot.elem.access in
if RacerDDomain.Access.get_access_exp access |> should_filter_access then map let k = Key.of_access access in
else M.update k (function None -> Some [rep] | Some reps -> Some (rep :: reps)) map
let k = Key.of_access access in
M.update k (function None -> Some [rep] | Some reps -> Some (rep :: reps)) map
let fold f map a = let fold f map a =

@ -10,17 +10,6 @@ module AccessExpression = HilExp.AccessExpression
module F = Format module F = Format
module MF = MarkupFormatter module MF = MarkupFormatter
(** Master function for deciding whether RacerD should completely ignore a variable as the root of
an access expression. Currently fires on *static locals* and any variable which does not appear
in source code (eg, temporary variables and frontend introduced variables). This is because
currently reports on these variables would not be easily actionable.
This is here and not in RacerDModels to avoid dependency cycles. *)
let should_skip_var v =
(not (Var.appears_in_source_code v))
|| match v with Var.ProgramVar pvar -> Pvar.is_static_local pvar | _ -> false
let pp_exp fmt exp = let pp_exp fmt exp =
match !Language.curr_language with match !Language.curr_language with
| Clang -> | Clang ->
@ -68,16 +57,21 @@ module Access = struct
let should_keep formals access = let should_keep formals access =
match get_access_exp access with let rec check_access (exp : AccessExpression.t) =
| None -> match exp with
true | FieldOffset (prefix, fld) ->
| Some acc_exp -> ( (not (String.is_substring ~substring:"$SwitchMap" (Fieldname.get_field_name fld)))
let ((root, _) as base) = AccessExpression.get_base acc_exp in && check_access prefix
match root with | ArrayOffset (prefix, _, _) | AddressOf prefix | Dereference prefix ->
| Var.LogicalVar _ -> check_access prefix
false | Base (LogicalVar _, _) ->
| Var.ProgramVar pvar -> false
Pvar.is_global pvar || FormalMap.is_formal base formals ) | Base (((ProgramVar pvar as var), _) as base) ->
Var.appears_in_source_code var
&& (not (Pvar.is_static_local pvar))
&& (Pvar.is_global pvar || FormalMap.is_formal base formals)
in
match get_access_exp access with None -> true | Some acc_exp -> check_access acc_exp
let map ~f access = let map ~f access =
@ -132,7 +126,7 @@ module CallPrinter = struct
let pp fmt cs = F.fprintf fmt "call to %a" Procname.pp (CallSite.pname cs) let pp fmt cs = F.fprintf fmt "call to %a" Procname.pp (CallSite.pname cs)
end end
module LocksDomain = struct module LockDomain = struct
include AbstractDomain.CountDomain (struct include AbstractDomain.CountDomain (struct
(** arbitrary threshold for max locks we expect to be held simultaneously *) (** arbitrary threshold for max locks we expect to be held simultaneously *)
let max = 5 let max = 5
@ -216,7 +210,7 @@ module OwnershipAbstractValue = struct
let owned = OwnedIf IntSet.empty let owned = OwnedIf IntSet.empty
let is_owned = function OwnedIf set -> IntSet.is_empty set | _ -> false let is_owned = function OwnedIf set -> IntSet.is_empty set | Unowned -> false
let unowned = Unowned let unowned = Unowned
@ -293,20 +287,20 @@ module AccessSnapshot = struct
let make_unannotated_call_access formals pname lock ownership loc = let make_unannotated_call_access formals pname lock ownership loc =
let lock = LocksDomain.is_locked lock in let lock = LockDomain.is_locked lock in
let access = Access.make_unannotated_call_access pname in let access = Access.make_unannotated_call_access pname in
make_if_not_owned formals access lock ownership loc make_if_not_owned formals access lock ownership loc
let make_access formals acc_exp ~is_write loc lock thread ownership_precondition = let make_access formals acc_exp ~is_write loc lock thread ownership_precondition =
let lock = LocksDomain.is_locked lock in let lock = LockDomain.is_locked lock in
let access = Access.make_field_access acc_exp ~is_write in let access = Access.make_field_access acc_exp ~is_write in
make_if_not_owned formals access lock thread ownership_precondition loc make_if_not_owned formals access lock thread ownership_precondition loc
let make_container_access formals acc_exp ~is_write callee loc lock thread ownership_precondition let make_container_access formals acc_exp ~is_write callee loc lock thread ownership_precondition
= =
let lock = LocksDomain.is_locked lock in let lock = LockDomain.is_locked lock in
let access = Access.make_container_access acc_exp callee ~is_write in let access = Access.make_container_access acc_exp callee ~is_write in
make_if_not_owned formals access lock thread ownership_precondition loc make_if_not_owned formals access lock thread ownership_precondition loc
@ -319,7 +313,7 @@ module AccessSnapshot = struct
let thread = let thread =
ThreadsDomain.integrate_summary ~callee_astate:snapshot.elem.thread ~caller_astate:threads ThreadsDomain.integrate_summary ~callee_astate:snapshot.elem.thread ~caller_astate:threads
in in
let lock = snapshot.elem.lock || LocksDomain.is_locked locks in let lock = snapshot.elem.lock || LockDomain.is_locked locks in
with_callsite snapshot callsite with_callsite snapshot callsite
|> map ~f:(fun elem -> {elem with lock; thread; ownership_precondition}) |> map ~f:(fun elem -> {elem with lock; thread; ownership_precondition})
|> filter formals |> filter formals
@ -334,14 +328,6 @@ end
module AccessDomain = struct module AccessDomain = struct
include AbstractDomain.FiniteSet (AccessSnapshot) include AbstractDomain.FiniteSet (AccessSnapshot)
let add ({AccessSnapshot.elem= {access}} as s) astate =
let skip =
Access.get_access_exp access
|> Option.exists ~f:(fun exp -> AccessExpression.get_base exp |> fst |> should_skip_var)
in
if skip then astate else add s astate
let add_opt snapshot_opt astate = let add_opt snapshot_opt astate =
Option.fold snapshot_opt ~init:astate ~f:(fun acc s -> add s acc) Option.fold snapshot_opt ~init:astate ~f:(fun acc s -> add s acc)
end end
@ -435,8 +421,8 @@ module AttributeMapDomain = struct
let find acc_exp t = find_opt acc_exp t |> Option.value ~default:Attribute.top let find acc_exp t = find_opt acc_exp t |> Option.value ~default:Attribute.top
let has_attribute access_expression attribute t = let is_functional t access_expression =
find_opt access_expression t |> Option.exists ~f:(Attribute.equal attribute) match find_opt access_expression t with Some Functional -> true | _ -> false
let rec attribute_of_expr attribute_map (e : HilExp.t) = let rec attribute_of_expr attribute_map (e : HilExp.t) =
@ -464,14 +450,14 @@ end
type t = type t =
{ threads: ThreadsDomain.t { threads: ThreadsDomain.t
; locks: LocksDomain.t ; locks: LockDomain.t
; accesses: AccessDomain.t ; accesses: AccessDomain.t
; ownership: OwnershipDomain.t ; ownership: OwnershipDomain.t
; attribute_map: AttributeMapDomain.t } ; attribute_map: AttributeMapDomain.t }
let bottom = let bottom =
let threads = ThreadsDomain.bottom in let threads = ThreadsDomain.bottom in
let locks = LocksDomain.bottom in let locks = LockDomain.bottom in
let accesses = AccessDomain.empty in let accesses = AccessDomain.empty in
let ownership = OwnershipDomain.empty in let ownership = OwnershipDomain.empty in
let attribute_map = AttributeMapDomain.empty in let attribute_map = AttributeMapDomain.empty in
@ -479,7 +465,7 @@ let bottom =
let is_bottom {threads; locks; accesses; ownership; attribute_map} = let is_bottom {threads; locks; accesses; ownership; attribute_map} =
ThreadsDomain.is_bottom threads && LocksDomain.is_bottom locks && AccessDomain.is_empty accesses ThreadsDomain.is_bottom threads && LockDomain.is_bottom locks && AccessDomain.is_empty accesses
&& OwnershipDomain.is_empty ownership && OwnershipDomain.is_empty ownership
&& AttributeMapDomain.is_empty attribute_map && AttributeMapDomain.is_empty attribute_map
@ -488,7 +474,7 @@ let leq ~lhs ~rhs =
if phys_equal lhs rhs then true if phys_equal lhs rhs then true
else else
ThreadsDomain.leq ~lhs:lhs.threads ~rhs:rhs.threads ThreadsDomain.leq ~lhs:lhs.threads ~rhs:rhs.threads
&& LocksDomain.leq ~lhs:lhs.locks ~rhs:rhs.locks && LockDomain.leq ~lhs:lhs.locks ~rhs:rhs.locks
&& AccessDomain.leq ~lhs:lhs.accesses ~rhs:rhs.accesses && AccessDomain.leq ~lhs:lhs.accesses ~rhs:rhs.accesses
&& AttributeMapDomain.leq ~lhs:lhs.attribute_map ~rhs:rhs.attribute_map && AttributeMapDomain.leq ~lhs:lhs.attribute_map ~rhs:rhs.attribute_map
@ -497,7 +483,7 @@ let join astate1 astate2 =
if phys_equal astate1 astate2 then astate1 if phys_equal astate1 astate2 then astate1
else else
let threads = ThreadsDomain.join astate1.threads astate2.threads in let threads = ThreadsDomain.join astate1.threads astate2.threads in
let locks = LocksDomain.join astate1.locks astate2.locks in let locks = LockDomain.join astate1.locks astate2.locks in
let accesses = AccessDomain.join astate1.accesses astate2.accesses in let accesses = AccessDomain.join astate1.accesses astate2.accesses in
let ownership = OwnershipDomain.join astate1.ownership astate2.ownership in let ownership = OwnershipDomain.join astate1.ownership astate2.ownership in
let attribute_map = AttributeMapDomain.join astate1.attribute_map astate2.attribute_map in let attribute_map = AttributeMapDomain.join astate1.attribute_map astate2.attribute_map in
@ -508,7 +494,7 @@ let widen ~prev ~next ~num_iters =
if phys_equal prev next then prev if phys_equal prev next then prev
else else
let threads = ThreadsDomain.widen ~prev:prev.threads ~next:next.threads ~num_iters in let threads = ThreadsDomain.widen ~prev:prev.threads ~next:next.threads ~num_iters in
let locks = LocksDomain.widen ~prev:prev.locks ~next:next.locks ~num_iters in let locks = LockDomain.widen ~prev:prev.locks ~next:next.locks ~num_iters in
let accesses = AccessDomain.widen ~prev:prev.accesses ~next:next.accesses ~num_iters in let accesses = AccessDomain.widen ~prev:prev.accesses ~next:next.accesses ~num_iters in
let ownership = OwnershipDomain.widen ~prev:prev.ownership ~next:next.ownership ~num_iters in let ownership = OwnershipDomain.widen ~prev:prev.ownership ~next:next.ownership ~num_iters in
let attribute_map = let attribute_map =
@ -519,15 +505,15 @@ let widen ~prev ~next ~num_iters =
type summary = type summary =
{ threads: ThreadsDomain.t { threads: ThreadsDomain.t
; locks: LocksDomain.t ; locks: LockDomain.t
; accesses: AccessDomain.t ; accesses: AccessDomain.t
; return_ownership: OwnershipAbstractValue.t ; return_ownership: OwnershipAbstractValue.t
; return_attribute: Attribute.t } ; return_attribute: Attribute.t }
let empty_summary = let empty_summary =
{ threads= ThreadsDomain.bottom { threads= ThreadsDomain.bottom
; locks= LocksDomain.bottom ; locks= LockDomain.bottom
; accesses= AccessDomain.empty ; accesses= AccessDomain.bottom
; return_ownership= OwnershipAbstractValue.unowned ; return_ownership= OwnershipAbstractValue.unowned
; return_attribute= Attribute.top } ; return_attribute= Attribute.top }
@ -535,13 +521,13 @@ let empty_summary =
let pp_summary fmt {threads; locks; accesses; return_ownership; return_attribute} = let pp_summary fmt {threads; locks; accesses; return_ownership; return_attribute} =
F.fprintf fmt F.fprintf fmt
"@\nThreads: %a, Locks: %a @\nAccesses %a @\nOwnership: %a @\nReturn Attributes: %a @\n" "@\nThreads: %a, Locks: %a @\nAccesses %a @\nOwnership: %a @\nReturn Attributes: %a @\n"
ThreadsDomain.pp threads LocksDomain.pp locks AccessDomain.pp accesses OwnershipAbstractValue.pp ThreadsDomain.pp threads LockDomain.pp locks AccessDomain.pp accesses OwnershipAbstractValue.pp
return_ownership Attribute.pp return_attribute return_ownership Attribute.pp return_attribute
let pp fmt {threads; locks; accesses; ownership; attribute_map} = let pp fmt {threads; locks; accesses; ownership; attribute_map} =
F.fprintf fmt "Threads: %a, Locks: %a @\nAccesses %a @\nOwnership: %a @\nAttributes: %a @\n" F.fprintf fmt "Threads: %a, Locks: %a @\nAccesses %a @\nOwnership: %a @\nAttributes: %a @\n"
ThreadsDomain.pp threads LocksDomain.pp locks AccessDomain.pp accesses OwnershipDomain.pp ThreadsDomain.pp threads LockDomain.pp locks AccessDomain.pp accesses OwnershipDomain.pp
ownership AttributeMapDomain.pp attribute_map ownership AttributeMapDomain.pp attribute_map

@ -21,18 +21,15 @@ module Access : sig
(** Write to container object *) (** Write to container object *)
| InterfaceCall of Procname.t | InterfaceCall of Procname.t
(** Call to method of interface not annotated with [@ThreadSafe] *) (** Call to method of interface not annotated with [@ThreadSafe] *)
[@@deriving compare]
include ExplicitTrace.Element with type t := t val pp : F.formatter -> t -> unit
val get_access_exp : t -> AccessExpression.t option val get_access_exp : t -> AccessExpression.t option
end end
(** Overapproximation of number of locks that are currently held *) (** Overapproximation of number of time the lock has been acquired *)
module LocksDomain : sig module LockDomain : sig
type t include AbstractDomain.WithBottom
val bottom : t
val acquire_lock : t -> t val acquire_lock : t -> t
(** record acquisition of a lock *) (** record acquisition of a lock *)
@ -77,6 +74,8 @@ module OwnershipAbstractValue : sig
val owned : t val owned : t
val unowned : t
val make_owned_if : int -> t val make_owned_if : int -> t
val join : t -> t -> t val join : t -> t -> t
@ -107,7 +106,7 @@ module AccessSnapshot : sig
-> AccessExpression.t -> AccessExpression.t
-> is_write:bool -> is_write:bool
-> Location.t -> Location.t
-> LocksDomain.t -> LockDomain.t
-> ThreadsDomain.t -> ThreadsDomain.t
-> OwnershipAbstractValue.t -> OwnershipAbstractValue.t
-> t option -> t option
@ -118,7 +117,7 @@ module AccessSnapshot : sig
-> is_write:bool -> is_write:bool
-> Procname.t -> Procname.t
-> Location.t -> Location.t
-> LocksDomain.t -> LockDomain.t
-> ThreadsDomain.t -> ThreadsDomain.t
-> OwnershipAbstractValue.t -> OwnershipAbstractValue.t
-> t option -> t option
@ -134,7 +133,7 @@ module AccessSnapshot : sig
-> CallSite.t -> CallSite.t
-> OwnershipAbstractValue.t -> OwnershipAbstractValue.t
-> ThreadsDomain.t -> ThreadsDomain.t
-> LocksDomain.t -> LockDomain.t
-> t option -> t option
end end
@ -175,7 +174,7 @@ module AttributeMapDomain : sig
val add : AccessExpression.t -> Attribute.t -> t -> t val add : AccessExpression.t -> Attribute.t -> t -> t
val has_attribute : AccessExpression.t -> Attribute.t -> t -> bool val is_functional : t -> AccessExpression.t -> bool
val propagate_assignment : AccessExpression.t -> HilExp.t -> t -> t val propagate_assignment : AccessExpression.t -> HilExp.t -> t -> t
(** propagate attributes from the leaves to the root of an RHS Hil expression *) (** propagate attributes from the leaves to the root of an RHS Hil expression *)
@ -183,7 +182,7 @@ end
type t = type t =
{ threads: ThreadsDomain.t (** current thread: main, background, or unknown *) { threads: ThreadsDomain.t (** current thread: main, background, or unknown *)
; locks: LocksDomain.t (** boolean that is true if a lock must currently be held *) ; locks: LockDomain.t (** boolean that is true if a lock must currently be held *)
; accesses: AccessDomain.t ; accesses: AccessDomain.t
(** read and writes accesses performed without ownership permissions *) (** read and writes accesses performed without ownership permissions *)
; ownership: OwnershipDomain.t (** map of access paths to ownership predicates *) ; ownership: OwnershipDomain.t (** map of access paths to ownership predicates *)
@ -199,7 +198,7 @@ val add_unannotated_call_access : FormalMap.t -> Procname.t -> Location.t -> t -
may escape *) may escape *)
type summary = type summary =
{ threads: ThreadsDomain.t { threads: ThreadsDomain.t
; locks: LocksDomain.t ; locks: LockDomain.t
; accesses: AccessDomain.t ; accesses: AccessDomain.t
; return_ownership: OwnershipAbstractValue.t ; return_ownership: OwnershipAbstractValue.t
; return_attribute: Attribute.t } ; return_attribute: Attribute.t }

Loading…
Cancel
Save