From 77c4bf1fd15c4639347dbb8a97a478ef2d761c98 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sat, 7 Aug 2021 14:27:41 +0200 Subject: [PATCH] update vendor --- .../crillab/gophersat/explain/check.go | 149 ++++++++++++ .../crillab/gophersat/explain/mus.go | 213 ++++++++++++++++++ .../crillab/gophersat/explain/parser.go | 104 +++++++++ .../crillab/gophersat/explain/problem.go | 125 ++++++++++ .../crillab/gophersat/solver/solver.go | 20 ++ .../crillab/gophersat/solver/types.go | 5 + .../crillab/gophersat/solver/watcher.go | 10 + vendor/modules.txt | 3 +- 8 files changed, 628 insertions(+), 1 deletion(-) create mode 100644 vendor/github.com/crillab/gophersat/explain/check.go create mode 100644 vendor/github.com/crillab/gophersat/explain/mus.go create mode 100644 vendor/github.com/crillab/gophersat/explain/parser.go create mode 100644 vendor/github.com/crillab/gophersat/explain/problem.go diff --git a/vendor/github.com/crillab/gophersat/explain/check.go b/vendor/github.com/crillab/gophersat/explain/check.go new file mode 100644 index 00000000..31950559 --- /dev/null +++ b/vendor/github.com/crillab/gophersat/explain/check.go @@ -0,0 +1,149 @@ +// Package explain provides facilities to check and understand UNSAT instances. +package explain + +import ( + "bufio" + "fmt" + "io" + "strconv" + "strings" + + "github.com/crillab/gophersat/solver" +) + +// Options is a set of options that can be set to true during the checking process. +type Options struct { + // If Verbose is true, information about resolution will be written on stdout. + Verbose bool +} + +// Checks whether the clause satisfies the problem or not. +// Will return true if the problem is UNSAT, false if it is SAT or indeterminate. +func unsat(pb *Problem, clause []int) bool { + oldUnits := make([]int, len(pb.units)) + copy(oldUnits, pb.units) + // lits is supposed to be implied by the problem. + // We add the negation of each lit as a unit clause to see if this is true. + for _, lit := range clause { + if lit > 0 { + pb.units[lit-1] = -1 + } else { + pb.units[-lit-1] = 1 + } + } + res := pb.unsat() + pb.units = oldUnits // We must restore the previous state + return res +} + +// UnsatChan will wait RUP clauses from ch and use them as a certificate. +// It will return true iff the certificate is valid, i.e iff it makes the problem UNSAT +// through unit propagation. +// If pb.Options.ExtractSubset is true, a subset will also be extracted for that problem. +func (pb *Problem) UnsatChan(ch chan string) (valid bool, err error) { + defer pb.restore() + pb.initTagged() + for line := range ch { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + if _, err := strconv.Atoi(fields[0]); err != nil { + // This is not a clause: ignore the line + continue + } + clause, err := parseClause(fields) + if err != nil { + return false, err + } + if !unsat(pb, clause) { + return false, nil + } + if len(clause) == 0 { + // This is the empty and unit propagation made the problem UNSAT: we're done. + return true, nil + } + // Since clause is a logical consequence, append it to the problem + pb.Clauses = append(pb.Clauses, clause) + } + + // If we did not send any information through the channel + // It implies that the problem is trivially unsatisfiable + // Since we had only unit clauses inside the channel. + return true, nil +} + +// Unsat will parse a certificate, and return true iff the certificate is valid, i.e iff it makes the problem UNSAT +// through unit propagation. +// If pb.Options.ExtractSubset is true, a subset will also be extracted for that problem. +func (pb *Problem) Unsat(cert io.Reader) (valid bool, err error) { + defer pb.restore() + pb.initTagged() + sc := bufio.NewScanner(cert) + for sc.Scan() { + line := sc.Text() + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + if _, err := strconv.Atoi(fields[0]); err != nil { + // This is not a clause: ignore the line + continue + } + clause, err := parseClause(fields) + if err != nil { + return false, err + } + if !unsat(pb, clause) { + return false, nil + } + // Since clause is a logical consequence, append it to the problem + pb.Clauses = append(pb.Clauses, clause) + } + if err := sc.Err(); err != nil { + return false, fmt.Errorf("could not parse certificate: %v", err) + } + return true, nil +} + +// ErrNotUnsat is the error returned when trying to get the MUS or UnsatSubset of a satisfiable problem. +var ErrNotUnsat = fmt.Errorf("problem is not UNSAT") + +// UnsatSubset returns an unsatisfiable subset of the problem. +// The subset is not guaranteed to be a MUS, meaning some clauses of the resulting +// problem might be removed while still keeping the unsatisfiability of the problem. +// However, this method is much more efficient than extracting a MUS, as it only calls +// the SAT solver once. +func (pb *Problem) UnsatSubset() (subset *Problem, err error) { + solverPb := solver.ParseSlice(pb.Clauses) + if solverPb.Status == solver.Unsat { + // Problem is trivially UNSAT + // Make a copy so that pb and pb2 are not the same value. + pb2 := *pb + return &pb2, nil + } + s := solver.New(solver.ParseSlice(pb.Clauses)) + s.Certified = true + s.CertChan = make(chan string) + status := solver.Unsat + go func() { + status = s.Solve() + close(s.CertChan) + }() + if valid, err := pb.UnsatChan(s.CertChan); !valid || status == solver.Sat { + return nil, ErrNotUnsat + } else if err != nil { + return nil, fmt.Errorf("could not solve problem: %v", err) + } + subset = &Problem{ + NbVars: pb.NbVars, + } + for i, clause := range pb.Clauses { + if pb.tagged[i] { + // clause was used to prove pb is UNSAT: it's part of the subset + subset.Clauses = append(subset.Clauses, clause) + subset.NbClauses++ + } + } + return subset, nil +} diff --git a/vendor/github.com/crillab/gophersat/explain/mus.go b/vendor/github.com/crillab/gophersat/explain/mus.go new file mode 100644 index 00000000..2806ed78 --- /dev/null +++ b/vendor/github.com/crillab/gophersat/explain/mus.go @@ -0,0 +1,213 @@ +package explain + +import ( + "fmt" + + "github.com/crillab/gophersat/solver" +) + +// MUSMaxSat returns a Minimal Unsatisfiable Subset for the problem using the MaxSat strategy. +// A MUS is an unsatisfiable subset such that, if any of its clause is removed, +// the problem becomes satisfiable. +// A MUS can be useful to understand why a problem is UNSAT, but MUSes are expensive to compute since +// a SAT solver must be called several times on parts of the original problem to find them. +// With the MaxSat strategy, the function computes the MUS through several calls to MaxSat. +func (pb *Problem) MUSMaxSat() (mus *Problem, err error) { + pb2 := pb.clone() + nbVars := pb2.NbVars + NbClauses := pb2.NbClauses + weights := make([]int, NbClauses) // Weights of each clause + relaxLits := make([]solver.Lit, NbClauses) // Set of all relax lits + relaxLit := nbVars + 1 // Index of last used relax lit + for i, clause := range pb2.Clauses { + pb2.Clauses[i] = append(clause, relaxLit) + relaxLits[i] = solver.IntToLit(int32(relaxLit)) + weights[i] = 1 + relaxLit++ + } + prob := solver.ParseSlice(pb2.Clauses) + prob.SetCostFunc(relaxLits, weights) + s := solver.New(prob) + s.Verbose = pb.Options.Verbose + var musClauses [][]int + done := make([]bool, NbClauses) // Indicates whether a clause is already part of MUS or not yet + for { + cost := s.Minimize() + if cost == -1 { + return makeMus(nbVars, musClauses), nil + } + if cost == 0 { + return nil, fmt.Errorf("cannot extract MUS from satisfiable problem") + } + model := s.Model() + for i, clause := range pb.Clauses { + if !done[i] && !satClause(clause, model) { + // The clause is part of the MUS + pb2.Clauses = append(pb2.Clauses, []int{-(nbVars + i + 1)}) // Now, relax lit has to be false + pb2.NbClauses++ + musClauses = append(musClauses, clause) + done[i] = true + // Make it a hard clause before restarting solver + lits := make([]solver.Lit, len(clause)) + for j, lit := range clause { + lits[j] = solver.IntToLit(int32(lit)) + } + s.AppendClause(solver.NewClause(lits)) + } + } + if pb.Options.Verbose { + fmt.Printf("c Currently %d/%d clauses in MUS\n", len(musClauses), NbClauses) + } + prob = solver.ParseSlice(pb2.Clauses) + prob.SetCostFunc(relaxLits, weights) + s = solver.New(prob) + s.Verbose = pb.Options.Verbose + } +} + +// true iff the clause is satisfied by the model +func satClause(clause []int, model []bool) bool { + for _, lit := range clause { + if (lit > 0 && model[lit-1]) || (lit < 0 && !model[-lit-1]) { + return true + } + } + return false +} + +func makeMus(nbVars int, clauses [][]int) *Problem { + mus := &Problem{ + Clauses: clauses, + NbVars: nbVars, + NbClauses: len(clauses), + units: make([]int, nbVars), + } + for _, clause := range clauses { + if len(clause) == 1 { + lit := clause[0] + if lit > 0 { + mus.units[lit-1] = 1 + } else { + mus.units[-lit-1] = -1 + } + } + } + return mus +} + +// MUSInsertion returns a Minimal Unsatisfiable Subset for the problem using the insertion method. +// A MUS is an unsatisfiable subset such that, if any of its clause is removed, +// the problem becomes satisfiable. +// A MUS can be useful to understand why a problem is UNSAT, but MUSes are expensive to compute since +// a SAT solver must be called several times on parts of the original problem to find them. +// The insertion algorithm is efficient is many cases, as it calls the same solver several times in a row. +// However, in some cases, the number of calls will be higher than using other methods. +// For instance, if called on a formula that is already a MUS, it will perform n*(n-1) calls to SAT, where +// n is the number of clauses of the problem. +func (pb *Problem) MUSInsertion() (mus *Problem, err error) { + pb2, err := pb.UnsatSubset() + if err != nil { + return nil, fmt.Errorf("could not extract MUS: %v", err) + } + mus = &Problem{NbVars: pb2.NbVars} + clauses := pb2.Clauses + for { + if pb.Options.Verbose { + fmt.Printf("c mus currently contains %d clauses\n", mus.NbClauses) + } + s := solver.New(solver.ParseSliceNb(mus.Clauses, mus.NbVars)) + s.Verbose = pb.Options.Verbose + st := s.Solve() + if st == solver.Unsat { // Found the MUS + return mus, nil + } + // Add clauses until the problem becomes UNSAT + idx := 0 + for st == solver.Sat { + clause := clauses[idx] + lits := make([]solver.Lit, len(clause)) + for i, lit := range clause { + lits[i] = solver.IntToLit(int32(lit)) + } + cl := solver.NewClause(lits) + s.AppendClause(cl) + idx++ + st = s.Solve() + } + idx-- // We went one step too far, go back + mus.Clauses = append(mus.Clauses, clauses[idx]) // Last clause is part of the MUS + mus.NbClauses++ + if pb.Options.Verbose { + fmt.Printf("c removing %d/%d clause(s)\n", len(clauses)-idx, len(clauses)) + } + clauses = clauses[:idx] // Remaining clauses are not part of the MUS + } +} + +// MUSDeletion returns a Minimal Unsatisfiable Subset for the problem using the insertion method. +// A MUS is an unsatisfiable subset such that, if any of its clause is removed, +// the problem becomes satisfiable. +// A MUS can be useful to understand why a problem is UNSAT, but MUSes are expensive to compute since +// a SAT solver must be called several times on parts of the original problem to find them. +// The deletion algorithm is guaranteed to call exactly n SAT solvers, where n is the number of clauses in the problem. +// It can be quite efficient, but each time the solver is called, it is starting from scratch. +// Other methods keep the solver "hot", so despite requiring more calls, these methods can be more efficient in practice. +func (pb *Problem) MUSDeletion() (mus *Problem, err error) { + pb2, err := pb.UnsatSubset() + if err != nil { + if err == ErrNotUnsat { + return nil, err + } + return nil, fmt.Errorf("could not extract MUS: %v", err) + } + pb2.NbVars += pb2.NbClauses // Add one relax var for each clause + for i, clause := range pb2.Clauses { // Add relax lit to each clause + newClause := make([]int, len(clause)+1) + copy(newClause, clause) + newClause[len(clause)] = pb.NbVars + i + 1 // Add relax lit to the clause + pb2.Clauses[i] = newClause + } + s := solver.New(solver.ParseSlice(pb2.Clauses)) + asumptions := make([]solver.Lit, pb2.NbClauses) + for i := 0; i < pb2.NbClauses; i++ { + asumptions[i] = solver.IntToLit(int32(-(pb.NbVars + i + 1))) // At first, all asumptions are false + } + for i := range pb2.Clauses { + // Relax current clause + asumptions[i] = asumptions[i].Negation() + s.Assume(asumptions) + if s.Solve() == solver.Sat { + // It is now sat; reinsert the clause, i.e re-falsify the relax lit + asumptions[i] = asumptions[i].Negation() + if pb.Options.Verbose { + fmt.Printf("c clause %d/%d: kept\n", i+1, pb2.NbClauses) + } + } else if pb.Options.Verbose { + fmt.Printf("c clause %d/%d: removed\n", i+1, pb2.NbClauses) + } + } + mus = &Problem{ + NbVars: pb.NbVars, + } + for i, val := range asumptions { + if !val.IsPositive() { + // Lit is not relaxed, meaning the clause is part of the MUS + clause := pb2.Clauses[i] + clause = clause[:len(clause)-1] // Remove relax lit + mus.Clauses = append(mus.Clauses, clause) + } + mus.NbClauses = len(mus.Clauses) + } + return mus, nil +} + +// MUS returns a Minimal Unsatisfiable Subset for the problem. +// A MUS is an unsatisfiable subset such that, if any of its clause is removed, +// the problem becomes satisfiable. +// A MUS can be useful to understand why a problem is UNSAT, but MUSes are expensive to compute since +// a SAT solver must be called several times on parts of the original problem to find them. +// The exact algorithm used to compute the MUS is not guaranteed. If you want to use a given algorithm, +// use the relevant functions. +func (pb *Problem) MUS() (mus *Problem, err error) { + return pb.MUSDeletion() +} diff --git a/vendor/github.com/crillab/gophersat/explain/parser.go b/vendor/github.com/crillab/gophersat/explain/parser.go new file mode 100644 index 00000000..c8ccef1e --- /dev/null +++ b/vendor/github.com/crillab/gophersat/explain/parser.go @@ -0,0 +1,104 @@ +package explain + +import ( + "bufio" + "fmt" + "io" + "strconv" + "strings" +) + +// parseClause parses a line representing a clause in the DIMACS CNF syntax. +func parseClause(fields []string) ([]int, error) { + clause := make([]int, 0, len(fields)-1) + for _, rawLit := range fields { + lit, err := strconv.Atoi(rawLit) + if err != nil { + return nil, fmt.Errorf("could not parse clause %v: %v", fields, err) + } + if lit != 0 { + clause = append(clause, lit) + } + } + return clause, nil +} + +// ParseCNF parses a CNF and returns the associated problem. +func ParseCNF(r io.Reader) (*Problem, error) { + sc := bufio.NewScanner(r) + var pb Problem + for sc.Scan() { + line := sc.Text() + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + switch fields[0] { + case "c": + continue + case "p": + if err := pb.parseHeader(fields); err != nil { + return nil, fmt.Errorf("could not parse header %q: %v", line, err) + } + default: + if err := pb.parseClause(fields); err != nil { + return nil, fmt.Errorf("could not parse clause %q: %v", line, err) + } + } + } + if err := sc.Err(); err != nil { + return nil, fmt.Errorf("could not parse problem: %v", err) + } + return &pb, nil +} + +func (pb *Problem) parseHeader(fields []string) error { + if len(fields) != 4 { + return fmt.Errorf("expected 4 fields, got %d", len(fields)) + } + strVars := fields[2] + strClauses := fields[3] + var err error + pb.NbVars, err = strconv.Atoi(fields[2]) + if err != nil { + return fmt.Errorf("invalid number of vars %q: %v", strVars, err) + } + if pb.NbVars < 0 { + return fmt.Errorf("negative number of vars %d", pb.NbVars) + } + pb.units = make([]int, pb.NbVars) + pb.NbClauses, err = strconv.Atoi(fields[3]) + if err != nil { + return fmt.Errorf("invalid number of clauses %s: %v", strClauses, err) + } + if pb.NbClauses < 0 { + return fmt.Errorf("negative number of clauses %d", pb.NbClauses) + } + pb.Clauses = make([][]int, 0, pb.NbClauses) + return nil +} + +func (pb *Problem) parseClause(fields []string) error { + clause, err := parseClause(fields) + if err != nil { + return err + } + pb.Clauses = append(pb.Clauses, clause) + if len(clause) == 1 { + lit := clause[0] + v := lit + if lit < 0 { + v = -v + } + if v > pb.NbVars { + // There was an error in the header + return fmt.Errorf("found lit %d but problem was supposed to hold only %d vars", lit, pb.NbVars) + } + if lit > 0 { + pb.units[v-1] = 1 + } else { + pb.units[v-1] = -1 + } + } + return nil +} diff --git a/vendor/github.com/crillab/gophersat/explain/problem.go b/vendor/github.com/crillab/gophersat/explain/problem.go new file mode 100644 index 00000000..767dd7c3 --- /dev/null +++ b/vendor/github.com/crillab/gophersat/explain/problem.go @@ -0,0 +1,125 @@ +package explain + +import ( + "fmt" + "strings" +) + +// A Problem is a conjunction of Clauses. +// This package does not use solver's representation. +// We want this code to be as simple as possible to be easy to audit. +// On the other hand, solver's code must be as efficient as possible. +type Problem struct { + Clauses [][]int + NbVars int + NbClauses int + units []int // For each var, 0 if the var is unbound, 1 if true, -1 if false + Options Options + tagged []bool // List of claused used whil proving the problem is unsat. Initialized lazily +} + +func (pb *Problem) initTagged() { + pb.tagged = make([]bool, pb.NbClauses) + for i, clause := range pb.Clauses { + // Unit clauses are tagged as they will probably be used during resolution + pb.tagged[i] = len(clause) == 1 + } +} + +func (pb *Problem) clone() *Problem { + pb2 := &Problem{ + Clauses: make([][]int, pb.NbClauses), + NbVars: pb.NbVars, + NbClauses: pb.NbClauses, + units: make([]int, pb.NbVars), + } + copy(pb2.units, pb.units) + for i, clause := range pb.Clauses { + pb2.Clauses[i] = make([]int, len(clause)) + copy(pb2.Clauses[i], clause) + } + return pb2 +} + +// restore removes all learned clauses, if any. +func (pb *Problem) restore() { + pb.Clauses = pb.Clauses[:pb.NbClauses] +} + +// unsat will be true iff the problem can be proven unsat through unit propagation. +// This methods modifies pb.units. +func (pb *Problem) unsat() bool { + done := make([]bool, len(pb.Clauses)) // clauses that were deemed sat during propagation + modified := true + for modified { + modified = false + for i, clause := range pb.Clauses { + if done[i] { // That clause was already proved true + continue + } + unbound := 0 + var unit int // An unbound literal, if any + sat := false + for _, lit := range clause { + v := lit + if v < 0 { + v = -v + } + binding := pb.units[v-1] + if binding == 0 { + unbound++ + if unbound == 1 { + unit = lit + } else { + break + } + } else if binding*lit == v { // (binding == -1 && lit < 0) || (binding == 1 && lit > 0) { + sat = true + break + } + } + if sat { + done[i] = true + continue + } + if unbound == 0 { + // All lits are false: problem is UNSAT + if i < pb.NbClauses { + pb.tagged[i] = true + } + return true + } + if unbound == 1 { + if unit < 0 { + pb.units[-unit-1] = -1 + } else { + pb.units[unit-1] = 1 + } + done[i] = true + if i < pb.NbClauses { + pb.tagged[i] = true + } + modified = true + } + } + } + // Problem is either sat or could not be proven unsat through unit propagation + return false +} + +// CNF returns a representation of the problem using the Dimacs syntax. +func (pb *Problem) CNF() string { + lines := make([]string, 1, pb.NbClauses+1) + lines[0] = fmt.Sprintf("p cnf %d %d", pb.NbVars, pb.NbClauses) + for i := 0; i < pb.NbClauses; i++ { + clause := pb.Clauses[i] + strClause := make([]string, len(clause)+1) + for i, lit := range clause { + strClause[i] = fmt.Sprintf("%d", lit) + } + strClause[len(clause)] = "0" + line := strings.Join(strClause, " ") + lines = append(lines, line) + } + return strings.Join(lines, "\n") +} diff --git a/vendor/github.com/crillab/gophersat/solver/solver.go b/vendor/github.com/crillab/gophersat/solver/solver.go index 3d1662d8..e7c35c3f 100644 --- a/vendor/github.com/crillab/gophersat/solver/solver.go +++ b/vendor/github.com/crillab/gophersat/solver/solver.go @@ -127,6 +127,25 @@ func New(problem *Problem) *Solver { return s } +// newVar is used to indicate a new variable must be added to the solver. +// This can be used when new clauses are appended and these clauses contain vars that were unseen so far. +// If the var already existed, nothing will happen. +func (s *Solver) newVar(v Var) { + if cnfVar := int(v.Int()); cnfVar > s.nbVars { + // If the var already existed, do nothing + for i := s.nbVars; i < cnfVar; i++ { + s.model = append(s.model, 0) + s.activity = append(s.activity, 0.) + s.polarity = append(s.polarity, false) + s.reason = append(s.reason, nil) + s.trailBuf = append(s.trailBuf, 0) + } + s.varQueue = newQueue(s.activity) + s.addVarWatcherList(v) + s.nbVars = cnfVar + } +} + // sets initial activity for optimization variables, if any. func (s *Solver) initOptimActivity() { for i, lit := range s.minLits { @@ -682,6 +701,7 @@ func (s *Solver) AppendClause(clause *Clause) { i := 0 for i < clause.Len() { lit := clause.Get(i) + s.newVar(lit.Var()) switch s.litStatus(lit) { case Sat: w := clause.Weight(i) diff --git a/vendor/github.com/crillab/gophersat/solver/types.go b/vendor/github.com/crillab/gophersat/solver/types.go index 66017ba6..fd6e81d1 100644 --- a/vendor/github.com/crillab/gophersat/solver/types.go +++ b/vendor/github.com/crillab/gophersat/solver/types.go @@ -71,6 +71,11 @@ func (v Var) Lit() Lit { return Lit(v * 2) } +// Int converts a Var to a CNF variable. +func (v Var) Int() int32 { + return int32(v + 1) +} + // SignedLit returns the Lit associated to v, negated if 'signed', positive else. func (v Var) SignedLit(signed bool) Lit { if signed { diff --git a/vendor/github.com/crillab/gophersat/solver/watcher.go b/vendor/github.com/crillab/gophersat/solver/watcher.go index 2948fdc2..4056d585 100644 --- a/vendor/github.com/crillab/gophersat/solver/watcher.go +++ b/vendor/github.com/crillab/gophersat/solver/watcher.go @@ -39,6 +39,16 @@ func (s *Solver) initWatcherList(clauses []*Clause) { } } +// Should be called when new vars are added to the problem (see Solver.newVar) +func (s *Solver) addVarWatcherList(v Var) { + cnfVar := int(v.Int()) + for i := s.nbVars; i < cnfVar; i++ { + s.wl.wlistBin = append(s.wl.wlistBin, nil, nil) + s.wl.wlist = append(s.wl.wlist, nil, nil) + s.wl.wlistPb = append(s.wl.wlistPb, nil, nil) + } +} + // appendClause appends the clause without checking whether the clause is already satisfiable, unit, or unsatisfiable. // To perform those checks, call s.AppendClause. // clause is supposed to be a problem clause, not a learned one. diff --git a/vendor/modules.txt b/vendor/modules.txt index cb69c7b2..003bdb26 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -115,9 +115,10 @@ github.com/containerd/ttrpc github.com/containerd/typeurl # github.com/cpuguy83/go-md2man/v2 v2.0.0 github.com/cpuguy83/go-md2man/v2/md2man -# github.com/crillab/gophersat v1.3.2-0.20201023142334-3fc2ac466765 +# github.com/crillab/gophersat v1.3.2-0.20210701121804-72b19f5b6b38 ## explicit github.com/crillab/gophersat/bf +github.com/crillab/gophersat/explain github.com/crillab/gophersat/solver # github.com/cyphar/filepath-securejoin v0.2.2 github.com/cyphar/filepath-securejoin