2016-08-25 00:14:36 +02:00
|
|
|
package fusefrontend_reverse
|
|
|
|
|
|
|
|
import (
|
2016-09-02 23:45:52 +02:00
|
|
|
"fmt"
|
2016-12-10 11:50:16 +01:00
|
|
|
"log"
|
2016-08-25 00:14:36 +02:00
|
|
|
"os"
|
2016-09-19 23:40:43 +02:00
|
|
|
"path/filepath"
|
2016-08-30 00:23:55 +02:00
|
|
|
"syscall"
|
2016-08-25 00:14:36 +02:00
|
|
|
|
|
|
|
"github.com/hanwen/go-fuse/fuse"
|
|
|
|
"github.com/hanwen/go-fuse/fuse/nodefs"
|
|
|
|
"github.com/hanwen/go-fuse/fuse/pathfs"
|
|
|
|
|
2016-09-20 19:32:28 +02:00
|
|
|
"github.com/rfjakob/gocryptfs/internal/configfile"
|
2016-08-25 00:14:36 +02:00
|
|
|
"github.com/rfjakob/gocryptfs/internal/contentenc"
|
|
|
|
"github.com/rfjakob/gocryptfs/internal/cryptocore"
|
|
|
|
"github.com/rfjakob/gocryptfs/internal/fusefrontend"
|
|
|
|
"github.com/rfjakob/gocryptfs/internal/nametransform"
|
2017-05-28 18:09:02 +02:00
|
|
|
"github.com/rfjakob/gocryptfs/internal/pathiv"
|
2016-10-08 20:57:38 +02:00
|
|
|
"github.com/rfjakob/gocryptfs/internal/tlog"
|
2016-08-25 00:14:36 +02:00
|
|
|
)
|
|
|
|
|
2016-11-10 00:38:01 +01:00
|
|
|
// ReverseFS implements the pathfs.FileSystem interface and provides an
|
|
|
|
// encrypted view of a plaintext directory.
|
|
|
|
type ReverseFS struct {
|
2016-08-30 00:23:55 +02:00
|
|
|
// Embed pathfs.defaultFileSystem for a ENOSYS implementation of all methods
|
2016-08-25 00:14:36 +02:00
|
|
|
pathfs.FileSystem
|
2016-08-30 00:23:55 +02:00
|
|
|
// pathfs.loopbackFileSystem, see go-fuse/fuse/pathfs/loopback.go
|
|
|
|
loopbackfs pathfs.FileSystem
|
2016-08-25 00:14:36 +02:00
|
|
|
// Stores configuration arguments
|
|
|
|
args fusefrontend.Args
|
|
|
|
// Filename encryption helper
|
|
|
|
nameTransform *nametransform.NameTransform
|
|
|
|
// Content encryption helper
|
|
|
|
contentEnc *contentenc.ContentEnc
|
|
|
|
}
|
|
|
|
|
2016-11-10 00:38:01 +01:00
|
|
|
var _ pathfs.FileSystem = &ReverseFS{}
|
2016-10-02 06:14:18 +02:00
|
|
|
|
2016-10-09 17:05:12 +02:00
|
|
|
// NewFS returns an encrypted FUSE overlay filesystem.
|
|
|
|
// In this case (reverse mode) the backing directory is plain-text and
|
2016-11-10 00:38:01 +01:00
|
|
|
// ReverseFS provides an encrypted view.
|
2017-08-11 18:42:30 +02:00
|
|
|
func NewFS(masterkey []byte, args fusefrontend.Args) *ReverseFS {
|
2016-10-09 17:05:12 +02:00
|
|
|
if args.CryptoBackend != cryptocore.BackendAESSIV {
|
2016-12-10 11:50:16 +01:00
|
|
|
log.Panic("reverse mode must use AES-SIV, everything else is insecure")
|
2016-10-09 17:05:12 +02:00
|
|
|
}
|
2016-10-05 22:22:28 +02:00
|
|
|
initLongnameCache()
|
2017-08-11 18:42:30 +02:00
|
|
|
cryptoCore := cryptocore.New(masterkey, args.CryptoBackend, contentenc.DefaultIVBits, args.HKDF, false)
|
2017-04-08 02:09:28 +02:00
|
|
|
contentEnc := contentenc.New(cryptoCore, contentenc.DefaultBS, false)
|
2017-03-05 13:58:24 +01:00
|
|
|
nameTransform := nametransform.New(cryptoCore.EMECipher, args.LongNames, args.Raw64)
|
2016-08-25 00:14:36 +02:00
|
|
|
|
2016-11-10 00:38:01 +01:00
|
|
|
return &ReverseFS{
|
2016-08-30 00:23:55 +02:00
|
|
|
// pathfs.defaultFileSystem returns ENOSYS for all operations
|
|
|
|
FileSystem: pathfs.NewDefaultFileSystem(),
|
|
|
|
loopbackfs: pathfs.NewLoopbackFileSystem(args.Cipherdir),
|
2016-08-25 00:14:36 +02:00
|
|
|
args: args,
|
|
|
|
nameTransform: nameTransform,
|
|
|
|
contentEnc: contentEnc,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2016-09-19 23:40:43 +02:00
|
|
|
// relDir is identical to filepath.Dir excepts that it returns "" when
|
|
|
|
// filepath.Dir would return ".".
|
|
|
|
// In the FUSE API, the root directory is called "", and we actually want that.
|
|
|
|
func relDir(path string) string {
|
|
|
|
dir := filepath.Dir(path)
|
|
|
|
if dir == "." {
|
|
|
|
return ""
|
|
|
|
}
|
|
|
|
return dir
|
|
|
|
}
|
|
|
|
|
|
|
|
// isDirIV determines if the path points to a gocryptfs.diriv file
|
2016-11-10 00:38:01 +01:00
|
|
|
func (rfs *ReverseFS) isDirIV(relPath string) bool {
|
2016-10-08 20:57:38 +02:00
|
|
|
if rfs.args.PlaintextNames {
|
|
|
|
return false
|
|
|
|
}
|
2016-09-19 23:40:43 +02:00
|
|
|
return filepath.Base(relPath) == nametransform.DirIVFilename
|
|
|
|
}
|
|
|
|
|
2016-09-22 23:28:11 +02:00
|
|
|
// isNameFile determines if the path points to a gocryptfs.longname.*.name
|
|
|
|
// file
|
2016-11-10 00:38:01 +01:00
|
|
|
func (rfs *ReverseFS) isNameFile(relPath string) bool {
|
2016-10-08 20:57:38 +02:00
|
|
|
if rfs.args.PlaintextNames {
|
|
|
|
return false
|
|
|
|
}
|
2016-09-22 23:28:11 +02:00
|
|
|
fileType := nametransform.NameType(filepath.Base(relPath))
|
|
|
|
return fileType == nametransform.LongNameFilename
|
|
|
|
}
|
|
|
|
|
2017-04-01 17:19:15 +02:00
|
|
|
// isTranslatedConfig returns true if the default config file name is in use
|
2016-10-08 21:14:16 +02:00
|
|
|
// and the ciphertext path is "gocryptfs.conf".
|
|
|
|
// "gocryptfs.conf" then maps to ".gocryptfs.reverse.conf" in the plaintext
|
|
|
|
// directory.
|
2016-11-10 00:38:01 +01:00
|
|
|
func (rfs *ReverseFS) isTranslatedConfig(relPath string) bool {
|
2016-10-08 21:14:16 +02:00
|
|
|
if rfs.args.ConfigCustom {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
if relPath == configfile.ConfDefaultName {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
2016-09-20 19:32:28 +02:00
|
|
|
// GetAttr - FUSE call
|
2017-04-01 17:19:15 +02:00
|
|
|
// "relPath" is the relative ciphertext path
|
2016-11-10 00:38:01 +01:00
|
|
|
func (rfs *ReverseFS) GetAttr(relPath string, context *fuse.Context) (*fuse.Attr, fuse.Status) {
|
2017-04-01 17:19:15 +02:00
|
|
|
// Handle "gocryptfs.conf"
|
2016-10-08 21:14:16 +02:00
|
|
|
if rfs.isTranslatedConfig(relPath) {
|
2017-04-01 17:19:15 +02:00
|
|
|
absConfPath, _ := rfs.abs(configfile.ConfReverseName, nil)
|
|
|
|
var st syscall.Stat_t
|
|
|
|
err := syscall.Lstat(absConfPath, &st)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fuse.ToStatus(err)
|
|
|
|
}
|
|
|
|
var a fuse.Attr
|
|
|
|
a.FromStat(&st)
|
2017-05-30 23:01:06 +02:00
|
|
|
if rfs.args.ForceOwner != nil {
|
|
|
|
a.Owner = *rfs.args.ForceOwner
|
|
|
|
}
|
2017-04-01 17:19:15 +02:00
|
|
|
return &a, fuse.OK
|
2016-09-20 20:59:02 +02:00
|
|
|
}
|
2017-04-01 17:19:15 +02:00
|
|
|
// Handle virtual files (gocryptfs.diriv, *.name)
|
2016-09-22 23:28:11 +02:00
|
|
|
var f nodefs.File
|
|
|
|
var status fuse.Status
|
|
|
|
virtual := false
|
2016-10-08 20:57:38 +02:00
|
|
|
if rfs.isDirIV(relPath) {
|
2016-09-22 23:28:11 +02:00
|
|
|
virtual = true
|
|
|
|
f, status = rfs.newDirIVFile(relPath)
|
|
|
|
}
|
2016-10-08 20:57:38 +02:00
|
|
|
if rfs.isNameFile(relPath) {
|
2016-09-22 23:28:11 +02:00
|
|
|
virtual = true
|
|
|
|
f, status = rfs.newNameFile(relPath)
|
|
|
|
}
|
|
|
|
if virtual {
|
|
|
|
if !status.Ok() {
|
2017-04-01 15:49:53 +02:00
|
|
|
tlog.Warn.Printf("GetAttr %q: newXFile failed: %v\n", relPath, status)
|
2016-09-22 23:28:11 +02:00
|
|
|
return nil, status
|
|
|
|
}
|
|
|
|
var a fuse.Attr
|
|
|
|
status = f.GetAttr(&a)
|
2017-05-30 23:01:06 +02:00
|
|
|
if rfs.args.ForceOwner != nil {
|
|
|
|
a.Owner = *rfs.args.ForceOwner
|
|
|
|
}
|
2016-09-22 23:28:11 +02:00
|
|
|
return &a, status
|
|
|
|
}
|
2017-04-01 17:19:15 +02:00
|
|
|
// Decrypt path to "plaintext relative path"
|
|
|
|
pRelPath, err := rfs.decryptPath(relPath)
|
|
|
|
if err != nil {
|
|
|
|
return nil, fuse.ToStatus(err)
|
|
|
|
}
|
|
|
|
absPath, _ := rfs.abs(pRelPath, nil)
|
|
|
|
// Stat the backing file
|
|
|
|
var st syscall.Stat_t
|
|
|
|
if relPath == "" {
|
|
|
|
// Look through symlinks for the root dir
|
|
|
|
err = syscall.Stat(absPath, &st)
|
|
|
|
} else {
|
|
|
|
err = syscall.Lstat(absPath, &st)
|
|
|
|
}
|
2016-08-25 00:14:36 +02:00
|
|
|
if err != nil {
|
|
|
|
return nil, fuse.ToStatus(err)
|
|
|
|
}
|
2017-04-01 17:19:15 +02:00
|
|
|
// Instead of risking an inode number collision, we return an error.
|
2017-07-29 16:13:38 +02:00
|
|
|
if st.Ino > inoBaseMin {
|
2017-04-01 17:19:15 +02:00
|
|
|
tlog.Warn.Printf("GetAttr %q: backing file inode number %d crosses reserved space, max=%d. Returning EOVERFLOW.",
|
2017-07-29 16:13:38 +02:00
|
|
|
relPath, st.Ino, inoBaseMin)
|
2017-04-01 17:19:15 +02:00
|
|
|
return nil, fuse.ToStatus(syscall.EOVERFLOW)
|
2016-08-25 00:14:36 +02:00
|
|
|
}
|
2017-04-01 17:19:15 +02:00
|
|
|
var a fuse.Attr
|
|
|
|
a.FromStat(&st)
|
2016-08-25 00:14:36 +02:00
|
|
|
// Calculate encrypted file size
|
|
|
|
if a.IsRegular() {
|
2016-08-30 00:23:55 +02:00
|
|
|
a.Size = rfs.contentEnc.PlainSizeToCipherSize(a.Size)
|
2017-03-07 10:09:09 +01:00
|
|
|
} else if a.IsSymlink() {
|
|
|
|
var linkTarget string
|
|
|
|
var readlinkStatus fuse.Status
|
|
|
|
|
|
|
|
linkTarget, readlinkStatus = rfs.Readlink(relPath, context)
|
|
|
|
if !readlinkStatus.Ok() {
|
|
|
|
return nil, readlinkStatus
|
|
|
|
}
|
|
|
|
|
|
|
|
a.Size = uint64(len(linkTarget))
|
2016-08-25 00:14:36 +02:00
|
|
|
}
|
2017-05-30 23:01:06 +02:00
|
|
|
if rfs.args.ForceOwner != nil {
|
|
|
|
a.Owner = *rfs.args.ForceOwner
|
|
|
|
}
|
2017-04-01 17:19:15 +02:00
|
|
|
return &a, fuse.OK
|
2016-08-25 00:14:36 +02:00
|
|
|
}
|
|
|
|
|
2016-09-20 19:32:28 +02:00
|
|
|
// Access - FUSE call
|
2016-11-10 00:38:01 +01:00
|
|
|
func (rfs *ReverseFS) Access(relPath string, mode uint32, context *fuse.Context) fuse.Status {
|
2017-02-16 21:16:42 +01:00
|
|
|
if rfs.isTranslatedConfig(relPath) || rfs.isDirIV(relPath) || rfs.isNameFile(relPath) {
|
2017-04-29 14:50:58 +02:00
|
|
|
// access(2) R_OK flag for checking if the file is readable, always 4 as defined in POSIX.
|
|
|
|
ROK := uint32(0x4)
|
2017-02-16 21:16:42 +01:00
|
|
|
// Virtual files can always be read and never written
|
2017-04-29 14:50:58 +02:00
|
|
|
if mode == ROK || mode == 0 {
|
2017-02-16 21:16:42 +01:00
|
|
|
return fuse.OK
|
|
|
|
}
|
2017-04-29 14:50:58 +02:00
|
|
|
return fuse.EPERM
|
2016-08-30 00:23:55 +02:00
|
|
|
}
|
2016-09-19 23:40:43 +02:00
|
|
|
absPath, err := rfs.abs(rfs.decryptPath(relPath))
|
2016-08-30 00:23:55 +02:00
|
|
|
if err != nil {
|
|
|
|
return fuse.ToStatus(err)
|
|
|
|
}
|
2016-09-19 23:40:43 +02:00
|
|
|
return fuse.ToStatus(syscall.Access(absPath, mode))
|
2016-08-30 00:23:55 +02:00
|
|
|
}
|
|
|
|
|
2016-09-20 19:32:28 +02:00
|
|
|
// Open - FUSE call
|
2016-11-10 00:38:01 +01:00
|
|
|
func (rfs *ReverseFS) Open(relPath string, flags uint32, context *fuse.Context) (fuseFile nodefs.File, status fuse.Status) {
|
2016-10-08 21:14:16 +02:00
|
|
|
if rfs.isTranslatedConfig(relPath) {
|
2016-09-20 19:32:28 +02:00
|
|
|
return rfs.loopbackfs.Open(configfile.ConfReverseName, flags, context)
|
|
|
|
}
|
2016-10-08 20:57:38 +02:00
|
|
|
if rfs.isDirIV(relPath) {
|
2016-09-22 23:28:11 +02:00
|
|
|
return rfs.newDirIVFile(relPath)
|
|
|
|
}
|
2016-10-08 20:57:38 +02:00
|
|
|
if rfs.isNameFile(relPath) {
|
2016-09-22 23:28:11 +02:00
|
|
|
return rfs.newNameFile(relPath)
|
2016-09-19 23:40:43 +02:00
|
|
|
}
|
2016-11-10 00:38:01 +01:00
|
|
|
return rfs.newFile(relPath, flags)
|
2016-08-25 00:14:36 +02:00
|
|
|
}
|
|
|
|
|
2016-11-10 00:38:01 +01:00
|
|
|
func (rfs *ReverseFS) openDirPlaintextnames(relPath string, entries []fuse.DirEntry) ([]fuse.DirEntry, fuse.Status) {
|
2016-10-08 20:57:38 +02:00
|
|
|
if relPath != "" || rfs.args.ConfigCustom {
|
|
|
|
return entries, fuse.OK
|
|
|
|
}
|
|
|
|
// We are in the root dir and the default config file name
|
|
|
|
// ".gocryptfs.reverse.conf" is used. We map it to "gocryptfs.conf".
|
|
|
|
dupe := -1
|
|
|
|
status := fuse.OK
|
|
|
|
for i := range entries {
|
|
|
|
if entries[i].Name == configfile.ConfReverseName {
|
|
|
|
entries[i].Name = configfile.ConfDefaultName
|
|
|
|
} else if entries[i].Name == configfile.ConfDefaultName {
|
|
|
|
dupe = i
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if dupe >= 0 {
|
|
|
|
// Warn the user loudly: The gocryptfs.conf_NAME_COLLISION file will
|
|
|
|
// throw ENOENT errors that are hard to miss.
|
|
|
|
tlog.Warn.Printf("The file %s is mapped to %s and shadows another file. Please rename %s in %s .",
|
|
|
|
configfile.ConfReverseName, configfile.ConfDefaultName, configfile.ConfDefaultName, rfs.args.Cipherdir)
|
|
|
|
entries[dupe].Name = "gocryptfs.conf_NAME_COLLISION_" + fmt.Sprintf("%d", cryptocore.RandUint64())
|
|
|
|
}
|
|
|
|
return entries, status
|
|
|
|
}
|
|
|
|
|
2016-09-20 19:32:28 +02:00
|
|
|
// OpenDir - FUSE readdir call
|
2016-11-10 00:38:01 +01:00
|
|
|
func (rfs *ReverseFS) OpenDir(cipherPath string, context *fuse.Context) ([]fuse.DirEntry, fuse.Status) {
|
2016-09-19 23:40:43 +02:00
|
|
|
relPath, err := rfs.decryptPath(cipherPath)
|
2016-08-25 00:14:36 +02:00
|
|
|
if err != nil {
|
|
|
|
return nil, fuse.ToStatus(err)
|
|
|
|
}
|
|
|
|
// Read plaintext dir
|
2016-08-30 00:23:55 +02:00
|
|
|
entries, status := rfs.loopbackfs.OpenDir(relPath, context)
|
2016-08-25 00:14:36 +02:00
|
|
|
if entries == nil {
|
|
|
|
return nil, status
|
|
|
|
}
|
2016-10-08 20:57:38 +02:00
|
|
|
if rfs.args.PlaintextNames {
|
|
|
|
return rfs.openDirPlaintextnames(cipherPath, entries)
|
|
|
|
}
|
2016-09-24 22:40:00 +02:00
|
|
|
// Allocate maximum possible number of virtual files.
|
|
|
|
// If all files have long names we need a virtual ".name" file for each,
|
|
|
|
// plus one for gocryptfs.diriv.
|
|
|
|
virtualFiles := make([]fuse.DirEntry, len(entries)+1)
|
2016-09-22 19:54:37 +02:00
|
|
|
// Virtual gocryptfs.diriv file
|
2016-09-25 19:01:50 +02:00
|
|
|
virtualFiles[0] = fuse.DirEntry{
|
2017-03-27 22:47:45 +02:00
|
|
|
Mode: virtualFileMode,
|
2016-09-25 19:01:50 +02:00
|
|
|
Name: nametransform.DirIVFilename,
|
|
|
|
}
|
2016-09-24 22:40:00 +02:00
|
|
|
// Actually used entries
|
|
|
|
nVirtual := 1
|
|
|
|
|
2016-08-25 00:14:36 +02:00
|
|
|
// Encrypt names
|
2017-05-28 18:09:02 +02:00
|
|
|
dirIV := pathiv.Derive(cipherPath, pathiv.PurposeDirIV)
|
2016-08-25 00:14:36 +02:00
|
|
|
for i := range entries {
|
2016-09-22 19:54:37 +02:00
|
|
|
var cName string
|
2016-09-20 20:59:02 +02:00
|
|
|
// ".gocryptfs.reverse.conf" in the root directory is mapped to "gocryptfs.conf"
|
2016-10-08 22:25:08 +02:00
|
|
|
if cipherPath == "" && entries[i].Name == configfile.ConfReverseName {
|
2016-09-22 19:54:37 +02:00
|
|
|
cName = configfile.ConfDefaultName
|
2016-09-20 20:59:02 +02:00
|
|
|
} else {
|
2016-09-22 19:54:37 +02:00
|
|
|
cName = rfs.nameTransform.EncryptName(entries[i].Name, dirIV)
|
|
|
|
if len(cName) > syscall.NAME_MAX {
|
2017-03-05 22:25:41 +01:00
|
|
|
cName = rfs.nameTransform.HashLongName(cName)
|
2016-09-25 19:01:50 +02:00
|
|
|
dotNameFile := fuse.DirEntry{
|
2017-03-27 22:47:45 +02:00
|
|
|
Mode: virtualFileMode,
|
2016-09-25 19:01:50 +02:00
|
|
|
Name: cName + nametransform.LongNameSuffix,
|
|
|
|
}
|
2016-09-24 22:40:00 +02:00
|
|
|
virtualFiles[nVirtual] = dotNameFile
|
|
|
|
nVirtual++
|
2016-09-22 19:54:37 +02:00
|
|
|
}
|
2016-09-20 20:59:02 +02:00
|
|
|
}
|
2016-09-22 19:54:37 +02:00
|
|
|
entries[i].Name = cName
|
2016-08-25 00:14:36 +02:00
|
|
|
}
|
2016-09-24 22:40:00 +02:00
|
|
|
entries = append(entries, virtualFiles[:nVirtual]...)
|
2016-08-25 00:14:36 +02:00
|
|
|
return entries, fuse.OK
|
|
|
|
}
|
2016-09-21 21:25:55 +02:00
|
|
|
|
|
|
|
// StatFs - FUSE call
|
2016-11-10 00:38:01 +01:00
|
|
|
func (rfs *ReverseFS) StatFs(name string) *fuse.StatfsOut {
|
2016-09-21 21:25:55 +02:00
|
|
|
return rfs.loopbackfs.StatFs(name)
|
|
|
|
}
|
2016-09-25 18:01:24 +02:00
|
|
|
|
|
|
|
// Readlink - FUSE call
|
2016-11-10 00:38:01 +01:00
|
|
|
func (rfs *ReverseFS) Readlink(cipherPath string, context *fuse.Context) (string, fuse.Status) {
|
2016-09-25 18:01:24 +02:00
|
|
|
absPath, err := rfs.abs(rfs.decryptPath(cipherPath))
|
|
|
|
if err != nil {
|
|
|
|
return "", fuse.ToStatus(err)
|
|
|
|
}
|
|
|
|
plainTarget, err := os.Readlink(absPath)
|
|
|
|
if err != nil {
|
|
|
|
return "", fuse.ToStatus(err)
|
|
|
|
}
|
|
|
|
if rfs.args.PlaintextNames {
|
|
|
|
return plainTarget, fuse.OK
|
|
|
|
}
|
2017-05-28 18:09:02 +02:00
|
|
|
nonce := pathiv.Derive(cipherPath, pathiv.PurposeSymlinkIV)
|
2016-09-25 18:01:24 +02:00
|
|
|
// Symlinks are encrypted like file contents and base64-encoded
|
2016-09-29 21:29:45 +02:00
|
|
|
cBinTarget := rfs.contentEnc.EncryptBlockNonce([]byte(plainTarget), 0, nil, nonce)
|
2017-03-05 22:59:25 +01:00
|
|
|
cTarget := rfs.nameTransform.B64.EncodeToString(cBinTarget)
|
2017-11-26 21:27:29 +01:00
|
|
|
// The kernel will reject a symlink target above 4096 chars and return
|
|
|
|
// and I/O error to the user. Better emit the proper error ourselves.
|
|
|
|
const PATH_MAX = 4096 // not defined on Darwin
|
|
|
|
if len(cTarget) > PATH_MAX {
|
|
|
|
return "", fuse.Status(syscall.ENAMETOOLONG)
|
|
|
|
}
|
2016-09-25 18:01:24 +02:00
|
|
|
return cTarget, fuse.OK
|
|
|
|
}
|