This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The per-algorithm CID fields (`cid_sha2-256`, `cid_midhash256`, …) and the
|
||||
// stored `canonical_cid` scalar were removed (METADATA_KEYS.md §14.13). They
|
||||
// must be rejected at the write boundary rather than stored.
|
||||
//
|
||||
// Why a hard 400 and not a silent rewrite: the reverse-index hook
|
||||
// (maybeAddAliasFromFieldLocked) only registers an alias for a field named
|
||||
// `cids/<cid>`. A `cid_*` field was therefore written verbatim and never
|
||||
// indexed — the record silently became unresolvable by that CID. A loud
|
||||
// failure is strictly better than a record that looks correct and cannot be
|
||||
// found.
|
||||
func TestRejectDeprecatedField(t *testing.T) {
|
||||
rejected := []string{
|
||||
"cid_midhash256",
|
||||
"cid_sha2-256",
|
||||
"cid_btih_v2",
|
||||
"cid_crc32",
|
||||
"midhash256",
|
||||
"canonical_cid",
|
||||
}
|
||||
for _, f := range rejected {
|
||||
if !rejectDeprecatedField(f) {
|
||||
t.Errorf("rejectDeprecatedField(%q) = false, want true (deprecated shape)", f)
|
||||
}
|
||||
}
|
||||
|
||||
accepted := []string{
|
||||
"cids/bagacbabaec7v3fu2ygzh3e2sybg3fbzmisry2hbtpmck6vx3yftea6vzq35r4",
|
||||
"filePath",
|
||||
"title",
|
||||
"fileinfo/duration",
|
||||
"tmdb/poster",
|
||||
// Near-misses that must NOT be caught: the prefix is `cid_`, with an
|
||||
// underscore. A field merely starting with "cid" is fine.
|
||||
"cids",
|
||||
"cidrank",
|
||||
}
|
||||
for _, f := range accepted {
|
||||
if rejectDeprecatedField(f) {
|
||||
t.Errorf("rejectDeprecatedField(%q) = true, want false (legitimate field)", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFields_Rejects400WithSlug(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
blocked := validateFields(rr, map[string]string{
|
||||
"title": "Inception",
|
||||
"cid_midhash256": "bagacbabaec7v3fu2ygzh3e2sybg3fbzmisry2hbtpmck6vx3yftea6vzq35r4",
|
||||
})
|
||||
|
||||
if !blocked {
|
||||
t.Fatal("validateFields returned false for a payload carrying cid_midhash256; the write would have been stored unindexed")
|
||||
}
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", rr.Code, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("response is not JSON: %v", err)
|
||||
}
|
||||
if got := body["error"]; got != ErrDeprecatedField {
|
||||
t.Errorf("error slug = %v, want %q", got, ErrDeprecatedField)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFields_AllowsCidsKeyset(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
blocked := validateFields(rr, map[string]string{
|
||||
"filePath": "/watch/Inception.mkv",
|
||||
"cids/bagacbabaec7v3fu2ygzh3e2sybg3fbzmisry2hbtpmck6vx3yftea6vzq35r4": "true",
|
||||
"cids/baejbeibku6ua2l6r5olnggx4pto2sii2s5jeeawgh4cbkhlbmp52gudbua": "true",
|
||||
})
|
||||
if blocked {
|
||||
t.Fatalf("validateFields rejected a legitimate cids/ key-set write: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base32"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -84,8 +85,45 @@ const (
|
||||
ErrSchemaViolation = "schema_violation"
|
||||
ErrStorageUnavailable = "storage_unavailable"
|
||||
ErrInternal = "internal"
|
||||
ErrDeprecatedField = "deprecated_field"
|
||||
)
|
||||
|
||||
// rejectDeprecatedField reports whether a field name is one of the removed
|
||||
// per-algorithm CID shapes (`cid_sha2-256`, `cid_midhash256`, `midhash256`, …).
|
||||
//
|
||||
// This is a tripwire, not a compatibility shim. Such a field used to be
|
||||
// accepted and written verbatim — but the reverse-index hook
|
||||
// (`maybeAddAliasFromFieldLocked`) only registers aliases for fields named
|
||||
// `cids/<cid>`, so a `cid_*` field was stored and then NEVER INDEXED: the
|
||||
// record silently became unresolvable by that CID. Failing the write loudly is
|
||||
// strictly better than a record that looks fine and cannot be found.
|
||||
//
|
||||
// Writers must emit the bare-CID key-set instead: `cids/<bareCid>` = "true".
|
||||
// See METADATA_KEYS.md §2/§14.13.
|
||||
func rejectDeprecatedField(field string) bool {
|
||||
return strings.HasPrefix(field, "cid_") ||
|
||||
field == "midhash256" ||
|
||||
field == "canonical_cid"
|
||||
}
|
||||
|
||||
// validateFields rejects a write carrying any deprecated CID field. Returns
|
||||
// true if the request was rejected (the handler must return immediately).
|
||||
func validateFields(w http.ResponseWriter, metadata map[string]string) bool {
|
||||
for field := range metadata {
|
||||
if rejectDeprecatedField(field) {
|
||||
writeErrorSlug(w, http.StatusBadRequest, ErrDeprecatedField,
|
||||
fmt.Sprintf("field %q is a removed per-algorithm CID shape; "+
|
||||
"write the bare-CID key-set instead: \"cids/<bareCid>\": \"true\" "+
|
||||
"(a cid_* field would be stored but never indexed, leaving the "+
|
||||
"record unresolvable by that CID). See METADATA_KEYS.md §2/§14.13.",
|
||||
field),
|
||||
false)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// defaultSlugFor maps an HTTP status code to the most likely error slug.
|
||||
// Handlers that want to override (e.g. /api/meta/{cid} → "unknown_cid"
|
||||
// instead of "unknown_root" on 404) call writeErrorSlug directly.
|
||||
@@ -255,6 +293,10 @@ func (s *Server) handlePutMeta(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if validateFields(w, metadata) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.storage.SetMetadataFlat(hashID, metadata); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -620,6 +662,10 @@ func (s *Server) handlePatchMeta(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if validateFields(w, metadata) {
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.storage.MergeMetadataFlat(hashID, metadata)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
@@ -697,6 +743,10 @@ func (s *Server) handlePutProperty(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if validateFields(w, map[string]string{key: ""}) {
|
||||
return
|
||||
}
|
||||
|
||||
if !s.storage.IsConnected() {
|
||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||
return
|
||||
@@ -790,6 +840,10 @@ func (s *Server) handleAddToSet(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if validateFields(w, map[string]string{key: ""}) {
|
||||
return
|
||||
}
|
||||
|
||||
if !s.storage.IsConnected() {
|
||||
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||
return
|
||||
|
||||
@@ -193,6 +193,9 @@ func (s *Server) handleGetMetadataByHashId(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
// Same resolution as the PUT below: callers hold a CID, not the root.
|
||||
hashID = s.storage.ResolveRoot(hashID)
|
||||
|
||||
metadata, err := s.storage.GetMetadataFlat(hashID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
@@ -227,6 +230,17 @@ func (s *Server) handleUpdateMetadataByHashId(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
// The {hashId} in the URL is whatever CID the caller knows the file by —
|
||||
// the gateway's auto-store keys this PUT by the hash it just computed
|
||||
// (dispatch.rs → store_metadata_only), which for a usenet→IPFS fetch is
|
||||
// the dag-pb root, not the nzb-release cid the record was minted under.
|
||||
// Without resolving, the write lands on file:<thatCid>/* and forks a
|
||||
// second root for a file that already has one: a full duplicate record,
|
||||
// cross-linked and separately indexed, which every downstream ingest then
|
||||
// sees as two files. Unknown CIDs pass through unchanged, so a genuinely
|
||||
// new gateway record still mints its root at the hash.
|
||||
hashID = s.storage.ResolveRoot(hashID)
|
||||
|
||||
var metadata map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&metadata); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
@@ -263,6 +277,12 @@ func (s *Server) handleDeleteMetadataByHashId(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
// meta-gateway retracts a dropped hit by whichever CID it holds
|
||||
// (meta_core.rs `delete`, which documents this resolution as a
|
||||
// precondition). Without it, deleting by a non-root CID is a silent
|
||||
// no-op and the retracted record lingers.
|
||||
hashID = s.storage.ResolveRoot(hashID)
|
||||
|
||||
deletedCount, err := s.storage.DeleteMetadata(hashID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
|
||||
Reference in New Issue
Block a user