mirror of
https://github.com/NationalSecurityAgency/ghidra.git
synced 2025-10-04 02:09:44 +02:00
GP-2627 TypePartialStruct
This commit is contained in:
parent
cc35d57933
commit
2591c17f22
15 changed files with 636 additions and 433 deletions
|
@ -43,12 +43,29 @@ void VariableGroup::addPiece(VariablePiece *piece)
|
|||
piece->group = this;
|
||||
if (!pieceSet.insert(piece).second)
|
||||
throw LowlevelError("Duplicate VariablePiece");
|
||||
int4 pieceMax = piece->getOffset() + piece->getSize();
|
||||
if (pieceMax > size)
|
||||
size = pieceMax;
|
||||
}
|
||||
|
||||
/// The adjustment amount must be positive, and this effectively increases the size of the group.
|
||||
/// \param amt is the given amount to add to offsets
|
||||
void VariableGroup::adjustOffsets(int4 amt)
|
||||
|
||||
{
|
||||
set<VariablePiece *,VariableGroup::PieceCompareByOffset>::iterator iter;
|
||||
|
||||
for(iter=pieceSet.begin();iter!=pieceSet.end();++iter) {
|
||||
(*iter)->groupOffset += amt;
|
||||
}
|
||||
size += amt;
|
||||
}
|
||||
|
||||
void VariableGroup::removePiece(VariablePiece *piece)
|
||||
|
||||
{
|
||||
pieceSet.erase(piece);
|
||||
// We currently don't adjust size here as removePiece is currently only called during clean up
|
||||
}
|
||||
|
||||
/// Construct piece given a HighVariable and its position within the whole.
|
||||
|
@ -134,17 +151,6 @@ void VariablePiece::updateCover(void) const
|
|||
high->highflags &= ~(uint4)HighVariable::extendcoverdirty;
|
||||
}
|
||||
|
||||
/// \param amt is the given amout to add to offset
|
||||
void VariablePiece::adjustOffset(int4 amt)
|
||||
|
||||
{
|
||||
set<VariablePiece *,VariableGroup::PieceCompareByOffset>::iterator iter;
|
||||
|
||||
for(iter=group->pieceSet.begin();iter!=group->pieceSet.end();++iter) {
|
||||
(*iter)->groupOffset += amt;
|
||||
}
|
||||
}
|
||||
|
||||
/// If there are no remaining references to the old VariableGroup it is deleted.
|
||||
/// \param newGroup is the new VariableGroup to transfer \b this to
|
||||
void VariablePiece::transferGroup(VariableGroup *newGroup)
|
||||
|
@ -169,13 +175,14 @@ void VariablePiece::combineOtherGroup(VariablePiece *op2,vector<HighVariable *>
|
|||
{
|
||||
int4 diff = groupOffset - op2->groupOffset; // Add to op2, or subtract from this
|
||||
if (diff > 0)
|
||||
op2->adjustOffset(diff);
|
||||
op2->group->adjustOffsets(diff);
|
||||
else if (diff < 0)
|
||||
adjustOffset(-diff);
|
||||
group->adjustOffsets(-diff);
|
||||
set<VariablePiece *,VariableGroup::PieceCompareByOffset>::iterator iter = op2->group->pieceSet.begin();
|
||||
set<VariablePiece *,VariableGroup::PieceCompareByOffset>::iterator enditer = op2->group->pieceSet.end();
|
||||
while(iter != enditer) {
|
||||
VariablePiece *piece = *iter;
|
||||
++iter;
|
||||
set<VariablePiece *,VariableGroup::PieceCompareByOffset>::iterator matchiter = group->pieceSet.find(piece);
|
||||
if (matchiter != group->pieceSet.end()) {
|
||||
mergePairs.push_back((*matchiter)->high);
|
||||
|
@ -228,15 +235,8 @@ void HighVariable::setSymbol(Varnode *vn) const
|
|||
}
|
||||
}
|
||||
symbol = entry->getSymbol();
|
||||
if (vn->isProtoPartial()) {
|
||||
Varnode *rootVn = PieceNode::findRoot(vn);
|
||||
if (rootVn == vn)
|
||||
throw LowlevelError("Partial varnode does not match symbol");
|
||||
|
||||
symboloffset = vn->getAddr().overlapJoin(0,rootVn->getAddr(),rootVn->getSize());
|
||||
SymbolEntry *entry = rootVn->getSymbolEntry();
|
||||
if (entry != (SymbolEntry *)0)
|
||||
symboloffset += entry->getOffset();
|
||||
if (vn->isProtoPartial() && piece != (VariablePiece *)0) {
|
||||
symboloffset = piece->getOffset() + piece->getGroup()->getSymbolOffset();
|
||||
}
|
||||
else if (entry->isDynamic()) // Dynamic symbols (that aren't partials) match whole variable
|
||||
symboloffset = -1;
|
||||
|
@ -249,6 +249,8 @@ void HighVariable::setSymbol(Varnode *vn) const
|
|||
symboloffset = vn->getAddr().overlapJoin(0,entry->getAddr(),symbol->getType()->getSize()) + entry->getOffset();
|
||||
}
|
||||
|
||||
if (type != (Datatype *)0 && type->getMetatype() == TYPE_PARTIALUNION)
|
||||
highflags |= typedirty;
|
||||
highflags &= ~((uint4)symboldirty); // We are no longer dirty
|
||||
}
|
||||
|
||||
|
@ -365,9 +367,17 @@ void HighVariable::updateType(void) const
|
|||
vn = getTypeRepresentative();
|
||||
|
||||
type = vn->getType();
|
||||
if (type->hasStripped() && type->getMetatype() != TYPE_PARTIALUNION)
|
||||
type = type->getStripped();
|
||||
|
||||
if (type->hasStripped()) {
|
||||
if (type->getMetatype() == TYPE_PARTIALUNION) {
|
||||
if (symbol != (Symbol *)0 && symboloffset != -1) {
|
||||
type_metatype meta = symbol->getType()->getMetatype();
|
||||
if (meta != TYPE_STRUCT && meta != TYPE_UNION) // If partial union does not have a bigger backing symbol
|
||||
type = type->getStripped(); // strip the partial union
|
||||
}
|
||||
}
|
||||
else
|
||||
type = type->getStripped();
|
||||
}
|
||||
// Update lock flags
|
||||
flags &= ~Varnode::typelock;
|
||||
if (vn->isTypeLock())
|
||||
|
@ -469,21 +479,6 @@ Varnode *HighVariable::getNameRepresentative(void) const
|
|||
return nameRepresentative;
|
||||
}
|
||||
|
||||
/// Find the first member that is either address tied or marked as a proto partial.
|
||||
/// \return a member Varnode acting as partial storage or null if none exist
|
||||
Varnode *HighVariable::getPartialOrAddrTied(void) const
|
||||
|
||||
{
|
||||
int4 i;
|
||||
|
||||
for(i=0;i<inst.size();++i) {
|
||||
Varnode *vn = inst[i];
|
||||
if (vn->isAddrTied() || vn->isProtoPartial())
|
||||
return vn;
|
||||
}
|
||||
return (Varnode *)0;
|
||||
}
|
||||
|
||||
/// Search for the given Varnode and cut it out of the list, marking all properties as \e dirty.
|
||||
/// \param vn is the given Varnode member to remove
|
||||
void HighVariable::remove(Varnode *vn)
|
||||
|
@ -551,7 +546,7 @@ void HighVariable::groupWith(int4 off,HighVariable *hi2)
|
|||
else if (hi2->piece == (VariablePiece *)0) {
|
||||
int4 hi2Off = piece->getOffset() - off;
|
||||
if (hi2Off < 0) {
|
||||
piece->adjustOffset(-hi2Off);
|
||||
piece->getGroup()->adjustOffsets(-hi2Off);
|
||||
hi2Off = 0;
|
||||
}
|
||||
if ((highflags & intersectdirty) == 0)
|
||||
|
@ -564,6 +559,22 @@ void HighVariable::groupWith(int4 off,HighVariable *hi2)
|
|||
}
|
||||
}
|
||||
|
||||
/// If \b this is part of a larger group and has had its \b symboloffset set, it can be used
|
||||
/// to calculate the \b symboloffset of other HighVariables in the same group, by writing it
|
||||
/// to the common VariableGroup object.
|
||||
void HighVariable::establishGroupSymbolOffset(void)
|
||||
|
||||
{
|
||||
VariableGroup *group = piece->getGroup();
|
||||
int4 off = symboloffset;
|
||||
if (off < 0)
|
||||
off = 0;
|
||||
off -= piece->getOffset();
|
||||
if (off < 0)
|
||||
throw LowlevelError("Symbol offset is incompatible with VariableGroup");
|
||||
group->setSymbolOffset(off);
|
||||
}
|
||||
|
||||
/// The lists of members are merged and the other HighVariable is deleted.
|
||||
/// \param tv2 is the other HighVariable to merge into \b this
|
||||
/// \param isspeculative is \b true to keep the new members in separate \e merge classes
|
||||
|
@ -614,12 +625,15 @@ void HighVariable::mergeInternal(HighVariable *tv2,bool isspeculative)
|
|||
/// the groups are combined into one, which may induce additional HighVariable pairs within the group to be merged.
|
||||
/// In all cases, the other HighVariable is deleted.
|
||||
/// \param tv2 is the other HighVariable to merge into \b this
|
||||
/// \param testCache if non-null is a cache of intersection tests that must be updated to reflect the merge
|
||||
/// \param isspeculative is \b true to keep the new members in separate \e merge classes
|
||||
void HighVariable::merge(HighVariable *tv2,bool isspeculative)
|
||||
void HighVariable::merge(HighVariable *tv2,HighIntersectTest *testCache,bool isspeculative)
|
||||
|
||||
{
|
||||
if (tv2 == this) return;
|
||||
|
||||
if (testCache != (HighIntersectTest *)0)
|
||||
testCache->moveIntersectTests(this,tv2);
|
||||
if (piece == (VariablePiece *)0 && tv2->piece == (VariablePiece *)0) {
|
||||
mergeInternal(tv2,isspeculative);
|
||||
return;
|
||||
|
@ -638,16 +652,18 @@ void HighVariable::merge(HighVariable *tv2,bool isspeculative)
|
|||
return;
|
||||
}
|
||||
// Reaching here both HighVariables are part of a group
|
||||
throw LowlevelError("Merging variables in separate groups not supported");
|
||||
// vector<HighVariable *> mergePairs;
|
||||
// piece->combineOtherGroup(tv2->piece, mergePairs);
|
||||
// for(int4 i=0;i<mergePairs.size();i+=2) {
|
||||
// HighVariable *high1 = mergePairs[i];
|
||||
// HighVariable *high2 = mergePairs[i+1];
|
||||
// // Need to deal with cached intersect tests herev
|
||||
// high1->mergeInternal(high2, isspeculative);
|
||||
// }
|
||||
// piece->markIntersectionDirty();
|
||||
if (isspeculative)
|
||||
throw LowlevelError("Trying speculatively merge variables in separate groups");
|
||||
vector<HighVariable *> mergePairs;
|
||||
piece->combineOtherGroup(tv2->piece, mergePairs);
|
||||
for(int4 i=0;i<mergePairs.size();i+=2) {
|
||||
HighVariable *high1 = mergePairs[i];
|
||||
HighVariable *high2 = mergePairs[i+1];
|
||||
if (testCache != (HighIntersectTest *)0)
|
||||
testCache->moveIntersectTests(high1, high2);
|
||||
high1->mergeInternal(high2, isspeculative);
|
||||
}
|
||||
piece->markIntersectionDirty();
|
||||
}
|
||||
|
||||
/// All Varnode objects are assigned a HighVariable, including those that don't get names like
|
||||
|
@ -755,15 +771,6 @@ int4 HighVariable::instanceIndex(const Varnode *vn) const
|
|||
return -1;
|
||||
}
|
||||
|
||||
/// \param op2 is the other HighVariable to compare with \b this
|
||||
/// \return \b true if they are in the same group
|
||||
bool HighVariable::sameGroup(const HighVariable *op2) const
|
||||
|
||||
{
|
||||
if (piece == (VariablePiece *)0 || op2->piece == (VariablePiece *)0) return false;
|
||||
return (piece->getGroup() == op2->piece->getGroup());
|
||||
}
|
||||
|
||||
/// \param encoder is the stream encoder
|
||||
void HighVariable::encode(Encoder &encoder) const
|
||||
|
||||
|
@ -780,6 +787,8 @@ void HighVariable::encode(Encoder &encoder) const
|
|||
else if (!isPersist() && (symbol != (Symbol *)0)) {
|
||||
if (symbol->getCategory() == Symbol::function_parameter)
|
||||
encoder.writeString(ATTRIB_CLASS, "param");
|
||||
else if (symbol->getScope()->isGlobal())
|
||||
encoder.writeString(ATTRIB_CLASS, "global");
|
||||
else
|
||||
encoder.writeString(ATTRIB_CLASS, "local");
|
||||
}
|
||||
|
@ -883,3 +892,233 @@ void HighVariable::verifyCover(void) const
|
|||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// \brief Gather Varnode instances of the given HighVariable that intersect a cover on a specific block
|
||||
///
|
||||
/// \param a is the given HighVariable
|
||||
/// \param blk is the specific block number
|
||||
/// \param cover is the Cover to test for intersection
|
||||
/// \param res will hold the resulting intersecting Varnodes
|
||||
void HighIntersectTest::gatherBlockVarnodes(HighVariable *a,int4 blk,const Cover &cover,vector<Varnode *> &res)
|
||||
|
||||
{
|
||||
for(int4 i=0;i<a->numInstances();++i) {
|
||||
Varnode *vn = a->getInstance(i);
|
||||
if (1<vn->getCover()->intersectByBlock(blk,cover))
|
||||
res.push_back(vn);
|
||||
}
|
||||
}
|
||||
|
||||
/// \brief Test instances of a the given HighVariable for intersection on a specific block with a cover
|
||||
///
|
||||
/// A list of Varnodes has already been determined to intersect on the block. For an instance that does as
|
||||
/// well, a final test of copy shadowing is performed with the Varnode list. If there is no shadowing,
|
||||
/// a merging intersection has been found and \b true is returned.
|
||||
/// \param a is the given HighVariable
|
||||
/// \param blk is the specific block number
|
||||
/// \param cover is the Cover to test for intersection
|
||||
/// \param relOff is the relative byte offset of the HighVariable to the Varnodes
|
||||
/// \param blist is the list of Varnodes for copy shadow testing
|
||||
/// \return \b true if there is an intersection preventing merging
|
||||
bool HighIntersectTest::testBlockIntersection(HighVariable *a,int4 blk,const Cover &cover,int4 relOff,
|
||||
const vector<Varnode *> &blist)
|
||||
{
|
||||
for(int4 i=0;i<a->numInstances();++i) {
|
||||
Varnode *vn = a->getInstance(i);
|
||||
if (2>vn->getCover()->intersectByBlock(blk,cover)) continue;
|
||||
for(int4 j=0;j<blist.size();++j) {
|
||||
Varnode *vn2 = blist[j];
|
||||
if (1<vn2->getCover()->intersectByBlock(blk,*vn->getCover())) {
|
||||
if (vn->getSize() == vn2->getSize()) {
|
||||
if (!vn->copyShadow(vn2))
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
if (!vn->partialCopyShadow(vn2,relOff))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// \brief Test if two HighVariables intersect on a given BlockBasic
|
||||
///
|
||||
/// Intersections are checked only on the specified block.
|
||||
/// \param a is the first HighVariable
|
||||
/// \param b is the second HighVariable
|
||||
/// \param blk is the index of the BlockBasic on which to test intersection
|
||||
/// \return \b true if an intersection occurs in the specified block
|
||||
bool HighIntersectTest::blockIntersection(HighVariable *a,HighVariable *b,int4 blk)
|
||||
|
||||
{
|
||||
vector<Varnode *> blist;
|
||||
|
||||
const Cover &aCover(a->getCover());
|
||||
const Cover &bCover(b->getCover());
|
||||
gatherBlockVarnodes(b,blk,aCover,blist);
|
||||
if (testBlockIntersection(a, blk, bCover, 0, blist))
|
||||
return true;
|
||||
if (a->piece != (VariablePiece *)0) {
|
||||
int4 baseOff = a->piece->getOffset();
|
||||
for(int4 i=0;i<a->piece->numIntersection();++i) {
|
||||
const VariablePiece *interPiece = a->piece->getIntersection(i);
|
||||
int4 off = interPiece->getOffset() - baseOff;
|
||||
if (testBlockIntersection(interPiece->getHigh(), blk, bCover, off, blist))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (b->piece != (VariablePiece *)0) {
|
||||
int4 bBaseOff = b->piece->getOffset();
|
||||
for(int4 i=0;i<b->piece->numIntersection();++i) {
|
||||
blist.clear();
|
||||
const VariablePiece *bPiece = b->piece->getIntersection(i);
|
||||
int4 bOff = bPiece->getOffset() - bBaseOff;
|
||||
gatherBlockVarnodes(bPiece->getHigh(),blk,aCover,blist);
|
||||
if (testBlockIntersection(a, blk, bCover, -bOff, blist))
|
||||
return true;
|
||||
if (a->piece != (VariablePiece *)0) {
|
||||
int4 baseOff = a->piece->getOffset();
|
||||
for(int4 j=0;j<a->piece->numIntersection();++j) {
|
||||
const VariablePiece *interPiece = a->piece->getIntersection(j);
|
||||
int4 off = (interPiece->getOffset() - baseOff) - bOff;
|
||||
if (off > 0 && off >= bPiece->getSize()) continue; // Do a piece and b piece intersect at all
|
||||
if (off < 0 && -off >= interPiece->getSize()) continue;
|
||||
if (testBlockIntersection(interPiece->getHigh(), blk, bCover, off, blist))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// All tests for pairs where either the first or second HighVariable matches the given one
|
||||
/// are removed.
|
||||
/// \param high is the given HighVariable to purge
|
||||
void HighIntersectTest::purgeHigh(HighVariable *high)
|
||||
|
||||
{
|
||||
map<HighEdge,bool>::iterator iterfirst = highedgemap.lower_bound( HighEdge(high,(HighVariable *)0) );
|
||||
map<HighEdge,bool>::iterator iterlast = highedgemap.lower_bound( HighEdge(high,(HighVariable *)~((uintp)0)) );
|
||||
|
||||
if (iterfirst == iterlast) return;
|
||||
--iterlast; // Move back 1 to prevent deleting under the iterator
|
||||
map<HighEdge,bool>::iterator iter;
|
||||
for(iter=iterfirst;iter!=iterlast;++iter)
|
||||
highedgemap.erase( HighEdge( (*iter).first.b, (*iter).first.a) );
|
||||
highedgemap.erase( HighEdge( (*iter).first.b, (*iter).first.a) );
|
||||
++iterlast; // Restore original range (with possibly new open endpoint)
|
||||
|
||||
highedgemap.erase(iterfirst,iterlast);
|
||||
}
|
||||
|
||||
/// \brief Translate any intersection tests for \e high2 into tests for \e high1
|
||||
///
|
||||
/// The two variables will be merged and \e high2, as an object, will be freed.
|
||||
/// We update the cached intersection tests for \e high2 so that they will now apply to new merged \e high1
|
||||
/// \param high1 is the variable object being kept
|
||||
/// \param high2 is the variable object being eliminated
|
||||
void HighIntersectTest::moveIntersectTests(HighVariable *high1,HighVariable *high2)
|
||||
|
||||
{
|
||||
vector<HighVariable *> yesinter; // Highs that high2 intersects
|
||||
vector<HighVariable *> nointer; // Highs that high2 does not intersect
|
||||
map<HighEdge,bool>::iterator iterfirst = highedgemap.lower_bound( HighEdge(high2,(HighVariable *)0) );
|
||||
map<HighEdge,bool>::iterator iterlast = highedgemap.lower_bound( HighEdge(high2,(HighVariable *)~((uintp)0)) );
|
||||
map<HighEdge,bool>::iterator iter;
|
||||
|
||||
for(iter=iterfirst;iter!=iterlast;++iter) {
|
||||
HighVariable *b = (*iter).first.b;
|
||||
if (b == high1) continue;
|
||||
if ((*iter).second) // Save all high2's intersections
|
||||
yesinter.push_back(b); // as they are still valid for the merge
|
||||
else {
|
||||
nointer.push_back(b);
|
||||
b->setMark(); // Mark that high2 did not intersect
|
||||
}
|
||||
}
|
||||
// Do a purge of all high2's tests
|
||||
if (iterfirst != iterlast) { // Delete all the high2 tests
|
||||
--iterlast; // Move back 1 to prevent deleting under the iterator
|
||||
for(iter=iterfirst;iter!=iterlast;++iter)
|
||||
highedgemap.erase( HighEdge( (*iter).first.b, (*iter).first.a) );
|
||||
highedgemap.erase( HighEdge( (*iter).first.b, (*iter).first.a) );
|
||||
++iterlast; // Restore original range (with possibly new open endpoint)
|
||||
|
||||
highedgemap.erase(iterfirst,iterlast);
|
||||
}
|
||||
|
||||
iter = highedgemap.lower_bound( HighEdge(high1,(HighVariable *)0) );
|
||||
while((iter!=highedgemap.end())&&((*iter).first.a == high1)) {
|
||||
if (!(*iter).second) { // If test is intersection==false
|
||||
if (!(*iter).first.b->isMark()) // and there was no test with high2
|
||||
highedgemap.erase( iter++ ); // Delete the test
|
||||
else
|
||||
++iter;
|
||||
}
|
||||
else // Keep any intersection==true tests
|
||||
++iter;
|
||||
}
|
||||
vector<HighVariable *>::iterator titer;
|
||||
for(titer=nointer.begin();titer!=nointer.end();++titer)
|
||||
(*titer)->clearMark();
|
||||
|
||||
// Reinsert high2's intersection==true tests for high1 now
|
||||
for(titer=yesinter.begin();titer!=yesinter.end();++titer) {
|
||||
highedgemap[ HighEdge(high1,*titer) ] = true;
|
||||
highedgemap[ HighEdge(*titer,high1) ] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// As manipulations are made, Cover information gets out of date. A \e dirty flag is used to
|
||||
/// indicate a particular HighVariable Cover is out-of-date. This routine checks the \e dirty
|
||||
/// flag and updates the Cover information if it is set.
|
||||
/// \param a is the HighVariable to update
|
||||
/// \return \b true if the HighVariable was not originally dirty
|
||||
bool HighIntersectTest::updateHigh(HighVariable *a)
|
||||
|
||||
{
|
||||
if (!a->isCoverDirty()) return true;
|
||||
|
||||
a->updateCover();
|
||||
purgeHigh(a);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// \brief Test the intersection of two HighVariables and cache the result
|
||||
///
|
||||
/// If the Covers of the two variables intersect, this routine returns \b true. To avoid
|
||||
/// expensive computation on the Cover objects themselves, the test result associated with
|
||||
/// the pair of HighVariables is cached.
|
||||
/// \param a is the first HighVariable
|
||||
/// \param b is the second HighVariable
|
||||
/// \return \b true if the variables intersect
|
||||
bool HighIntersectTest::intersection(HighVariable *a,HighVariable *b)
|
||||
|
||||
{
|
||||
if (a==b) return false;
|
||||
bool ares = updateHigh(a);
|
||||
bool bres = updateHigh(b);
|
||||
if (ares && bres) { // If neither high was dirty
|
||||
map<HighEdge,bool>::iterator iter = highedgemap.find( HighEdge(a,b) );
|
||||
if (iter != highedgemap.end()) // If previous test is present
|
||||
return (*iter).second; // Use it
|
||||
}
|
||||
|
||||
bool res = false;
|
||||
int4 blk;
|
||||
vector<int4> blockisect;
|
||||
a->getCover().intersectList(blockisect,b->getCover(),2);
|
||||
for(blk=0;blk<blockisect.size();++blk) {
|
||||
if (blockIntersection(a,b,blockisect[blk])) {
|
||||
res = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
highedgemap[ HighEdge(a,b) ] = res; // Cache the result
|
||||
highedgemap[ HighEdge(b,a) ] = res;
|
||||
return res;
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue