Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- The reserved `_filesql_` table prefix is reserved in both directions. v0.43.0 began hiding those names from `DumpDatabase` and from the table listings this package returns, but still loaded a file called `_filesql_report.csv` into a table under the prefix: that table existed and answered queries while being absent from every listing and from any dump, so its rows were silently left out of an export. An input that would land in the namespace is now refused with `ErrReservedTableName`, naming the table and the prefix, which is how SQLite answers for its own `sqlite_` prefix. The comparison folds ASCII case, because the LIKE that hides these names does: `_FILESQL_report` loaded and then vanished the same way. A name that merely resembles the prefix, such as `filesql_report`, is a normal table.

## [0.43.0] - 2026-08-09

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,7 @@ Control records are derived, not stored: writing an ACH file rebuilds each batch

Writing needs the source file. Neither format can be rebuilt from its SQL tables alone: fields no table exposes exist only in the original. `DumpACH` and `DumpFedWire` therefore read the file the tables were loaded from and apply the edits to it, and fail with `ErrSourceUnavailable`, naming the file, when it is gone or unreadable. A database loaded from an `io.Reader` has no such file: parse the reader with `parser/ach` or `parser/wire` and pass the result to `DumpACHWithTableSet` or `DumpFedWireWithTableSet`.

Each database records its own source, so two databases loaded from files that share a name in different directories each export their own data. The record lives in a reserved table named `_filesql_sources`. Table names beginning with `_filesql_` belong to this package; they are hidden from `DumpDatabase` and from the table listings filesql returns, and a caller should not create them.
Each database records its own source, so two databases loaded from files that share a name in different directories each export their own data. The record lives in a reserved table named `_filesql_sources`. Table names beginning with `_filesql_` belong to this package: they are hidden from `DumpDatabase` and from the table listings filesql returns, and an input that would load into one is refused with `ErrReservedTableName`, the way SQLite refuses its own `sqlite_` prefix.

## Examples

Expand Down
7 changes: 6 additions & 1 deletion ach.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ package filesql
// table exposes exist only in the original, so exporting reads the file the
// tables were loaded from and applies the edits to it. That file must still
// exist and be readable when the export runs; the path is recorded in the
// database, in the reserved table _filesql_sources.
// database, in the reserved table _filesql_sources. Names beginning with
// _filesql_ belong to this package, and an input that would load into one is
// refused.
//
// A database loaded from an io.Reader has no source file, so DumpACH cannot
// export it. Parse the reader with parser/ach and pass the result to
Expand Down Expand Up @@ -320,6 +322,9 @@ func IsACHBaseTableName(tableName string) (baseName string, isACH bool) {
// streamACHFileToDatabase streams an ACH file to the database as multiple tables
func streamACHFileToDatabase(ctx context.Context, db DBTX, reader io.Reader, filePath, sourcePath string, replaceExisting bool) error {
baseTableName := sanitizeTableName(tableFromFilePath(filePath))
if err := validateTableName(baseTableName); err != nil {
return err
}

tables, _, err := parseACHFile(reader, baseTableName)
if err != nil {
Expand Down
4 changes: 4 additions & 0 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ var (
// ErrDuplicateTable indicates a table with the same name already exists.
ErrDuplicateTable = errors.New("filesql: duplicate table name")

// ErrReservedTableName indicates an input would be loaded into a table whose
// name this package reserves for its own bookkeeping.
ErrReservedTableName = errors.New("filesql: reserved table name")

// ErrNilInput indicates a required input parameter is nil.
ErrNilInput = errors.New("filesql: nil input")

Expand Down
44 changes: 39 additions & 5 deletions source_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

achconv "github.com/nao1215/filesql/parser/ach"
wireconv "github.com/nao1215/filesql/parser/wire"
Expand All @@ -22,11 +23,14 @@ import (
// rolled-back load discard the metadata with the tables.
const sourceTableName = "_filesql_sources"

// sourceTableLikePattern matches the reserved _filesql_ prefix in a LIKE
// clause, so this package's own bookkeeping tables stay hidden from callers and
// from dumps. The underscores are escaped because LIKE reads a bare underscore
// as a wildcard, which would also hide a caller's table named, say,
// xfilesqly_totals.
// sourceTablePrefix is reserved for this package's own bookkeeping tables.
// A caller's table cannot occupy it; see validateTableName.
const sourceTablePrefix = "_filesql_"

// sourceTableLikePattern matches sourceTablePrefix in a LIKE clause, so those
// tables stay hidden from callers and from dumps. The underscores are escaped
// because LIKE reads a bare underscore as a wildcard, which would also hide a
// caller's table named, say, xfilesqly_totals.
const sourceTableLikePattern = `\_filesql\_%`

// sourceFormat names the reader that can rebuild a file's structure.
Expand Down Expand Up @@ -160,3 +164,33 @@ func wireTableSetForDump(ctx context.Context, db *sql.DB, baseTableName string)
}
return tableSet, nil
}

// validateTableName refuses a table name in this package's reserved namespace.
//
// The prefix is only reserved if nothing else can occupy it. Hiding _filesql_
// tables from dumps and listings while still loading a file named
// _filesql_report.csv into one would make that file's table exist and be
// queryable but absent from everything that enumerates tables — the kind of
// half-present table a caller cannot debug. SQLite answers the same way for its
// own sqlite_ prefix, so the rule and its message follow that precedent.
//
// The comparison ignores ASCII case because the LIKE that hides these tables
// does: without that, _FILESQL_report loaded and then vanished from every
// listing, which is the state this check exists to prevent.
func validateTableName(tableName string) error {
if hasReservedPrefix(tableName) {
return fmt.Errorf("%w: %q begins with %s, which this package keeps for its own tables; a table under it would be hidden from dumps and from table listings",
ErrReservedTableName, tableName, sourceTablePrefix)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return nil
}

// hasReservedPrefix reports whether tableName starts with sourceTablePrefix,
// folding ASCII case only. SQLite's LIKE folds exactly that much, so matching it
// here keeps the set of refused names equal to the set of hidden ones.
func hasReservedPrefix(tableName string) bool {
if len(tableName) < len(sourceTablePrefix) {
return false
}
return strings.EqualFold(tableName[:len(sourceTablePrefix)], sourceTablePrefix)
}
49 changes: 49 additions & 0 deletions source_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,55 @@ func TestACHAndFedwireShareABaseNameInOneDatabase(t *testing.T) {
assert.Contains(t, readFileString(t, filepath.Join(outDir, "out.fed")), "{1500}")
}

// TestReservedTableNameIsRefused pins that the reserved prefix is reserved in
// both directions. Hiding _filesql_ tables from dumps and listings while still
// loading a file named _filesql_report.csv into one would leave that table
// queryable but absent from everything that enumerates tables, so the load is
// refused instead, the way SQLite refuses its own sqlite_ prefix.
func TestReservedTableNameIsRefused(t *testing.T) {
t.Parallel()

ctx := context.Background()
dir := t.TempDir()

csvPath := filepath.Join(dir, "_filesql_report.csv")
require.NoError(t, os.WriteFile(csvPath, []byte("id,v\n1,a\n"), 0o600))

_, err := OpenContext(ctx, csvPath)
require.Error(t, err)
assert.ErrorIs(t, err, ErrReservedTableName)
assert.Contains(t, err.Error(), "_filesql_")

// A reader names its own table, so it can reach the namespace too.
builder, err := NewBuilder().
AddReader(strings.NewReader("id\n1\n"), "_filesql_sources", FileTypeCSV).
Build(ctx)
require.NoError(t, err)
_, err = builder.Open(ctx)
require.Error(t, err)
assert.ErrorIs(t, err, ErrReservedTableName)

// The LIKE that hides these tables folds ASCII case, so the refusal must
// too: an upper-case spelling used to load and then vanish from every
// listing while still answering queries.
upperPath := filepath.Join(dir, "_FILESQL_report.csv")
require.NoError(t, os.WriteFile(upperPath, []byte("id,v\n1,a\n"), 0o600))
_, err = OpenContext(ctx, upperPath)
require.Error(t, err)
assert.ErrorIs(t, err, ErrReservedTableName)

// A name that merely resembles the prefix is a normal table.
okPath := filepath.Join(dir, "filesql_report.csv")
require.NoError(t, os.WriteFile(okPath, []byte("id,v\n1,a\n"), 0o600))
db, err := OpenContext(ctx, okPath)
require.NoError(t, err)
defer db.Close()

names, err := getSQLiteTableNames(db)
require.NoError(t, err)
assert.Contains(t, names, "filesql_report")
}

// TestSourceMetadataRolledBackWithTransaction pins that metadata written by a
// load shares the fate of the tables it describes. A rolled-back load must not
// leave a row pointing at tables that do not exist.
Expand Down
9 changes: 9 additions & 0 deletions stream_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,10 @@ func (sp *streamProcessor) streamReaderToDatabase(ctx context.Context, db DBTX,
return streamWireFileToDatabase(ctx, db, input.reader, input.tableName+extFED, "", sp.replaceExisting)
}

if err := validateTableName(input.tableName); err != nil {
return err
}

// Reader should already be validated at Build time, but ensure it's buffered
if _, ok := input.reader.(*bufio.Reader); !ok {
input.reader = bufio.NewReader(input.reader)
Expand Down Expand Up @@ -754,6 +758,11 @@ func (sp *streamProcessor) streamXLSXFileToDatabase(ctx context.Context, db DBTX
sp.logger.Error("sheet names collide", "path", filePath, "error", err)
return err
}
for _, tableName := range sheetTables {
if err := validateTableName(tableName); err != nil {
return err
}
}

// Process each sheet as a separate table
for i, sheetName := range sheetNames {
Expand Down
3 changes: 3 additions & 0 deletions wire.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ func IsWireBaseTableName(tableName string) (baseName string, isWire bool) {
// streamWireFileToDatabase streams a Fedwire file to the database as a single table.
func streamWireFileToDatabase(ctx context.Context, db DBTX, reader io.Reader, filePath, sourcePath string, replaceExisting bool) error {
baseTableName := sanitizeTableName(tableFromFilePath(filePath))
if err := validateTableName(baseTableName); err != nil {
return err
}

tables, _, err := parseFedWireFile(reader, baseTableName)
if err != nil {
Expand Down