Co-authored-by: Mathias <mthbqv@gmail.com>
This commit was merged in pull request #156.
This commit is contained in:
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/claim"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/search"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
|
||||
@@ -481,7 +480,7 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
||||
allWarnings = append(allWarnings, fmt.Sprintf("skipped %s: %v", path, rerr))
|
||||
return nil
|
||||
}
|
||||
content, readErr := extract.Text(path)
|
||||
content, readErr := ReadIngestFile(h.brainDir, path)
|
||||
if readErr != nil {
|
||||
allWarnings = append(allWarnings, fmt.Sprintf("extract %s: %v", path, readErr))
|
||||
return nil
|
||||
@@ -510,7 +509,7 @@ func (h *Handler) IngestPath(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported file extension: %s", ext))
|
||||
return
|
||||
}
|
||||
content, readErr := extract.Text(req.Path)
|
||||
content, readErr := ReadIngestFile(h.brainDir, req.Path)
|
||||
if readErr != nil {
|
||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("extract text: %v", readErr))
|
||||
return
|
||||
|
||||
@@ -2,6 +2,9 @@ package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
@@ -19,6 +22,11 @@ func ResolveIngestPath(brainDir, p string) (string, error) {
|
||||
if strings.TrimSpace(p) == "" {
|
||||
return "", ErrOutsideIngestRoot
|
||||
}
|
||||
// raw/ itself must be a real directory: if it were a symlink, containment
|
||||
// inside it would mean nothing (hyperguild#153 item 4).
|
||||
if fi, lerr := os.Lstat(filepath.Join(brainDir, "raw")); lerr != nil || fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() {
|
||||
return "", ErrOutsideIngestRoot
|
||||
}
|
||||
root, err := filepath.EvalSymlinks(filepath.Join(brainDir, "raw"))
|
||||
if err != nil {
|
||||
return "", ErrOutsideIngestRoot
|
||||
@@ -36,3 +44,24 @@ func ResolveIngestPath(brainDir, p string) (string, error) {
|
||||
}
|
||||
return real, nil
|
||||
}
|
||||
|
||||
// ReadIngestFile extracts text from an ingest path already confined by
|
||||
// ResolveIngestPath, reading it through the raw/ root rather than reopening
|
||||
// the path by name (hyperguild#153 item 3): if the file is swapped for a
|
||||
// symlink after the check, the read still cannot leave raw/.
|
||||
func ReadIngestFile(brainDir, resolved string) (string, error) {
|
||||
root, rawPath, err := OpenRawRoot(brainDir)
|
||||
if err != nil {
|
||||
return "", ErrOutsideIngestRoot
|
||||
}
|
||||
defer func() { _ = root.Close() }()
|
||||
realRoot, err := filepath.EvalSymlinks(rawPath)
|
||||
if err != nil {
|
||||
return "", ErrOutsideIngestRoot
|
||||
}
|
||||
rel, err := filepath.Rel(realRoot, resolved)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
return "", ErrOutsideIngestRoot
|
||||
}
|
||||
return extract.TextIn(root, rel)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -28,25 +30,25 @@ var datePrefix = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}-`)
|
||||
// (natural review order). An absent raw/ dir yields an empty slice, not an
|
||||
// error. Only .md files are listed; tunnel-candidate files are skipped.
|
||||
func ListPending(brainDir string) ([]PendingNote, error) {
|
||||
dir := filepath.Join(brainDir, "raw")
|
||||
entries, err := os.ReadDir(dir)
|
||||
root, _, err := OpenRawRoot(brainDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []PendingNote{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read raw dir: %w", err)
|
||||
}
|
||||
defer func() { _ = root.Close() }()
|
||||
entries, err := fs.ReadDir(root.FS(), ".")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read raw dir: %w", err)
|
||||
}
|
||||
|
||||
out := make([]PendingNote, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
|
||||
continue
|
||||
if !e.Type().IsRegular() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
|
||||
continue // directories, and symlinks whose target may be anywhere (#153)
|
||||
}
|
||||
info, statErr := e.Info()
|
||||
if statErr != nil {
|
||||
continue
|
||||
}
|
||||
raw, readErr := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
raw, info, readErr := readRawNote(root, e.Name())
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
@@ -105,12 +107,9 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
||||
}
|
||||
|
||||
src := filepath.Join(brainDir, "raw", base)
|
||||
raw, err := os.ReadFile(src)
|
||||
raw, err := readRawSource(brainDir, base)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("pending note %q does not exist in raw/", base)
|
||||
}
|
||||
return "", fmt.Errorf("read source: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Collision: never silently overwrite an existing promoted note.
|
||||
@@ -180,12 +179,9 @@ func RejectNote(brainDir string, opts RejectOptions) (string, error) {
|
||||
}
|
||||
|
||||
src := filepath.Join(brainDir, "raw", base)
|
||||
raw, err := os.ReadFile(src)
|
||||
raw, err := readRawSource(brainDir, base)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("pending note %q does not exist in raw/", base)
|
||||
}
|
||||
return "", fmt.Errorf("read source: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
dest := filepath.Join(brainDir, "raw", "rejected", base)
|
||||
@@ -224,3 +220,24 @@ func excerpt(s string, n int) string {
|
||||
}
|
||||
return strings.TrimSpace(string(r))
|
||||
}
|
||||
|
||||
// readRawSource reads a pending note for promote/reject through the raw root,
|
||||
// refusing symlinks and a symlinked raw/ (hyperguild#153).
|
||||
func readRawSource(brainDir, base string) ([]byte, error) {
|
||||
root, _, err := OpenRawRoot(brainDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("pending note %q does not exist in raw/", base)
|
||||
}
|
||||
return nil, fmt.Errorf("read source: %w", err)
|
||||
}
|
||||
defer func() { _ = root.Close() }()
|
||||
raw, _, err := readRawNote(root, base)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("pending note %q does not exist in raw/", base)
|
||||
}
|
||||
return nil, fmt.Errorf("read source: %w", err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||
)
|
||||
|
||||
// hyperguild#153 / PR #156: behavioural race tests, so the control does not
|
||||
// rest on lints that list forbidden APIs (round 3: an os.ReadFile or
|
||||
// Root.ReadFile rewrite passed the lint and leaked 19k times in 4s). A
|
||||
// swapper keeps renaming a regular note, a symlink to an outside file, or a
|
||||
// FIFO into raw/r.md while the public readers loop. Any leaked byte fails;
|
||||
// any hang fails.
|
||||
|
||||
const raceSentinel = "SENTINEL-153-RACE"
|
||||
|
||||
func swap(raw, outside string, fifo bool, stop *atomic.Bool) {
|
||||
tgt := filepath.Join(raw, "r.md")
|
||||
for i := 0; !stop.Load(); i++ {
|
||||
tmp := filepath.Join(raw, ".swap")
|
||||
_ = os.Remove(tmp)
|
||||
switch {
|
||||
case i%2 == 0:
|
||||
_ = os.WriteFile(tmp, []byte("# r\n\ninside"), 0o644)
|
||||
case fifo:
|
||||
_ = syscall.Mkfifo(tmp, 0o644)
|
||||
default:
|
||||
_ = os.Symlink(outside, tmp)
|
||||
}
|
||||
_ = os.Rename(tmp, tgt)
|
||||
}
|
||||
}
|
||||
|
||||
func raceBrain(t *testing.T) (brain, raw, outside string) {
|
||||
t.Helper()
|
||||
brain = t.TempDir()
|
||||
raw = filepath.Join(brain, "raw")
|
||||
if err := os.MkdirAll(raw, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside = filepath.Join(t.TempDir(), "secret.md")
|
||||
if err := os.WriteFile(outside, []byte("# s\n\n"+raceSentinel), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(raw, "r.md"), []byte("# r\n\ninside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return brain, raw, outside
|
||||
}
|
||||
|
||||
// readers runs every public raw/ reader once and returns all text they
|
||||
// produced (excerpts, extracted text).
|
||||
func readers(brain, raw string) string {
|
||||
var b strings.Builder
|
||||
if ns, err := api.ListPending(brain); err == nil {
|
||||
for _, n := range ns {
|
||||
b.WriteString(n.Excerpt)
|
||||
}
|
||||
}
|
||||
if s, err := api.ReadIngestFile(brain, filepath.Join(raw, "r.md")); err == nil {
|
||||
b.WriteString(s)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestRawReadersLeakNothingUnderASymlinkSwapRace(t *testing.T) {
|
||||
brain, raw, outside := raceBrain(t)
|
||||
var stop atomic.Bool
|
||||
go swap(raw, outside, false, &stop)
|
||||
defer stop.Store(true)
|
||||
leaks, iters := 0, 0
|
||||
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ {
|
||||
leaks += strings.Count(readers(brain, raw), raceSentinel)
|
||||
}
|
||||
if leaks > 0 {
|
||||
t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters)
|
||||
}
|
||||
t.Logf("%d iterations, 0 leaks", iters)
|
||||
}
|
||||
|
||||
func TestRawReadersDoNotHangUnderAFIFOSwapRace(t *testing.T) {
|
||||
brain, raw, outside := raceBrain(t)
|
||||
var stop atomic.Bool
|
||||
go swap(raw, outside, true, &stop)
|
||||
var iters atomic.Int64
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); {
|
||||
_ = readers(brain, raw)
|
||||
iters.Add(1)
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
stop.Store(true)
|
||||
case <-time.After(6 * time.Second):
|
||||
stop.Store(true)
|
||||
// Unblock the stuck open so the test process can exit.
|
||||
if f, err := os.OpenFile(filepath.Join(raw, "r.md"), os.O_WRONLY|syscall.O_NONBLOCK, 0); err == nil {
|
||||
_ = f.Close()
|
||||
}
|
||||
t.Fatalf("a raw/ reader hung on a swapped-in FIFO after %d iterations", iters.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// PR #156 round 4: the pdf/docmark branch of TextIn copies the file through
|
||||
// the root to a temp file; pointing the extractor back at the raw/ path by
|
||||
// name stayed green because the race tests only used r.md (a stub docmark
|
||||
// then received the outside file's bytes 34k times in 4s). This races a
|
||||
// symlink into raw/r.docx and has a fake docmark count the sentinel in every
|
||||
// document it is sent.
|
||||
func TestDocmarkExtractionLeaksNothingUnderASymlinkSwapRace(t *testing.T) {
|
||||
var seen atomic.Int64
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Params struct {
|
||||
Arguments struct {
|
||||
ContentBase64 string `json:"content_base64"`
|
||||
} `json:"arguments"`
|
||||
} `json:"params"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if b, err := base64.StdEncoding.DecodeString(req.Params.Arguments.ContentBase64); err == nil &&
|
||||
strings.Contains(string(b), raceSentinel) {
|
||||
seen.Add(1)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"converted"}]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("DOCMARK_URL", srv.URL)
|
||||
|
||||
brain, raw, outside := raceBrain(t)
|
||||
tgt := filepath.Join(raw, "r.docx")
|
||||
if err := os.WriteFile(tgt, []byte("inside docx bytes"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stop atomic.Bool
|
||||
go func() {
|
||||
for i := 0; !stop.Load(); i++ {
|
||||
tmp := filepath.Join(raw, ".swapdocx")
|
||||
_ = os.Remove(tmp)
|
||||
if i%2 == 0 {
|
||||
_ = os.WriteFile(tmp, []byte("inside docx bytes"), 0o644)
|
||||
} else {
|
||||
_ = os.Symlink(outside, tmp)
|
||||
}
|
||||
_ = os.Rename(tmp, tgt)
|
||||
}
|
||||
}()
|
||||
defer stop.Store(true)
|
||||
iters := 0
|
||||
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ {
|
||||
_, _ = api.ReadIngestFile(brain, tgt)
|
||||
}
|
||||
if n := seen.Load(); n > 0 {
|
||||
t.Fatalf("docmark received the outside file %d times in %d iterations", n, iters)
|
||||
}
|
||||
t.Logf("%d iterations, 0 leaks to docmark", iters)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||
)
|
||||
|
||||
// hyperguild#153: readers of brain/raw/ followed symlinks. /pending showed the
|
||||
// target's contents and promote would copy it into wiki/ (and brain-sync to
|
||||
// Gitea). raw/ is writable via Syncthing, so a symlink there is reachable
|
||||
// input. Every raw/ reader now treats a symlink as not-a-note.
|
||||
const sentinel153 = "SENTINEL-153-OUTSIDE"
|
||||
|
||||
func rawWithSymlink(t *testing.T) (brain string) {
|
||||
t.Helper()
|
||||
brain = t.TempDir()
|
||||
raw := filepath.Join(brain, "raw")
|
||||
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||
outside := filepath.Join(t.TempDir(), "secret.md")
|
||||
require.NoError(t, os.WriteFile(outside, []byte("---\ncreated_at: 2026-01-01T00:00:00Z\n---\n# s\n\n"+sentinel153), 0o644))
|
||||
require.NoError(t, os.Symlink(outside, filepath.Join(raw, "2026-01-01-leak.md")))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-01-02-real.md"), []byte("# real\n\nreal body"), 0o644))
|
||||
return brain
|
||||
}
|
||||
|
||||
func TestListPendingSkipsSymlinkedNotes(t *testing.T) {
|
||||
notes, err := api.ListPending(rawWithSymlink(t))
|
||||
require.NoError(t, err)
|
||||
var names []string
|
||||
for _, n := range notes {
|
||||
names = append(names, n.Filename)
|
||||
assert.NotContains(t, n.Excerpt, sentinel153)
|
||||
}
|
||||
assert.Equal(t, []string{"2026-01-02-real.md"}, names)
|
||||
}
|
||||
|
||||
func TestPromoteNoteRefusesASymlinkedSource(t *testing.T) {
|
||||
brain := rawWithSymlink(t)
|
||||
_, err := api.PromoteNote(brain, api.PromoteOptions{Filename: "2026-01-01-leak.md", Wing: "testing", Hall: "facts"})
|
||||
require.Error(t, err)
|
||||
_, statErr := os.Stat(filepath.Join(brain, "wiki", "testing", "facts", "leak.md"))
|
||||
assert.True(t, os.IsNotExist(statErr), "nothing may be promoted from a symlink")
|
||||
}
|
||||
|
||||
func TestRejectNoteRefusesASymlinkedSource(t *testing.T) {
|
||||
brain := rawWithSymlink(t)
|
||||
_, err := api.RejectNote(brain, api.RejectOptions{Filename: "2026-01-01-leak.md"})
|
||||
require.Error(t, err)
|
||||
b, _ := os.ReadFile(filepath.Join(brain, "raw", "rejected", "2026-01-01-leak.md"))
|
||||
assert.False(t, strings.Contains(string(b), sentinel153))
|
||||
}
|
||||
|
||||
// Item 4: if raw/ itself is a symlink, containment inside it means nothing.
|
||||
func TestResolvePendingPromoteRejectAndReadIngestRefuseASymlinkedRawRoot(t *testing.T) {
|
||||
brain := t.TempDir()
|
||||
elsewhere := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(elsewhere, "x.md"), []byte("# x\n\n"+sentinel153), 0o644))
|
||||
require.NoError(t, os.Symlink(elsewhere, filepath.Join(brain, "raw")))
|
||||
|
||||
_, err := api.ResolveIngestPath(brain, "x.md")
|
||||
assert.Error(t, err, "ingest must refuse a symlinked raw/")
|
||||
notes, err := api.ListPending(brain)
|
||||
assert.Error(t, err, "pending must refuse a symlinked raw/")
|
||||
assert.Empty(t, notes)
|
||||
_, err = api.PromoteNote(brain, api.PromoteOptions{Filename: "x.md", Wing: "testing", Hall: "facts"})
|
||||
assert.Error(t, err, "promote must refuse a symlinked raw/")
|
||||
_, err = api.RejectNote(brain, api.RejectOptions{Filename: "x.md"})
|
||||
assert.Error(t, err, "reject must refuse a symlinked raw/")
|
||||
text, err := api.ReadIngestFile(brain, filepath.Join(elsewhere, "x.md"))
|
||||
assert.Error(t, err, "ReadIngestFile must refuse a symlinked raw/")
|
||||
assert.NotContains(t, text, sentinel153)
|
||||
// The watcher's refusal is tested in internal/watcher.
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||
)
|
||||
|
||||
// errNotANote is returned for a raw/ entry that is not a regular file — in
|
||||
// particular a symlink, whose target may be anywhere (hyperguild#153).
|
||||
var errNotANote = errors.New("not a regular note file in raw/ (symlinks are refused)")
|
||||
|
||||
// errRawNotDir means brain/raw is not a real directory (e.g. a symlink), so
|
||||
// containment inside it would mean nothing (hyperguild#153 item 4).
|
||||
var errRawNotDir = errors.New("brain/raw is not a real directory")
|
||||
|
||||
// OpenRawRoot opens brain/raw as an os.Root, refusing a raw/ that is itself a
|
||||
// symlink. Reads through the root cannot leave it, even if an entry is
|
||||
// swapped for a symlink between a check and the read. It returns the root
|
||||
// and raw/'s path. A missing raw/ returns an error wrapping os.ErrNotExist.
|
||||
func OpenRawRoot(brainDir string) (*os.Root, string, error) {
|
||||
p := filepath.Join(brainDir, "raw")
|
||||
fi, err := os.Lstat(p)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() {
|
||||
return nil, "", errRawNotDir
|
||||
}
|
||||
r, err := os.OpenRoot(p)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("open raw root: %w", err)
|
||||
}
|
||||
return r, p, nil
|
||||
}
|
||||
|
||||
// readRawNote reads one raw/ entry by basename through the root, refusing
|
||||
// anything that is not a regular file.
|
||||
//
|
||||
// Accepted residual: a HARD link to an outside file is a regular file and is
|
||||
// read. Syncthing syncs contents, not links, so a peer cannot create one;
|
||||
// fs.protected_hardlinks=1 and the pod's secrets live on a different
|
||||
// filesystem from the brain volume, so a local caller cannot link them in.
|
||||
// Refusing Nlink > 1 is the next step if either assumption changes.
|
||||
func readRawNote(r *os.Root, base string) ([]byte, os.FileInfo, error) {
|
||||
fi, err := r.Lstat(base)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !fi.Mode().IsRegular() {
|
||||
return nil, nil, errNotANote
|
||||
}
|
||||
f, _, err := extract.OpenRegular(r, base)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
b, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return b, fi, nil
|
||||
}
|
||||
Reference in New Issue
Block a user