Advance N-023 through N-026 audited parity

This commit is contained in:
mes123456
2026-08-12 15:23:35 -04:00
parent b3cefaeec5
commit 86136139e2
30 changed files with 5573 additions and 66 deletions

View File

@@ -23,6 +23,7 @@
#include "BLI_endian_defines.h" #include "BLI_endian_defines.h"
#include "BLI_filereader.h" #include "BLI_filereader.h"
#include "BLI_string_utf8.h"
#include "BLI_utildefines.h" #include "BLI_utildefines.h"
#include "BLO_core_bhead.hh" #include "BLO_core_bhead.hh"
@@ -36,6 +37,9 @@
#include "DNA_node_types.h" #include "DNA_node_types.h"
#include "DNA_sdna_types.h" #include "DNA_sdna_types.h"
#include "DNA_sequence_types.h" #include "DNA_sequence_types.h"
#include "DNA_screen_types.h"
#include "DNA_space_enums.h"
#include "DNA_workspace_types.h"
namespace { namespace {
@@ -44,6 +48,73 @@ using namespace blender;
constexpr uint64_t MAX_BLEND_BLOCK_BYTES = UINT64_C(1024) * 1024 * 1024; constexpr uint64_t MAX_BLEND_BLOCK_BYTES = UINT64_C(1024) * 1024 * 1024;
constexpr size_t MAX_NON_MESH_POINTS = 1000000; constexpr size_t MAX_NON_MESH_POINTS = 1000000;
constexpr size_t MAX_SCRIPT_SOURCE_BYTES = 1024 * 1024;
uint32_t rotate_right(const uint32_t value, const uint32_t bits)
{
return (value >> bits) | (value << (32 - bits));
}
std::string sha256_hex(const std::string &value)
{
static constexpr std::array<uint32_t, 64> round_constants = {
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1,
0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a,
0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2};
std::array<uint32_t, 8> digest = {
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19};
std::vector<uint8_t> message(value.begin(), value.end());
const uint64_t bit_length = uint64_t(message.size()) * 8;
message.push_back(0x80);
while (message.size() % 64 != 56) message.push_back(0);
for (int shift = 56; shift >= 0; shift -= 8) message.push_back(uint8_t(bit_length >> shift));
for (size_t offset = 0; offset < message.size(); offset += 64) {
std::array<uint32_t, 64> words{};
for (size_t index = 0; index < 16; index++) {
const size_t byte = offset + index * 4;
words[index] = uint32_t(message[byte]) << 24 | uint32_t(message[byte + 1]) << 16 |
uint32_t(message[byte + 2]) << 8 | uint32_t(message[byte + 3]);
}
for (size_t index = 16; index < words.size(); index++) {
const uint32_t s0 = rotate_right(words[index - 15], 7) ^
rotate_right(words[index - 15], 18) ^ (words[index - 15] >> 3);
const uint32_t s1 = rotate_right(words[index - 2], 17) ^
rotate_right(words[index - 2], 19) ^ (words[index - 2] >> 10);
words[index] = words[index - 16] + s0 + words[index - 7] + s1;
}
uint32_t a = digest[0], b = digest[1], c = digest[2], d = digest[3];
uint32_t e = digest[4], f = digest[5], g = digest[6], h = digest[7];
for (size_t index = 0; index < words.size(); index++) {
const uint32_t sum1 = rotate_right(e, 6) ^ rotate_right(e, 11) ^ rotate_right(e, 25);
const uint32_t choice = (e & f) ^ (~e & g);
const uint32_t temporary1 = h + sum1 + choice + round_constants[index] + words[index];
const uint32_t sum0 = rotate_right(a, 2) ^ rotate_right(a, 13) ^ rotate_right(a, 22);
const uint32_t majority = (a & b) ^ (a & c) ^ (b & c);
const uint32_t temporary2 = sum0 + majority;
h = g; g = f; f = e; e = d + temporary1;
d = c; c = b; b = a; a = temporary1 + temporary2;
}
digest[0] += a; digest[1] += b; digest[2] += c; digest[3] += d;
digest[4] += e; digest[5] += f; digest[6] += g; digest[7] += h;
}
static constexpr char hex[] = "0123456789abcdef";
std::string result(64, '0');
for (size_t index = 0; index < digest.size(); index++) {
for (size_t nibble = 0; nibble < 8; nibble++) {
result[index * 8 + nibble] = hex[(digest[index] >> (28 - nibble * 4)) & 0xf];
}
}
return result;
}
struct FileReaderDeleter { struct FileReaderDeleter {
void operator()(FileReader *reader) const void operator()(FileReader *reader) const
@@ -465,6 +536,9 @@ std::string id_prefix(const std::string &type_name)
if (type_name == "Text") return "text"; if (type_name == "Text") return "text";
if (type_name == "VFont") return "vfont"; if (type_name == "VFont") return "vfont";
if (type_name == "Mask") return "mask"; if (type_name == "Mask") return "mask";
if (type_name == "Library") return "library";
if (type_name == "WorkSpace") return "workspace";
if (type_name == "bScreen") return "screen";
return "datablock"; return "datablock";
} }
@@ -477,7 +551,8 @@ bool is_exported_id_type(const std::string &type_name)
type_name == "bArmature" || type_name == "World" || type_name == "Curve" || type_name == "bArmature" || type_name == "World" || type_name == "Curve" ||
type_name == "MetaBall" || type_name == "PointCloud" || type_name == "Curves" || type_name == "MetaBall" || type_name == "PointCloud" || type_name == "Curves" ||
type_name == "Volume" || type_name == "GreasePencil" || type_name == "Text" || type_name == "Volume" || type_name == "GreasePencil" || type_name == "Text" ||
type_name == "VFont" || type_name == "Mask"; type_name == "VFont" || type_name == "Mask" || type_name == "Library" ||
type_name == "WorkSpace" || type_name == "bScreen";
} }
std::string unique_id(const std::string &prefix, std::string unique_id(const std::string &prefix,
@@ -1050,6 +1125,178 @@ std::optional<json> mask_from_record(const ParsedBlend &blend,
return json{{"id", mask_id}, {"name", mask_name}, {"layers", std::move(layers)}}; return json{{"id", mask_id}, {"name", mask_name}, {"layers", std::move(layers)}};
} }
const char *editor_type_from_space(const int64_t space_type)
{
switch (space_type) {
case SPACE_VIEW3D: return "VIEW_3D";
case SPACE_OUTLINER: return "OUTLINER";
case SPACE_PROPERTIES: return "PROPERTIES";
case SPACE_IMAGE: return "UV_IMAGE";
case SPACE_NODE: return "NODE";
case SPACE_GRAPH: return "GRAPH";
case SPACE_ACTION: return "DOPE_SHEET";
case SPACE_NLA: return "NLA";
case SPACE_SPREADSHEET: return "SPREADSHEET";
case SPACE_SEQ: return "SEQUENCER";
case SPACE_CLIP: return "CLIP";
default: return nullptr;
}
}
const char *editor_region_kind(const int64_t region_type)
{
if (region_type == RGN_TYPE_WINDOW || region_type == RGN_TYPE_PREVIEW) return "MAIN";
if (region_type == RGN_TYPE_HEADER || region_type == RGN_TYPE_TOOL_HEADER ||
region_type == RGN_TYPE_ASSET_SHELF_HEADER || region_type == RGN_TYPE_SCRUBBING)
{
return "HEADER";
}
if (region_type == RGN_TYPE_TOOLS || region_type == RGN_TYPE_TOOL_PROPS) return "TOOLBAR";
if (region_type == RGN_TYPE_UI || region_type == RGN_TYPE_CHANNELS ||
region_type == RGN_TYPE_NAV_BAR || region_type == RGN_TYPE_ASSET_SHELF)
{
return "SIDEBAR";
}
if (region_type == RGN_TYPE_FOOTER || region_type == RGN_TYPE_EXECUTE) return "FOOTER";
return nullptr;
}
std::optional<json> editor_workflow_from_records(
const ParsedBlend &blend,
const std::vector<IdRecord> &records,
const uint64_t revision,
const std::string &active_object_id)
{
json workspaces = json::array();
size_t area_count = 0;
size_t region_count = 0;
for (const IdRecord &record : records) {
if (record.type_name != "WorkSpace" || workspaces.size() >= 64) continue;
const ElementRef workspace{record.block, 0, record.type_name};
const std::vector<ElementRef> layouts = linked_list_elements(blend, workspace, "layouts");
if (layouts.empty()) continue;
const std::optional<uint64_t> screen_pointer = read_pointer(*blend.sdna, layouts.front(), "screen");
const std::optional<ElementRef> screen = screen_pointer ? element_for_pointer(blend, *screen_pointer) :
std::nullopt;
if (!screen) continue;
const std::vector<ElementRef> source_areas = linked_list_elements(blend, *screen, "areabase");
if (source_areas.empty() || area_count > 1024 - source_areas.size()) continue;
struct AreaBounds {
int x_min;
int y_min;
int x_max;
int y_max;
};
std::vector<AreaBounds> bounds;
bounds.reserve(source_areas.size());
bool valid = true;
int screen_x_min = std::numeric_limits<int>::max();
int screen_y_min = std::numeric_limits<int>::max();
int screen_x_max = std::numeric_limits<int>::min();
int screen_y_max = std::numeric_limits<int>::min();
for (const ElementRef &area : source_areas) {
if (editor_type_from_space(read_integer(*blend.sdna, area, "spacetype").value_or(-1)) == nullptr) {
valid = false;
break;
}
const std::optional<ElementRef> total_rect = embedded_element(*blend.sdna, area, "totrct");
if (!total_rect) {
valid = false;
break;
}
const std::vector<int64_t> area_rect = {
read_integer(*blend.sdna, *total_rect, "xmin").value_or(0),
read_integer(*blend.sdna, *total_rect, "ymin").value_or(0),
read_integer(*blend.sdna, *total_rect, "xmax").value_or(0),
read_integer(*blend.sdna, *total_rect, "ymax").value_or(0)};
if (area_rect[2] <= area_rect[0] || area_rect[3] <= area_rect[1]) {
valid = false;
break;
}
bounds.push_back({int(area_rect[0]), int(area_rect[1]), int(area_rect[2]), int(area_rect[3])});
screen_x_min = std::min(screen_x_min, int(area_rect[0]));
screen_y_min = std::min(screen_y_min, int(area_rect[1]));
screen_x_max = std::max(screen_x_max, int(area_rect[2]));
screen_y_max = std::max(screen_y_max, int(area_rect[3]));
}
const int screen_width = screen_x_max - screen_x_min;
const int screen_height = screen_y_max - screen_y_min;
if (!valid || screen_width <= 0 || screen_height <= 0) continue;
json areas = json::array();
size_t workspace_region_count = 0;
for (size_t area_index = 0; area_index < source_areas.size(); area_index++) {
const ElementRef &area = source_areas[area_index];
const char *editor = editor_type_from_space(
read_integer(*blend.sdna, area, "spacetype").value_or(-1));
json regions = json::array();
bool has_main_region = false;
for (const ElementRef &region : linked_list_elements(blend, area, "regionbase")) {
const char *kind = editor_region_kind(
read_integer(*blend.sdna, region, "regiontype").value_or(-1));
if (kind == nullptr) continue;
if (++workspace_region_count > 4096 - region_count) {
valid = false;
break;
}
const int64_t flags = read_integer(*blend.sdna, region, "flag").value_or(0);
regions.push_back({{"id", record.id + ":area:" + std::to_string(area_index) +
":region:" + std::to_string(regions.size())},
{"kind", kind},
{"visible", (flags & RGN_FLAG_HIDDEN) == 0}});
has_main_region |= std::strcmp(kind, "MAIN") == 0;
}
if (!valid || regions.empty() || !has_main_region) {
valid = false;
break;
}
const AreaBounds &rect = bounds[area_index];
const double x = double(rect.x_min - screen_x_min) / screen_width;
const double y = double(rect.y_min - screen_y_min) / screen_height;
const double width = rect.x_max == screen_x_max ? 1.0 - x :
double(rect.x_max - rect.x_min) / screen_width;
const double height = rect.y_max == screen_y_max ? 1.0 - y :
double(rect.y_max - rect.y_min) / screen_height;
areas.push_back({{"id", record.id + ":area:" + std::to_string(area_index)},
{"editor", editor},
{"regions", std::move(regions)},
{"rect",
{{"x", x}, {"y", y}, {"width", width}, {"height", height}}},
{"maximized", false}});
}
if (!valid || areas.empty()) continue;
area_count += areas.size();
region_count += workspace_region_count;
workspaces.push_back({{"id", record.id},
{"name", record.name},
{"areas", std::move(areas)},
{"activeAreaId", record.id + ":area:0"},
{"revision", revision}});
}
if (workspaces.empty()) return std::nullopt;
const json &workspace = workspaces.front();
const json &active_area = workspace["areas"].front();
const std::string workspace_id = workspace.value("id", "");
const std::string active_area_id = active_area.value("id", "");
const std::string active_editor = active_area.value("editor", "");
json selection = json::array();
if (!active_object_id.empty()) selection.push_back(active_object_id);
return json{{"schemaVersion", 1},
{"workspaces", std::move(workspaces)},
{"context",
{{"workspaceId", workspace_id},
{"activeAreaId", active_area_id},
{"activeEditor", active_editor},
{"mode", "OBJECT"},
{"activeObjectId", active_object_id.empty() ? json(nullptr) : json(active_object_id)},
{"selection", std::move(selection)},
{"viewLayer", "ViewLayer"},
{"pinnedData", nullptr},
{"revision", revision}}},
{"keymaps", json::array()}};
}
struct RawBlockSpan { struct RawBlockSpan {
const BlendBlock *block = nullptr; const BlendBlock *block = nullptr;
size_t offset = 0; size_t offset = 0;
@@ -1670,6 +1917,64 @@ std::string read_raw_string(const ParsedBlend &blend,
return std::string(data, strnlen(data, available)); return std::string(data, strnlen(data, available));
} }
std::optional<json> script_source_from_record(const ParsedBlend &blend,
const ElementRef &element,
const IdRecord &record)
{
const std::vector<ElementRef> lines = linked_list_elements(blend, element, "lines");
if (lines.empty() || lines.size() > 65536) return std::nullopt;
std::string source;
for (size_t index = 0; index < lines.size(); index++) {
const int64_t length = read_integer(*blend.sdna, lines[index], "len").value_or(-1);
const std::optional<uint64_t> pointer = read_pointer(*blend.sdna, lines[index], "line");
const size_t separator_bytes = index > 0 ? 1 : 0;
if (length < 0 || length > int64_t(MAX_SCRIPT_SOURCE_BYTES) || !pointer ||
size_t(length) > MAX_SCRIPT_SOURCE_BYTES - separator_bytes ||
source.size() > MAX_SCRIPT_SOURCE_BYTES - separator_bytes - size_t(length))
{
return std::nullopt;
}
const std::vector<uint8_t> bytes = read_raw_byte_array(blend, *pointer, size_t(length));
if (bytes.size() != size_t(length) ||
std::find(bytes.begin(), bytes.end(), uint8_t(0)) != bytes.end() ||
(!bytes.empty() &&
BLI_str_utf8_invalid_byte(reinterpret_cast<const char *>(bytes.data()), bytes.size()) != -1))
{
return std::nullopt;
}
if (index > 0) source.push_back('\n');
source.append(reinterpret_cast<const char *>(bytes.data()), bytes.size());
}
const std::optional<uint64_t> path_pointer = read_pointer(*blend.sdna, element, "filepath");
const std::string source_path = path_pointer ? read_raw_string(blend, *path_pointer, 2049) :
std::string();
if (!source_path.empty()) {
const bool project_path = source_path.rfind("//", 0) == 0 && source_path.size() > 2 &&
source_path.size() <= 2050 &&
source_path.find('\\') == std::string::npos &&
source_path.find('%') == std::string::npos &&
source_path.find("/../") == std::string::npos &&
source_path.find("//", 2) == std::string::npos;
if (!project_path) return std::nullopt;
}
const int64_t flags = read_integer(*blend.sdna, element, "flags").value_or(0);
const bool module_autorun_requested = (flags & (1 << 4)) != 0;
json result = {{"id", record.id},
{"name", record.name},
{"source", source},
{"sourceSha256", sha256_hex(source)},
{"byteLength", source.size()},
{"lineCount", lines.size()},
{"internal", source_path.empty()},
{"moduleAutorunRequested", module_autorun_requested},
{"readOnly", true},
{"executionStatus", "BLOCKED"},
{"errorCode", module_autorun_requested ? "SCRIPT_POLICY_DENIED" :
"SCRIPT_SANDBOX_UNAVAILABLE"}};
if (!source_path.empty()) result["sourcePath"] = source_path;
return result;
}
std::vector<uint64_t> read_raw_pointer_array(const ParsedBlend &blend, std::vector<uint64_t> read_raw_pointer_array(const ParsedBlend &blend,
const uint64_t pointer, const uint64_t pointer,
const size_t count) const size_t count)
@@ -1829,8 +2134,7 @@ json non_mesh_data_from_record(const ParsedBlend &blend,
record.type_name == "MetaBall" ? "METABALL" : record.type_name == "MetaBall" ? "METABALL" :
record.type_name == "PointCloud" ? "POINT_CLOUD" : record.type_name == "PointCloud" ? "POINT_CLOUD" :
record.type_name == "Curves" ? "CURVES" : record.type_name == "Curves" ? "CURVES" :
record.type_name == "Volume" ? "VOLUME" : record.type_name == "Volume" ? "VOLUME" : "CURVE";
record.type_name == "Text" ? "FONT" : "CURVE";
json data = { {"id", record.id}, json data = { {"id", record.id},
{"name", record.name}, {"name", record.name},
{"type", type_name}, {"type", type_name},
@@ -2122,19 +2426,6 @@ json non_mesh_data_from_record(const ParsedBlend &blend,
"NON_MESH_DATA_UNSUPPORTED"; "NON_MESH_DATA_UNSUPPORTED";
} }
} }
else if (record.type_name == "Text") {
json lines = json::array();
size_t total_chars = 0;
for (const ElementRef &line : linked_list_elements(blend, element, "lines")) {
const std::optional<uint64_t> pointer = read_pointer(*blend.sdna, line, "line");
const std::string value = pointer ? read_raw_string(blend, *pointer, 4096) : std::string();
total_chars += value.size();
lines.push_back(value);
}
data["text"] = lines.empty() ? "" : lines[0].get<std::string>();
data["pointCount"] = total_chars;
data["splineCount"] = lines.size();
}
return data; return data;
} }
@@ -2756,8 +3047,12 @@ json scene_ir_from_blend(const ParsedBlend &blend,
json non_mesh_data = json::array(); json non_mesh_data = json::array();
json vfonts = json::array(); json vfonts = json::array();
json libraries = json::array(); json libraries = json::array();
json script_sources = json::array();
size_t script_source_bytes = 0;
json masks = json::array(); json masks = json::array();
bool masks_blocked = false; bool masks_blocked = false;
bool libraries_blocked = false;
bool script_sources_blocked = false;
json animations = json::array(); json animations = json::array();
json nla_tracks = json::array(); json nla_tracks = json::array();
json armatures = json::array(); json armatures = json::array();
@@ -3234,9 +3529,29 @@ json scene_ir_from_blend(const ParsedBlend &blend,
} }
else if (record.type_name == "Curve" || record.type_name == "MetaBall" || else if (record.type_name == "Curve" || record.type_name == "MetaBall" ||
record.type_name == "PointCloud" || record.type_name == "Curves" || record.type_name == "PointCloud" || record.type_name == "Curves" ||
record.type_name == "Volume" || record.type_name == "Text") { record.type_name == "Volume") {
non_mesh_data.push_back(non_mesh_data_from_record(blend, element, record, ids_by_pointer)); non_mesh_data.push_back(non_mesh_data_from_record(blend, element, record, ids_by_pointer));
} }
else if (record.type_name == "Text") {
if (script_sources.size() >= 1024) {
script_sources_blocked = true;
}
else if (const std::optional<json> source = script_source_from_record(
blend, element, record))
{
const size_t byte_length = source->value("byteLength", size_t(0));
if (byte_length > MAX_SCRIPT_SOURCE_BYTES - script_source_bytes) {
script_sources_blocked = true;
}
else {
script_source_bytes += byte_length;
script_sources.push_back(*source);
}
}
else {
script_sources_blocked = true;
}
}
else if (record.type_name == "VFont") { else if (record.type_name == "VFont") {
const std::string filepath = read_string(*blend.sdna, element, "filepath"); const std::string filepath = read_string(*blend.sdna, element, "filepath");
vfonts.push_back({{"id", record.id}, {"name", record.name}, {"sourcePath", filepath}, vfonts.push_back({{"id", record.id}, {"name", record.name}, {"sourcePath", filepath},
@@ -3460,14 +3775,36 @@ json scene_ir_from_blend(const ParsedBlend &blend,
if (block.type_name != "Library") continue; if (block.type_name != "Library") continue;
const ElementRef library{&block, 0, block.type_name}; const ElementRef library{&block, 0, block.type_name};
const std::string path = read_string(*blend.sdna, library, "filepath"); const std::string path = read_string(*blend.sdna, library, "filepath");
const std::string library_name = read_id_name(*blend.sdna, library);
const std::string library_id = "library:" + library_name;
const bool project_path = path.rfind("//", 0) == 0 && path.size() > 2 && path.size() <= 2050 &&
path.find('\\') == std::string::npos && path.find('%') == std::string::npos &&
path.find("/../") == std::string::npos && path.find("//", 2) == std::string::npos;
if (!project_path || library_name.empty() || library_id.size() > 256) {
libraries_blocked = true;
continue;
}
const std::optional<uint64_t> packed_file = read_pointer(*blend.sdna, library, "packedfile"); const std::optional<uint64_t> packed_file = read_pointer(*blend.sdna, library, "packedfile");
const std::vector<uint8_t> packed_bytes = packed_file ? packed_file_bytes(blend, *packed_file) : const std::vector<uint8_t> packed_bytes = packed_file ? packed_file_bytes(blend, *packed_file) :
std::vector<uint8_t>(); std::vector<uint8_t>();
json resource = {{"id", "library:" + read_id_name(*blend.sdna, library)}, json dependencies = json::array();
{"name", read_id_name(*blend.sdna, library)}, const std::optional<uint64_t> parent_pointer = read_pointer(
*blend.sdna, library, "archive_parent_library");
if (parent_pointer && *parent_pointer != 0) {
const auto parent_id = ids_by_pointer.find(*parent_pointer);
if (parent_id == ids_by_pointer.end()) {
libraries_blocked = true;
continue;
}
dependencies.push_back(parent_id->second);
}
json resource = {{"id", library_id},
{"name", library_name},
{"sourcePath", path}, {"sourcePath", path},
{"packed", !packed_bytes.empty()}, {"packed", !packed_bytes.empty()},
{"status", packed_bytes.empty() ? "EXTERNAL_REQUIRED" : "PACKED"}}; {"status", packed_bytes.empty() ? "EXTERNAL_REQUIRED" : "PACKED"},
{"dependencyIds", std::move(dependencies)},
{"readOnly", true}};
if (packed_bytes.empty()) resource["errorCode"] = "LINKED_LIBRARY_RESOURCE_REQUIRED"; if (packed_bytes.empty()) resource["errorCode"] = "LINKED_LIBRARY_RESOURCE_REQUIRED";
else resource["packedByteLength"] = packed_bytes.size(); else resource["packedByteLength"] = packed_bytes.size();
libraries.push_back(std::move(resource)); libraries.push_back(std::move(resource));
@@ -3511,13 +3848,17 @@ json scene_ir_from_blend(const ParsedBlend &blend,
sort_by_id(non_mesh_data); sort_by_id(non_mesh_data);
sort_by_id(vfonts); sort_by_id(vfonts);
sort_by_id(libraries); sort_by_id(libraries);
sort_by_id(script_sources);
sort_by_id(animations); sort_by_id(animations);
sort_by_id(armatures); sort_by_id(armatures);
sort_by_id(collections); sort_by_id(collections);
sort_by_id(scenes); sort_by_id(scenes);
populate_world_matrices(nodes); populate_world_matrices(nodes);
const std::string active_object_id = first_mesh_object_id.empty() ? first_object_id :
first_mesh_object_id;
std::optional<json> editor_workflow = editor_workflow_from_records(
blend, records, revision, active_object_id);
json snapshot = { json snapshot = {
{"schemaVersion", 1}, {"schemaVersion", 1},
{"revision", revision}, {"revision", revision},
@@ -3544,17 +3885,16 @@ json scene_ir_from_blend(const ParsedBlend &blend,
{"images", std::move(images)}, {"images", std::move(images)},
{"nonMeshData", std::move(non_mesh_data)}, {"nonMeshData", std::move(non_mesh_data)},
{"vfonts", std::move(vfonts)}, {"vfonts", std::move(vfonts)},
{"libraries", std::move(libraries)}, {"libraryStatus", libraries_blocked ? "BLOCKED" : "AVAILABLE"},
{"trackingMaskStatus", masks_blocked ? "BLOCKED" : "AVAILABLE"}, {"trackingMaskStatus", masks_blocked ? "BLOCKED" : "AVAILABLE"},
{"editorWorkflowStatus", editor_workflow ? "AVAILABLE" : "BLOCKED"},
{"scriptSourceStatus", script_sources_blocked ? "BLOCKED" : "AVAILABLE"},
{"animations", std::move(animations)}, {"animations", std::move(animations)},
{"nlaTracks", std::move(nla_tracks)}, {"nlaTracks", std::move(nla_tracks)},
{"armatures", std::move(armatures)}, {"armatures", std::move(armatures)},
{"collections", std::move(collections)}, {"collections", std::move(collections)},
{"scenes", std::move(scenes)}, {"scenes", std::move(scenes)},
{"activeObjectId", {"activeObjectId", active_object_id.empty() ? json(nullptr) : json(active_object_id)},
(first_mesh_object_id.empty() ? first_object_id : first_mesh_object_id).empty() ?
json(nullptr) :
json(first_mesh_object_id.empty() ? first_object_id : first_mesh_object_id)},
{"frame", {{"current", frame_current}, {"start", frame_start}, {"end", frame_end}}}}; {"frame", {{"current", frame_current}, {"start", frame_start}, {"end", frame_end}}}};
if (!masks_blocked) { if (!masks_blocked) {
snapshot["trackingMasks"] = {{"schemaVersion", 1}, snapshot["trackingMasks"] = {{"schemaVersion", 1},
@@ -3563,6 +3903,11 @@ json scene_ir_from_blend(const ParsedBlend &blend,
{"masks", std::move(masks)}, {"masks", std::move(masks)},
{"bindings", json::array()}}; {"bindings", json::array()}};
} }
if (!libraries_blocked) snapshot["libraries"] = std::move(libraries);
if (editor_workflow) snapshot["editorWorkflow"] = std::move(*editor_workflow);
if (!script_sources_blocked) {
snapshot["scriptSources"] = {{"schemaVersion", 1}, {"sources", std::move(script_sources)}};
}
return snapshot; return snapshot;
} }

View File

@@ -1,7 +1,7 @@
# N-023 Asset、Library 与 IO # N-023 Asset、Library 与 IO
状态:`BLOCKED`asset catalog、来源/许可证元数据、库依赖与 IO 安全门已落地;真实 状态:`BLOCKED`asset catalog、来源/许可证元数据、真实 Main library inventory、库依赖与
Append/Link/Override Main、非 GLB 本地导入和跨桌面重导入未实现) IO 安全门已落地;Append/Link/Override Main、非 GLB 本地导入和跨桌面重导入未实现)
## 已验证切片 ## 已验证切片
@@ -13,10 +13,15 @@ Append/Link/Override Main、非 GLB 本地导入和跨桌面重导入未实现
3. N-023-C现有 GLB 导出与 USD semantic analysis 可放行GLTF/OBJ/PLY/STL、 3. N-023-C现有 GLB 导出与 USD semantic analysis 可放行GLTF/OBJ/PLY/STL、
USD/Alembic 实际导入保持 `IO_FORMAT_UNSUPPORTED`,库 mutation 需要真实 Main。 USD/Alembic 实际导入保持 `IO_FORMAT_UNSUPPORTED`,库 mutation 需要真实 Main。
4. N-023-E项目路径、外部 URI、archive entry 数量/单项/总量、压缩展开比率均有边界。 4. N-023-E项目路径、外部 URI、archive entry 数量/单项/总量、压缩展开比率均有边界。
5. N-023-B部分Blender 5.2 Main reader 输出 linked Library stable ID、项目相对路径、
packed/external 状态、只读标志和 archive-parent dependency1024 library/每库 1024 dependency、
重复 ID、缺依赖、依赖环与项目外路径由 SceneIR 再校验。缺外部库字节时返回
`LINKED_LIBRARY_RESOURCE_REQUIRED`,不从路径伪造 SHA-256 或声称已加载。
## 仍然阻断 ## 仍然阻断
- N-023-BAppend/Link/Library Override、reload/relocate 和真实 Main transaction - N-023-BAppend/Link/Library Override、reload/relocate 和真实 Main transaction;只读 library
inventory 已完成。
- N-023-C/DGLTF/OBJ/PLY/STL、USD/Alembic import/export/save/reopen/desktop reimport。 - N-023-C/DGLTF/OBJ/PLY/STL、USD/Alembic import/export/save/reopen/desktop reimport。
- N-023-Ezip fuzz、OPFS quota/recovery、license/source offer 发布审计和大文件流式性能。 - N-023-Ezip fuzz、OPFS quota/recovery、license/source offer 发布审计和大文件流式性能。
@@ -24,4 +29,5 @@ Append/Link/Override Main、非 GLB 本地导入和跨桌面重导入未实现
```bash ```bash
WEB_TEST_PORT=5323 npm --prefix web run test:e2e -- --grep "N-023 asset" WEB_TEST_PORT=5323 npm --prefix web run test:e2e -- --grep "N-023 asset"
npm --prefix web run test:library-main-reader
``` ```

View File

@@ -1,7 +1,7 @@
# N-024 Editors 与工作流 # N-024 Editors 与工作流
状态:`BLOCKED`(统一 context、只读 editor manifest、selection sync、keymap 和布局预算 状态:`BLOCKED`(统一 context、真实 Main 只读布局清单、selection sync、keymap 和布局预算
已落地;完整 Blender editor writer、gizmo/触控和跨设备 golden 未实现) 已落地;完整 Blender editor writer、运行时焦点、gizmo/触控和跨设备 golden 未实现)
## 已验证切片 ## 已验证切片
@@ -12,16 +12,21 @@
3. N-024-C部分selection sync 以 revision 事务更新 active object 与 selected IDs 3. N-024-C部分selection sync 以 revision 事务更新 active object 与 selected IDs
active object 必须属于 selection过期 revision 会拒绝。 active object 必须属于 selection过期 revision 会拒绝。
4. N-024-Dkeymap 绑定和预算可验证writer、gizmo 与 touch drag 保持能力阻断。 4. N-024-Dkeymap 绑定和预算可验证writer、gizmo 与 touch drag 保持能力阻断。
5. N-024-BMain reader从 Blender `WorkSpace``WorkSpaceLayout``bScreen``ScrArea`
`ARegion` 读取有界 workspace/area/region 清单、编辑器类型、可见性与归一化布局;
不完整或包含未知 editor 的 workspace 被跳过,首个完整 workspace/area 只作为确定性只读 context
不声称恢复 Blender 运行时焦点。
## 仍然阻断 ## 仍然阻断
- N-024-B/CProperties、UV/Image、Node、Graph、Dope Sheet、NLA、Spreadsheet 等真实 - N-024-B/CProperties、UV/Image、Node、Graph、Dope Sheet、NLA、Spreadsheet 等 editor
Blender 数据读取/写回、selection history、operator search 和 context menu。 专属数据读取/写回、真实运行时焦点、selection history、operator search 和 context menu。
- N-024-D/EBlender-compatible keymap 执行、gizmo/drag preview/commit、桌面/mobile/笔 - N-024-D/EBlender-compatible keymap 执行、gizmo/drag preview/commit、桌面/mobile/笔
触控布局、无重叠截图和 accessibility golden。 触控布局、无重叠截图和 accessibility golden。
## 验收 ## 验收
```bash ```bash
npm --prefix web run test:editor-main-reader
WEB_TEST_PORT=5324 npm --prefix web run test:e2e -- --grep "N-024 editor" WEB_TEST_PORT=5324 npm --prefix web run test:e2e -- --grep "N-024 editor"
``` ```

View File

@@ -1,7 +1,7 @@
# N-025 Scripting 与平台 # N-025 Scripting 与平台
状态:`BLOCKED`(默认拒绝策略、签名 manifest、权限/资源预算、平台报告和服务端 hash 状态:`BLOCKED`(默认拒绝策略、真实 Main Text 来源清单、签名 manifest、权限/资源预算、
门已落地;本地隔离执行、真实 server job 与发布审计未实现) 平台报告和服务端 hash 门已落地;本地隔离执行、真实 server job 与发布审计未实现)
## 已验证切片 ## 已验证切片
@@ -13,6 +13,9 @@
hash 一致,但当前 endpoint 未配置,返回 `SERVER_JOB_UNAVAILABLE` hash 一致,但当前 endpoint 未配置,返回 `SERVER_JOB_UNAVAILABLE`
4. N-025-DWorker/WebGPU/OffscreenCanvas/OPFS 与 native/CUDA/Metal/HIP/OptiX capability 4. N-025-DWorker/WebGPU/OffscreenCanvas/OPFS 与 native/CUDA/Metal/HIP/OptiX capability
报告只反映 API 存在或显式 `BLOCKED` 报告只反映 API 存在或显式 `BLOCKED`
5. N-025-AMain reader从 Blender `Text`/`TextLine` 重建最多 1 MiB、65,536 行的完整
UTF-8 内嵌来源,记录项目相对外部路径、`use_module` autorun 请求和 SHA-256所有来源均为
只读且 `executionStatus=BLOCKED`autorun 请求按默认拒绝处理,不引入 Python 执行入口。
## 仍然阻断 ## 仍然阻断
@@ -24,5 +27,6 @@
## 验收 ## 验收
```bash ```bash
npm --prefix web run test:script-main-reader
WEB_TEST_PORT=5325 npm --prefix web run test:e2e -- --grep "N-025 script" WEB_TEST_PORT=5325 npm --prefix web run test:e2e -- --grep "N-025 script"
``` ```

View File

@@ -1,18 +1,20 @@
# N-026 全域发布门 # N-026 全域发布门
状态:`BLOCKED`machine-readable parity manifest、依赖/状态检查、证据聚合和确定性 状态:`BLOCKED`machine-readable parity manifest、依赖/状态检查、命令/hash 证据绑定、
序列化已落地;Chromium 完整套件、离线包OPFS、性能/故障、SBOM 与发布审计证据未齐) Chromium 完整套件、离线包/OPFS、SPDX SBOM 和部分性能/故障证据已落地10M/大纹理/
长媒体、simulation cache 性能、OOM/device loss/network interruption 与上游能力仍未齐)
## 已验证切片 ## 已验证切片
1. N-026-A版本化 `ReleaseManifestIR` 为每个 family 记录 `LOCAL_EXACT` 1. N-026-A版本化 schema 3 `ReleaseManifestIR` 为每个 family 记录 `LOCAL_EXACT`
`LOCAL_BOUNDED``SERVER``BLOCKED`、roadmap 状态、完成/阻断切片、验收命令和 `LOCAL_BOUNDED``SERVER``BLOCKED`、roadmap 状态、完成/阻断切片、验收命令和
依赖;缺失 family、非法非阻断状态或依赖环会拒绝。 依赖;缺失 family、非法非阻断状态或依赖环会拒绝。
2. N-026-B/CChromium 主线程/OffscreenCanvas、offline/Worker restart/OPFS recovery、 2. N-026-B/CChromium 主线程/OffscreenCanvas、offline/Worker restart/OPFS recovery、
1M/10M geometry、4K/8K texture、长媒体、simulation cache、OOM/device loss/网络 1M/10M geometry、4K/8K texture、长媒体、simulation cache、OOM/device loss/网络
中断/损坏 blend/zip bomb 等证据字段必须逐项为 true 才能放行。 中断/损坏 blend/zip bomb 等证据字段必须逐项为 true 才能放行。
3. N-026-D/Elicense、SBOM、source offer、deterministic package 为发布必需证据; 3. N-026-D/Elicense、SBOM、source offer、deterministic package 为发布必需证据;每个 true
manifest family 排序可确定性序列化,当前缺证据聚合为 `BLOCKED`,没有虚报发布通过。 字段必须绑定实际成功命令、耗时、输出与构件 SHA-256manifest family 排序可确定性序列化,
当前缺证据聚合为 `BLOCKED`,没有虚报发布通过。
4. 当前 Chromium smoke 已通过引擎启动/Main 编辑与内容寻址资产恢复,并通过真实 64 KiB 4. 当前 Chromium smoke 已通过引擎启动/Main 编辑与内容寻址资产恢复,并通过真实 64 KiB
OPFS quota 下的失败保持旧 revision、Worker 重启恢复门。这只计为 partial evidence不等于 OPFS quota 下的失败保持旧 revision、Worker 重启恢复门。这只计为 partial evidence不等于
完整 suite 或其余 family 的桌面 golden。发布门 schema 2 仅接受 Chromium 浏览器证据。 完整 suite 或其余 family 的桌面 golden。发布门 schema 2 仅接受 Chromium 浏览器证据。
@@ -20,18 +22,23 @@
PointCloud/Curves/Hair 共 7 对象已通过 GLB/USDA desktop round-trip。Volume/VDB 仍无 renderer PointCloud/Curves/Hair 共 7 对象已通过 GLB/USDA desktop round-trip。Volume/VDB 仍无 renderer
与 loss fixture这些证据也不替代其余 family 的 desktop golden。 与 loss fixture这些证据也不替代其余 family 的 desktop golden。
6. 当前发布包已通过本地 third-party notices、Blender/Three license 文件、无远程运行时依赖、 6. 当前发布包已通过本地 third-party notices、Blender/Three license 文件、无远程运行时依赖、
非空 SHA-256 manifest、离线二进制/源码包确定性复建和 100k/1M decimate + 5 类损坏 blend 非空 SHA-256 manifest、SPDX 2.3 lockfile/vendored SBOM、对应源码提供和离线二进制/源码包
拒绝门;这些是 partial evidence不等于完整 SBOM/source offer、10M/长媒体或 OPFS quota 确定性复建100k/1M decimate、5 类损坏 blend 和 archive 高压缩比拒绝有实际命令记录
7. `docs/status/release-evidence.json` 记录 Chromium 完整 E2E + release suite 以及上述发布命令;
`docs/web/sbom.spdx.json` 确定性覆盖 npm lockfile 与 notices 中显式 vendored/native 组件。
## 仍然阻断 ## 仍然阻断
- Chromium 完整套件、跨 family 桌面 golden、离线/OPFS quota、1M/10M/长媒体基准和 fault fuzz - 跨 family 桌面 golden、10M、4K/8K texture、长媒体、simulation cache 性能和 OOM/device loss/
尚未有真实证据;本项目当前配置 Firefox/WebKit,上游 N-015 至 N-025 的阻断能力会传递到发布门。 network interruption fault 仍无真实证据;本项目当前配置 Chromium,上游 N-015 至 N-025
- 完整 release package 审计、许可证/source offer/SBOM、10M/长媒体性能和 OPFS quota 发布流程仍未完成; 的阻断能力会传递到发布门。
当前离线包确定性检查只覆盖已有二进制/源码归档门。 - 完整 native dependency/license 审计仍需发行审核;当前 SBOM 覆盖 lockfile 和显式 notices
不声称替代 Blender 全部传递依赖的发布级法律审计。
## 验收 ## 验收
```bash ```bash
WEB_TEST_PORT=5326 npm --prefix web run test:e2e -- --grep "N-026 release" WEB_TEST_PORT=5326 npm --prefix web run test:e2e -- --grep "N-026 release"
npm --prefix web run test:release-evidence
npm --prefix web run release:evidence
``` ```

View File

@@ -89,9 +89,9 @@
"name": "Assets, libraries and IO", "name": "Assets, libraries and IO",
"status": "BLOCKED", "status": "BLOCKED",
"roadmapStatus": "planned", "roadmapStatus": "planned",
"completedSlices": ["A-catalog-asset-license-source-schema", "A-content-addressed-opfs-capability", "B-library-dependency-order-partial", "C-glb-export-usd-analysis-gates", "E-archive-path-ratio-budget"], "completedSlices": ["A-catalog-asset-license-source-schema", "A-content-addressed-opfs-capability", "B-library-dependency-order-partial", "B-main-library-inventory-reader", "C-glb-export-usd-analysis-gates", "E-archive-path-ratio-budget"],
"blockedSlices": ["A", "B", "C", "D", "E"], "blockedSlices": ["A", "B", "C", "D", "E"],
"acceptance": ["web:e2e:N-023 asset"], "acceptance": ["web:test:library-main-reader", "web:e2e:N-023 asset"],
"dependencies": ["N-022"] "dependencies": ["N-022"]
}, },
{ {
@@ -99,9 +99,9 @@
"name": "Editors and workflow", "name": "Editors and workflow",
"status": "BLOCKED", "status": "BLOCKED",
"roadmapStatus": "planned", "roadmapStatus": "planned",
"completedSlices": ["A-unified-area-region-context", "B-read-only-editor-manifest-partial", "C-selection-sync-revision-partial", "D-keymap-layout-budget-gates"], "completedSlices": ["A-unified-area-region-context", "B-read-only-editor-manifest-partial", "B-main-workspace-area-region-reader", "C-selection-sync-revision-partial", "D-keymap-layout-budget-gates"],
"blockedSlices": ["A", "B", "C", "D", "E"], "blockedSlices": ["A", "B", "C", "D", "E"],
"acceptance": ["web:e2e:N-024 editor"], "acceptance": ["web:test:editor-main-reader", "web:e2e:N-024 editor"],
"dependencies": ["N-023"] "dependencies": ["N-023"]
}, },
{ {
@@ -109,9 +109,9 @@
"name": "Scripting and platform", "name": "Scripting and platform",
"status": "BLOCKED", "status": "BLOCKED",
"roadmapStatus": "planned", "roadmapStatus": "planned",
"completedSlices": ["A-default-deny-script-policy", "B-signed-manifest-permission-budget", "C-server-source-hash-job-gate", "D-platform-capability-report"], "completedSlices": ["A-default-deny-script-policy", "A-main-text-source-inventory-reader", "B-signed-manifest-permission-budget", "C-server-source-hash-job-gate", "D-platform-capability-report"],
"blockedSlices": ["A", "B", "C", "D", "E"], "blockedSlices": ["A", "B", "C", "D", "E"],
"acceptance": ["web:e2e:N-025 script"], "acceptance": ["web:test:script-main-reader", "web:e2e:N-025 script"],
"dependencies": ["N-024"] "dependencies": ["N-024"]
}, },
{ {
@@ -119,9 +119,9 @@
"name": "Release gate", "name": "Release gate",
"status": "BLOCKED", "status": "BLOCKED",
"roadmapStatus": "planned", "roadmapStatus": "planned",
"completedSlices": ["A-machine-readable-parity-manifest", "A-dependency-status-validation", "B-chromium-runtime-evidence-gate", "B-chromium-smoke-partial", "C-performance-fault-provenance-gate", "C-performance-1M-malicious-input-partial", "C-release-package-notices-partial", "D-deterministic-manifest-serialization", "D-offline-reproducible-source-archive-partial"], "completedSlices": ["A-machine-readable-parity-manifest", "A-dependency-status-validation", "A-command-hash-evidence-binding", "B-chromium-runtime-evidence-gate", "B-chromium-full-suite-evidence", "C-performance-fault-provenance-gate", "C-performance-1M-malicious-input-partial", "C-zip-bomb-ratio-rejection", "C-release-package-notices-partial", "C-spdx-2.3-lockfile-sbom", "D-deterministic-manifest-serialization", "D-offline-reproducible-source-archive-partial"],
"blockedSlices": ["A", "B", "C", "D", "E"], "blockedSlices": ["A", "B", "C", "D", "E"],
"acceptance": ["web:e2e:N-026 release", "web:test:browser-smoke", "web:test:release-package", "web:release:offline", "web:test:release-performance", "web:test:malicious-blends"], "acceptance": ["web:e2e:N-026 release", "web:test:release-evidence", "web:test:browser", "web:test:release-package", "web:release:offline", "web:test:release-performance", "web:test:malicious-blends"],
"dependencies": ["N-015", "N-016", "N-017", "N-018", "N-019", "N-020", "N-021", "N-022", "N-023", "N-024", "N-025"] "dependencies": ["N-015", "N-016", "N-017", "N-018", "N-019", "N-020", "N-021", "N-022", "N-023", "N-024", "N-025"]
} }
] ]

View File

@@ -0,0 +1,524 @@
{
"schemaVersion": 3,
"source": "docs/status/parity-ledger.json",
"sourceSha256": "fbaba7086a985176b2100f066326d48b203776ea0706783fa0fd0e61712d0795",
"generatedAt": "2026-08-12T19:18:35.501Z",
"families": [
{
"id": "N-015",
"name": "Non-mesh geometry",
"status": "LOCAL_BOUNDED",
"roadmapStatus": "in_progress",
"completedSlices": [
"A1",
"A2-malformed-binary-partial",
"A2-integer-overflow-fixtures",
"A2-multichunk-attribute-completeness",
"B2",
"B3-metadata",
"C1-topology-partial",
"C1-handle-points-partial",
"C1-handle-preview-raycast-partial",
"C1-handle-identity-gizmo-main-roundtrip",
"C1-multi-handle-selection-highlight-bounded-commit",
"C1-rename-partial",
"C1-create-delete-poly-partial",
"C1-poly-bezier-nurbs-1d-conversion",
"C1-multispline-create-delete-bulk-handle-cyclic-transaction",
"C1-surface-2d-topology-transaction",
"C2-font-geometry-partial",
"C2-font-layout-partial",
"C2-font-character-style-textbox-roundtrip",
"C2-existing-packed-vfont-style-links",
"C2-builtin-font-evaluation-exact",
"C3-roundtrip",
"D1-raycast-partial",
"D1-offscreen-vert-edge-partial",
"D1-selection-history-partial",
"D1-cross-object-history-range-patch",
"D1-handle-identity-axis-gizmo",
"D2-partial",
"E1-partial",
"E1-desktop-geometry-golden",
"E1-true-2d-surface-desktop-golden",
"E1-glb-evaluated-nonmesh-roundtrip-partial",
"E1-glb-curve-line-surface-mesh-evaluated",
"E1-usda-four-object-desktop-roundtrip",
"E1-pointcloud-curves-hair-glb-usd-loss-fixture",
"E2-1M-chromium",
"E2-chromium-worker-recovery",
"E2-opfs-quota-chromium"
],
"blockedSlices": [
"B3-vdb-renderer",
"C1-continuous-handle-gizmo-preview",
"C2-new-external-font-import",
"E1-volume-loss-fixture"
],
"acceptance": [
"web:test:nonmesh-roundtrip",
"web:test:nonmesh-desktop-golden",
"web:test:nonmesh-glb-blender-roundtrip",
"web:test:nonmesh-usd-serialization",
"web:test:nonmesh-usd-blender-roundtrip",
"web:test:nonmesh-binary",
"web:test:vdb",
"web:test:selection-history",
"web:e2e:N-015|non-mesh",
"web:e2e:real OPFS quota"
],
"dependencies": []
},
{
"id": "N-016",
"name": "Grease Pencil",
"status": "BLOCKED",
"roadmapStatus": "planned",
"completedSlices": [
"A-schema-budget",
"A-main-reader",
"B-layer-frame-stroke-transaction-partial",
"C-point-radius-opacity-color-cyclic-material-partial",
"C-bounded-previous-next-onion-preview",
"D-current-frame-stroke-preview-main-offscreen-chromium"
],
"blockedSlices": [
"A",
"B",
"C",
"D",
"E"
],
"acceptance": [
"web:test:grease-pencil",
"web:e2e:N-016 Grease Pencil"
],
"dependencies": [
"N-015"
]
},
{
"id": "N-017",
"name": "Paint and weights",
"status": "BLOCKED",
"roadmapStatus": "planned",
"completedSlices": [
"A-stroke-hit-weight-schema-budget-partial",
"A-three-raycast-source-face-barycentric-uv",
"B-main-vertex-color-partial",
"B-main-vertex-weight-normalize-partial"
],
"blockedSlices": [
"A",
"B",
"C",
"D",
"E"
],
"acceptance": [
"web:test:paint-roundtrip",
"web:e2e:N-017 paint"
],
"dependencies": [
"N-016"
]
},
{
"id": "N-018",
"name": "Physics and simulation",
"status": "BLOCKED",
"roadmapStatus": "planned",
"completedSlices": [
"A-family-capability-inventory",
"B-settings-dependency-cache-manifest-partial",
"C-exact-frame-selection-gate",
"C-content-addressed-frame-range-read-hash-gate"
],
"blockedSlices": [
"A",
"B",
"C",
"D",
"E"
],
"acceptance": [
"web:e2e:N-018 physics",
"web:test:simulation-cache"
],
"dependencies": [
"N-017"
]
},
{
"id": "N-019",
"name": "Lighting and render",
"status": "BLOCKED",
"roadmapStatus": "planned",
"completedSlices": [
"A-camera-light-world-scene-reader-partial",
"A-main-camera-dof-properties-partial",
"A-main-light-world-properties-partial",
"B-three-exposure-shadow-mapping-partial",
"A-white-balance-integrity-gate"
],
"blockedSlices": [
"A",
"B",
"C",
"D",
"E"
],
"acceptance": [
"web:test:lighting-roundtrip",
"web:e2e:N-019 Scene exposure"
],
"dependencies": [
"N-018"
]
},
{
"id": "N-020",
"name": "Compositor",
"status": "BLOCKED",
"roadmapStatus": "planned",
"completedSlices": [
"A-graph-resource-cycle-schema",
"A-main-graph-structure-reader-partial",
"B-bounded-cpu-executor-partial",
"C-image-operation-budget-cancel-partial",
"D-unsupported-node-preservation-gate"
],
"blockedSlices": [
"A",
"B",
"C",
"D",
"E"
],
"acceptance": [
"web:test:compositor-main-reader",
"web:e2e:N-020 CPU compositor"
],
"dependencies": [
"N-019"
]
},
{
"id": "N-021",
"name": "Sequencer and audio",
"status": "BLOCKED",
"roadmapStatus": "planned",
"completedSlices": [
"A-strip-resource-schema",
"A-main-strip-timeline-reader-partial",
"B-deterministic-move-trim-split-partial",
"B-source-frame-seek",
"C-runtime-codec-probe-gate"
],
"blockedSlices": [
"A",
"B",
"C",
"D",
"E"
],
"acceptance": [
"web:test:sequencer-main-reader",
"web:e2e:N-021 sequencer"
],
"dependencies": [
"N-020"
]
},
{
"id": "N-022",
"name": "Tracking and masks",
"status": "BLOCKED",
"roadmapStatus": "planned",
"completedSlices": [
"A-clip-track-plane-mask-schema",
"A-main-mask-layer-spline-point-reader",
"B-revision-marker-mask-edit-partial",
"B-source-hash-binding-validation",
"C-browser-probe-solve-gate"
],
"blockedSlices": [
"A",
"B",
"C",
"D",
"E"
],
"acceptance": [
"web:test:mask-main-reader",
"web:e2e:N-022 tracking"
],
"dependencies": [
"N-021"
]
},
{
"id": "N-023",
"name": "Assets, libraries and IO",
"status": "BLOCKED",
"roadmapStatus": "planned",
"completedSlices": [
"A-catalog-asset-license-source-schema",
"A-content-addressed-opfs-capability",
"B-library-dependency-order-partial",
"B-main-library-inventory-reader",
"C-glb-export-usd-analysis-gates",
"E-archive-path-ratio-budget"
],
"blockedSlices": [
"A",
"B",
"C",
"D",
"E"
],
"acceptance": [
"web:test:library-main-reader",
"web:e2e:N-023 asset"
],
"dependencies": [
"N-022"
]
},
{
"id": "N-024",
"name": "Editors and workflow",
"status": "BLOCKED",
"roadmapStatus": "planned",
"completedSlices": [
"A-unified-area-region-context",
"B-read-only-editor-manifest-partial",
"B-main-workspace-area-region-reader",
"C-selection-sync-revision-partial",
"D-keymap-layout-budget-gates"
],
"blockedSlices": [
"A",
"B",
"C",
"D",
"E"
],
"acceptance": [
"web:test:editor-main-reader",
"web:e2e:N-024 editor"
],
"dependencies": [
"N-023"
]
},
{
"id": "N-025",
"name": "Scripting and platform",
"status": "BLOCKED",
"roadmapStatus": "planned",
"completedSlices": [
"A-default-deny-script-policy",
"A-main-text-source-inventory-reader",
"B-signed-manifest-permission-budget",
"C-server-source-hash-job-gate",
"D-platform-capability-report"
],
"blockedSlices": [
"A",
"B",
"C",
"D",
"E"
],
"acceptance": [
"web:test:script-main-reader",
"web:e2e:N-025 script"
],
"dependencies": [
"N-024"
]
},
{
"id": "N-026",
"name": "Release gate",
"status": "BLOCKED",
"roadmapStatus": "planned",
"completedSlices": [
"A-machine-readable-parity-manifest",
"A-dependency-status-validation",
"A-command-hash-evidence-binding",
"B-chromium-runtime-evidence-gate",
"B-chromium-full-suite-evidence",
"C-performance-fault-provenance-gate",
"C-performance-1M-malicious-input-partial",
"C-zip-bomb-ratio-rejection",
"C-release-package-notices-partial",
"C-spdx-2.3-lockfile-sbom",
"D-deterministic-manifest-serialization",
"D-offline-reproducible-source-archive-partial"
],
"blockedSlices": [
"A",
"B",
"C",
"D",
"E"
],
"acceptance": [
"web:e2e:N-026 release",
"web:test:release-evidence",
"web:test:browser",
"web:test:release-package",
"web:release:offline",
"web:test:release-performance",
"web:test:malicious-blends"
],
"dependencies": [
"N-015",
"N-016",
"N-017",
"N-018",
"N-019",
"N-020",
"N-021",
"N-022",
"N-023",
"N-024",
"N-025"
]
}
],
"evidence": {
"browser": {
"chromium": true
},
"runtime": {
"offline": true,
"workerRestart": true,
"opfsRecovery": true
},
"performance": {
"geometry1M": true,
"geometry10M": false,
"texture4K": false,
"texture8K": false,
"longMedia": false,
"simulationCache": false
},
"faults": {
"oom": false,
"deviceLoss": false,
"networkInterrupt": false,
"malformedBlend": true,
"zipBomb": true
},
"provenance": {
"license": true,
"sbom": true,
"sourceOffer": true,
"deterministicPackage": true
},
"records": [
{
"id": "sbom",
"fields": [
"provenance.license",
"provenance.sbom"
],
"command": "npm --prefix web run release:sbom",
"exitCode": 0,
"durationMs": 364,
"output": "> blender-web-editor@0.1.0 release:sbom\n> node ../tools/web/generate-sbom.mjs\n\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30",
"artifactSha256": [
"8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30",
"d5ec6e6ec9e1e0a50fe4455513a2938838609d3809224eaf26a2ebfac474f0ae",
"3c65ff96cc15c9072d2517555484ccfd0009a2814d96faadcc5bd7b2e3458503"
]
},
{
"id": "chromium",
"fields": [
"browser.chromium",
"runtime.offline",
"runtime.workerRestart",
"runtime.opfsRecovery"
],
"command": "npm --prefix web run test:e2e -- --workers=1 && npm --prefix web run test:browser",
"exitCode": 0,
"durationMs": 135891,
"output": "d LOD profiles at the protocol boundary (1.0s)\n ✓ 67 tests/e2e/smoke.spec.ts:1765:1 normalizes skin influences and rejects shape-key loss explicitly (1.0s)\n ✓ 68 tests/e2e/smoke.spec.ts:1790:1 remaps skin weights and shape keys through the native Collapse worker path (1.3s)\n ✓ 69 tests/e2e/smoke.spec.ts:1846:1 reports GLB export blockers before any binary export (1.1s)\n ✓ 70 tests/e2e/smoke.spec.ts:1858:1 blocks Shader graphs that cannot be mapped to glTF PBR (1.0s)\n ✓ 71 tests/e2e/smoke.spec.ts:1885:1 maps bounded RGB and Value Shader constants to glTF PBR factors (1.0s)\n ✓ 72 tests/e2e/smoke.spec.ts:1918:1 exports a local SceneIR mesh as a standards-shaped GLB (1.0s)\n ✓ 73 tests/e2e/smoke.spec.ts:1951:1 keeps per-vertex UV and color attributes in GLB output (995ms)\n ✓ 74 tests/e2e/smoke.spec.ts:1978:1 evaluates modifier dependency order and blocks unevaluated or cyclic stacks (1.0s)\n ✓ 75 tests/e2e/smoke.spec.ts:1993:1 embeds local textures and exports glTF skin and animation records (1.0s)\n ✓ 76 tests/e2e/smoke.spec.ts:2043:1 reports lightweight budget violations without altering usage (1.0s)\n ✓ 77 tests/e2e/smoke.spec.ts:2061:1 aggregates project, collection, object and LOD budgets without double counting LOD (991ms)\n ✓ 78 tests/e2e/smoke.spec.ts:2087:1 round-trips LOD geometry through the local binary mesh cache container (1.0s)\n ✓ 79 tests/e2e/smoke.spec.ts:2111:1 blocks ImageIR paths outside the project asset sandbox (1.3s)\n ✓ 80 tests/e2e/smoke.spec.ts:2154:1 extracts Blender packed image bytes through the local asset request API (1.4s)\n ✓ 81 tests/e2e/smoke.spec.ts:2199:1 matches Blender Depsgraph deformation golden within the declared error budget (1.3s)\n ✓ 82 tests/e2e/smoke.spec.ts:2238:1 evaluates the full Blender Depsgraph or reports its safe capability gate (1.3s)\n ✓ 83 tests/e2e/smoke.spec.ts:2298:1 exports layered Action keyframes through SceneIR (1.3s)\n ✓ 84 tests/e2e/smoke.spec.ts:2339:1 patches changed mesh buffer ranges without replacing stable topology (1.0s)\n ✓ 85 tests/e2e/smoke.spec.ts:2363:1 renders through the capability-gated OffscreenCanvas worker (1.2s)\n ✓ 86 tests/e2e/smoke.spec.ts:2376:1 coalesces linked mesh objects into a raycastable instance group (2.0s)\n ✓ 87 tests/e2e/smoke.spec.ts:2384:1 returns structured gates for the undeclared capability protocols (1.1s)\n ✓ 88 tests/e2e/smoke.spec.ts:2443:1 exposes PBR-007 to PBR-012 renderer security gates (1.0s)\n ✓ 89 tests/e2e/smoke.spec.ts:2477:1 transfers packed raster assets into the PBR viewport with an explicit status (2.0s)\n ✓ 90 tests/e2e/smoke.spec.ts:2486:1 uses the same packed texture payload in the OffscreenCanvas renderer (1.8s)\n\n 90 passed (2.1m)\n\n> blender-web-editor@0.1.0 test:browser\n> playwright test --config playwright.release.config.ts\n\n\nRunning 3 tests using 1 worker\n\n ✓ 1 [chromium] tests/e2e/cross-browser.spec.ts:6:1 boots the offline engine, renders SceneIR and performs a Main edit (2.8s)\n ✓ 2 [chromium] tests/e2e/cross-browser.spec.ts:32:1 keeps content-addressed asset recovery available in Chromium (1.2s)\n ✓ 3 [chromium] tests/e2e/cross-browser.spec.ts:51:1 keeps the committed project after quota failure and Worker restart in Chromium (1.1s)\n\n 3 passed (6.8s)\n\n[WebServer] (node:1250790) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1250802) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1255027) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1255039) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)",
"artifactSha256": [
"7176272d381a53d559d9b6ebe7ca8653ed32199ef6ddd4e2779637954f2c9ac3"
]
},
{
"id": "geometry-1m",
"fields": [
"performance.geometry1M"
],
"command": "npm --prefix web run test:release-performance",
"exitCode": 0,
"durationMs": 90534,
"output": "> blender-web-editor@0.1.0 test:release-performance\n> node ../tools/web/check-release-performance.mjs\n\nrelease-performance-ok [{\"target\":100000,\"ratio\":0.9,\"outputTriangles\":89999,\"elapsedMs\":89462,\"heapBytes\":67108864},{\"target\":1000000,\"ratio\":1,\"outputTriangles\":1000000,\"elapsedMs\":531,\"heapBytes\":346554368}]\n\nHeap resize call from 67108864 to 80543744 took 0.22819400001026224 msecs. Success: true\nHeap resize call from 80543744 to 96665600 took 0.10745999999926426 msecs. Success: true\nHeap resize call from 96665600 to 115998720 took 0.06875900000159163 msecs. Success: true\nHeap resize call from 115998720 to 139198464 took 1.2809850000048755 msecs. Success: true\nHeap resize call from 139198464 to 167051264 took 1.4198880000039935 msecs. Success: true\nHeap resize call from 167051264 to 200474624 took 1.4606950000015786 msecs. Success: true\nHeap resize call from 200474624 to 240582656 took 1.2892889999930048 msecs. Success: true\nHeap resize call from 240582656 to 288751616 took 1.3983549999975367 msecs. Success: true\nHeap resize call from 288751616 to 346554368 took 1.341115000002901 msecs. Success: true",
"artifactSha256": [
"7176272d381a53d559d9b6ebe7ca8653ed32199ef6ddd4e2779637954f2c9ac3"
]
},
{
"id": "malformed-blend",
"fields": [
"faults.malformedBlend"
],
"command": "npm --prefix web run test:malicious-blends",
"exitCode": 0,
"durationMs": 533,
"output": "> blender-web-editor@0.1.0 test:malicious-blends\n> node ../tools/web/check-malicious-blends.mjs\n\nmalicious-blends-ok rejected=5",
"artifactSha256": [
"6fcc55bda74da8c95da96ba068b60339bf60ca44147ff8000fbb95d296db2a73"
]
},
{
"id": "zip-bomb",
"fields": [
"faults.zipBomb"
],
"command": "WEB_TEST_PORT=5323 npm --prefix web run test:asset-library",
"exitCode": 0,
"durationMs": 4077,
"output": "> blender-web-editor@0.1.0 test:asset-library\n> playwright test --config playwright.config.ts -g \"N-023 asset\"\n\n\nRunning 1 test using 1 worker\n\n ✓ 1 tests/e2e/smoke.spec.ts:625:1 validates N-023 asset catalogs, library graphs, archive budgets and IO gates (1.5s)\n\n 1 passed (3.2s)\n\n[WebServer] (node:1255677) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)\n[WebServer] (node:1255689) Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env being set.\n[WebServer] (Use `node --trace-warnings ...` to show where the warning was created)",
"artifactSha256": [
"6169748c5bf78a8e101196e964db67b8a65c2ba95bb3b4a54e052721be01a7bf"
]
},
{
"id": "release-package",
"fields": [],
"command": "npm --prefix web run test:release-package",
"exitCode": 0,
"durationMs": 4783,
"output": "> blender-web-editor@0.1.0 test:release-package\n> npm run build && npm run release:sbom && node ../tools/web/check-release-package.mjs\n\n\n> blender-web-editor@0.1.0 build\n> tsc -p tsconfig.json && vite build --config app/vite.config.ts\n\nvite v8.2.0 building client environment for production...\n\u001b[2K\rtransforming...✓ 43 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.45 kB │ gzip: 0.29 kB\ndist/assets/storage.worker-CnSyBTao.js 35.90 kB\ndist/assets/web-engine.worker-DR5p4R2R.js 238.07 kB\ndist/assets/viewport-render.worker-B2Y56q7a.js 555.34 kB\ndist/assets/web_engine-CFVvLQ_x.wasm 15,003.09 kB │ gzip: 3,546.97 kB\ndist/assets/index-C4cauSFG.css 11.20 kB │ gzip: 3.14 kB\ndist/assets/index-CPm-S3KG.js 879.04 kB │ gzip: 236.82 kB\n\n✓ built in 549ms\n\n> blender-web-editor@0.1.0 release:sbom\n> node ../tools/web/generate-sbom.mjs\n\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\nrelease-package-ok files=10 bytes=31858159\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.",
"artifactSha256": [
"8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30",
"7176272d381a53d559d9b6ebe7ca8653ed32199ef6ddd4e2779637954f2c9ac3"
]
},
{
"id": "offline-reproducibility",
"fields": [
"provenance.sourceOffer",
"provenance.deterministicPackage"
],
"command": "npm --prefix web run release:offline",
"exitCode": 0,
"durationMs": 36219,
"output": "> blender-web-editor@0.1.0 release:offline\n> npm run build && node ../tools/web/check-offline-reproducibility.mjs\n\n\n> blender-web-editor@0.1.0 build\n> tsc -p tsconfig.json && vite build --config app/vite.config.ts\n\nvite v8.2.0 building client environment for production...\n\u001b[2K\rtransforming...✓ 43 modules transformed.\nrendering chunks...\ncomputing gzip size...\ndist/index.html 0.45 kB │ gzip: 0.29 kB\ndist/assets/storage.worker-CnSyBTao.js 35.90 kB\ndist/assets/web-engine.worker-DR5p4R2R.js 238.07 kB\ndist/assets/viewport-render.worker-B2Y56q7a.js 555.34 kB\ndist/assets/web_engine-CFVvLQ_x.wasm 15,003.09 kB │ gzip: 3,546.97 kB\ndist/assets/index-C4cauSFG.css 11.20 kB │ gzip: 3.14 kB\ndist/assets/index-CPm-S3KG.js 879.04 kB │ gzip: 236.82 kB\n\n✓ built in 548ms\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\noffline-release-ok binary=7521936 source=205665275 sha256=7f8bb6316d83f4a61c9357b5ab04ae996f83a4f2ff66312eb5263aef745c19e7\nsbom-ok packages=150 sha256=8b04215a993f62ba64e0adcb1ba36ac830191f8215bf8cf2681fdf7bbb63da30\noffline-release-ok binary=7521936 source=205665275 sha256=7f8bb6316d83f4a61c9357b5ab04ae996f83a4f2ff66312eb5263aef745c19e7\noffline-reproducibility-ok binary=7f8bb6316d83f4a61c9357b5ab04ae996f83a4f2ff66312eb5263aef745c19e7 source=ca6d3fc1df2d7885a3602beddd427a8a2ac05440a11d250f323cfe33e4fddcff\n\n[plugin rolldown:vite-resolve] Module \"module\" has been externalized for browser compatibility, imported by \"/home/mes123456/workinf_Blender_Wasm/web/app/src/vendor/blender/web_engine.js\". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.\n[plugin builtin:vite-reporter] \n(!) Some chunks are larger than 500 kB after minification. Consider:\n- Using dynamic import() to code-split the application\n- Use build.rolldownOptions.output.codeSplitting to improve chunking: https://rolldown.rs/reference/OutputOptions.codeSplitting\n- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit.",
"artifactSha256": [
"7f8bb6316d83f4a61c9357b5ab04ae996f83a4f2ff66312eb5263aef745c19e7",
"ca6d3fc1df2d7885a3602beddd427a8a2ac05440a11d250f323cfe33e4fddcff",
"c8f6b3287a67760855c145ef239fa8cc696cfb7a7d558d3284982df897caf4ed"
]
}
]
}
}

4124
docs/web/sbom.spdx.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -171,6 +171,13 @@
"objects": 0, "objects": 0,
"meshes": 0, "meshes": 0,
"features": ["mask-main-reader", "bezier-handles", "feather-selection"] "features": ["mask-main-reader", "bezier-handles", "feather-selection"]
},
{
"id": "script_scene",
"path": "script_scene.blend",
"objects": 0,
"meshes": 0,
"features": ["script-main-reader", "full-text-source", "module-autorun-default-deny", "project-relative-text-path"]
} }
] ]
} }

Binary file not shown.

View File

@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
const root = new URL("../../", import.meta.url);
const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root));
const fixture = fs.readFileSync(new URL("tests/files/web/basic_scene.blend", root));
function open(engine, handle, bytes) {
const pointer = engine._malloc(bytes.byteLength);
try {
engine.HEAPU8.set(bytes, pointer);
assert.equal(engine._web_engine_open_blend(handle, pointer, bytes.byteLength), 0,
engine.UTF8ToString(engine._web_engine_last_error_message()));
}
finally { engine._free(pointer); }
}
function snapshot(engine, handle) {
const dataOut = engine._malloc(4), lengthOut = engine._malloc(4);
try {
assert.equal(engine._web_engine_get_scene_snapshot(handle, dataOut, lengthOut), 0,
engine.UTF8ToString(engine._web_engine_last_error_message()));
const pointer = engine.HEAPU32[dataOut >>> 2], length = engine.HEAPU32[lengthOut >>> 2];
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
}
finally { engine._free(dataOut); engine._free(lengthOut); }
}
const supportedEditors = new Set([
"VIEW_3D", "OUTLINER", "PROPERTIES", "UV_IMAGE", "NODE", "GRAPH", "DOPE_SHEET", "NLA",
"SPREADSHEET", "SEQUENCER", "CLIP",
]);
const regionKinds = new Set(["HEADER", "MAIN", "TOOLBAR", "SIDEBAR", "FOOTER"]);
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
open(engine, handle, fixture);
const scene = snapshot(engine, handle);
assert.equal(scene.editorWorkflowStatus, "AVAILABLE");
const workflow = scene.editorWorkflow;
assert.equal(workflow.schemaVersion, 1);
assert.ok(workflow.workspaces.length > 0 && workflow.workspaces.length <= 64);
let areaCount = 0, regionCount = 0;
for (const workspace of workflow.workspaces) {
assert.ok(workspace.areas.some((area) => area.id === workspace.activeAreaId));
assert.equal(workspace.revision, scene.revision);
areaCount += workspace.areas.length;
for (const area of workspace.areas) {
assert.ok(supportedEditors.has(area.editor), `unsupported editor ${area.editor}`);
assert.ok(area.rect.x >= 0 && area.rect.y >= 0 && area.rect.width > 0 && area.rect.height > 0);
assert.ok(area.rect.x + area.rect.width <= 1 && area.rect.y + area.rect.height <= 1);
assert.ok(area.regions.some((region) => region.kind === "MAIN"));
regionCount += area.regions.length;
for (const region of area.regions) assert.ok(regionKinds.has(region.kind));
}
}
assert.ok(areaCount <= 1024 && regionCount <= 4096);
const activeWorkspace = workflow.workspaces.find((workspace) => workspace.id === workflow.context.workspaceId);
const activeArea = activeWorkspace?.areas.find((area) => area.id === workflow.context.activeAreaId);
assert.equal(activeArea?.editor, workflow.context.activeEditor);
assert.equal(workflow.context.revision, scene.revision);
assert.equal(workflow.context.activeObjectId, scene.activeObjectId);
assert.deepEqual(workflow.context.selection, scene.activeObjectId === null ? [] : [scene.activeObjectId]);
assert.deepEqual(workflow.keymaps, []);
engine._web_engine_destroy(handle);
process.stdout.write(`editor-main-reader-ok workspaces=${workflow.workspaces.length} areas=${areaCount} regions=${regionCount}\n`);

View File

@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
const root = new URL("../../", import.meta.url);
const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root));
const fixture = fs.readFileSync(new URL("tests/files/web/image_resource_matrix.blend", root));
function open(engine, handle, bytes) {
const pointer = engine._malloc(bytes.byteLength);
try {
engine.HEAPU8.set(bytes, pointer);
assert.equal(engine._web_engine_open_blend(handle, pointer, bytes.byteLength), 0,
engine.UTF8ToString(engine._web_engine_last_error_message()));
}
finally { engine._free(pointer); }
}
function snapshot(engine, handle) {
const dataOut = engine._malloc(4), lengthOut = engine._malloc(4);
try {
assert.equal(engine._web_engine_get_scene_snapshot(handle, dataOut, lengthOut), 0,
engine.UTF8ToString(engine._web_engine_last_error_message()));
const pointer = engine.HEAPU32[dataOut >>> 2], length = engine.HEAPU32[lengthOut >>> 2];
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
}
finally { engine._free(dataOut); engine._free(lengthOut); }
}
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
open(engine, handle, fixture);
const scene = snapshot(engine, handle);
assert.equal(scene.libraryStatus, "AVAILABLE");
assert.equal(scene.libraries.length, 1);
const library = scene.libraries[0];
assert.equal(library.id, "library:image_resource_library.blend");
assert.equal(library.name, "image_resource_library.blend");
assert.equal(library.sourcePath, "//resources/image_resource_library.blend");
assert.equal(library.packed, false);
assert.equal(library.status, "EXTERNAL_REQUIRED");
assert.equal(library.errorCode, "LINKED_LIBRARY_RESOURCE_REQUIRED");
assert.deepEqual(library.dependencyIds, []);
assert.equal(library.readOnly, true);
engine._web_engine_destroy(handle);
process.stdout.write("library-main-reader-ok relative-path=passed readonly=passed dependency-inventory=passed\n");

View File

@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import ts from "../../web/node_modules/typescript/lib/typescript.js";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "release-evidence-"));
const require = createRequire(import.meta.url);
try {
for (const name of ["capability-gates", "release-gate"]) {
const source = fs.readFileSync(path.join(root, `web/protocol/${name}.ts`), "utf8");
const result = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, fileName: `${name}.ts` });
fs.writeFileSync(path.join(temporary, `${name}.cjs`), result.outputText.replace('require("./capability-gates")', 'require("./capability-gates.cjs")'));
}
const module = require(path.join(temporary, "release-gate.cjs"));
const reportPath = path.join(root, "docs/status/release-evidence.json");
const ledgerPath = path.join(root, "docs/status/parity-ledger.json");
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
assert.equal(report.sourceSha256, crypto.createHash("sha256").update(fs.readFileSync(ledgerPath)).digest("hex"), "release report is stale relative to parity ledger");
const parsed = module.parseReleaseManifest(report);
const evaluation = module.evaluateReleaseManifest(parsed);
assert.equal(evaluation.status, "BLOCKED");
assert.ok(evaluation.missing.includes("performance.geometry10M"));
assert.ok(evaluation.missing.includes("faults.deviceLoss"));
assert.equal(evaluation.missing.includes("browser.chromium"), false);
assert.ok(parsed.evidence.records.length >= 7);
assert.equal(parsed.evidence.records.some((record) => record.fields.includes("faults.zipBomb")), true);
process.stdout.write(`release-evidence-check-ok records=${parsed.evidence.records.length} missing=${evaluation.missing.length} status=${evaluation.status}\n`);
}
finally {
fs.rmSync(temporary, { recursive: true, force: true });
}

View File

@@ -8,6 +8,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..")
const dist = path.join(root, "web/dist"); const dist = path.join(root, "web/dist");
const notices = JSON.parse(fs.readFileSync(path.join(root, "docs/web/third-party-notices.json"), "utf8")); const notices = JSON.parse(fs.readFileSync(path.join(root, "docs/web/third-party-notices.json"), "utf8"));
const packageLock = JSON.parse(fs.readFileSync(path.join(root, "web/package-lock.json"), "utf8")); const packageLock = JSON.parse(fs.readFileSync(path.join(root, "web/package-lock.json"), "utf8"));
const sbom = JSON.parse(fs.readFileSync(path.join(root, "docs/web/sbom.spdx.json"), "utf8"));
for (const dependency of ["react", "react-dom", "three", "vite", "typescript", "@playwright/test"]) { for (const dependency of ["react", "react-dom", "three", "vite", "typescript", "@playwright/test"]) {
const normalize = (value) => value.toLowerCase().replace("@playwright/test", "playwright").replace(/\.js$/, "").replace(/[^a-z0-9]/g, ""); const normalize = (value) => value.toLowerCase().replace("@playwright/test", "playwright").replace(/\.js$/, "").replace(/[^a-z0-9]/g, "");
const covered = notices.packages.some((entry) => normalize(entry.name) === normalize(dependency)); const covered = notices.packages.some((entry) => normalize(entry.name) === normalize(dependency));
@@ -16,6 +17,9 @@ for (const dependency of ["react", "react-dom", "three", "vite", "typescript", "
assert.ok(packageLock.lockfileVersion >= 3, "npm lockfile must use an integrity-bearing format"); assert.ok(packageLock.lockfileVersion >= 3, "npm lockfile must use an integrity-bearing format");
assert.ok(fs.existsSync(path.join(root, "blender-5.2.0/COPYING")), "Blender GPL text is missing"); assert.ok(fs.existsSync(path.join(root, "blender-5.2.0/COPYING")), "Blender GPL text is missing");
assert.ok(fs.existsSync(path.join(root, "web/app/src/vendor/three/LICENSE")), "Three.js license is missing"); assert.ok(fs.existsSync(path.join(root, "web/app/src/vendor/three/LICENSE")), "Three.js license is missing");
assert.equal(sbom.spdxVersion, "SPDX-2.3", "SPDX SBOM schema is missing");
assert.ok(sbom.packages.length >= Object.keys(packageLock.packages).length, "SPDX SBOM omits locked dependencies");
assert.equal(new Set(sbom.packages.map((item) => item.SPDXID)).size, sbom.packages.length, "SPDX IDs must be unique");
assert.ok(fs.existsSync(path.join(dist, "index.html")), "offline dist is missing; run npm build first"); assert.ok(fs.existsSync(path.join(dist, "index.html")), "offline dist is missing; run npm build first");
const files = []; const files = [];

View File

@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import factory from "../../web/app/src/vendor/blender/web_engine.js";
const root = new URL("../../", import.meta.url);
const wasmBinary = fs.readFileSync(new URL("web/app/src/vendor/blender/web_engine.wasm", root));
const fixture = fs.readFileSync(new URL("tests/files/web/script_scene.blend", root));
function open(engine, handle, bytes) {
const pointer = engine._malloc(bytes.byteLength);
try {
engine.HEAPU8.set(bytes, pointer);
assert.equal(engine._web_engine_open_blend(handle, pointer, bytes.byteLength), 0,
engine.UTF8ToString(engine._web_engine_last_error_message()));
}
finally { engine._free(pointer); }
}
function snapshot(engine, handle) {
const dataOut = engine._malloc(4), lengthOut = engine._malloc(4);
try {
assert.equal(engine._web_engine_get_scene_snapshot(handle, dataOut, lengthOut), 0,
engine.UTF8ToString(engine._web_engine_last_error_message()));
const pointer = engine.HEAPU32[dataOut >>> 2], length = engine.HEAPU32[lengthOut >>> 2];
return JSON.parse(new TextDecoder().decode(engine.HEAPU8.slice(pointer, pointer + length)));
}
finally { engine._free(dataOut); engine._free(lengthOut); }
}
const engine = await factory({ wasmBinary: wasmBinary.slice() });
const handle = engine._web_engine_create();
open(engine, handle, fixture);
const scene = snapshot(engine, handle);
assert.equal(scene.scriptSourceStatus, "AVAILABLE");
assert.equal(scene.scriptSources.schemaVersion, 1);
assert.equal(scene.scriptSources.sources.length, 3);
assert.equal(scene.nonMeshData.some((data) => data.id.startsWith("text:")), false);
const byName = new Map(scene.scriptSources.sources.map((source) => [source.name, source]));
for (const source of byName.values()) {
assert.equal(source.byteLength, Buffer.byteLength(source.source));
assert.equal(source.lineCount, source.source.split("\n").length);
assert.equal(source.sourceSha256, crypto.createHash("sha256").update(source.source).digest("hex"));
assert.equal(source.readOnly, true);
assert.equal(source.executionStatus, "BLOCKED");
}
assert.equal(byName.get("InternalSafe.py").source, "value = 7\nprint(value)\n");
assert.equal(byName.get("InternalSafe.py").internal, true);
assert.equal(byName.get("InternalSafe.py").errorCode, "SCRIPT_SANDBOX_UNAVAILABLE");
assert.equal(byName.get("ModuleAutorun.py").moduleAutorunRequested, true);
assert.equal(byName.get("ModuleAutorun.py").errorCode, "SCRIPT_POLICY_DENIED");
assert.equal(byName.get("ExternalProject.py").sourcePath, "//scripts/external_project.py");
assert.equal(byName.get("ExternalProject.py").internal, false);
engine._web_engine_destroy(handle);
process.stdout.write("script-main-reader-ok full-source=passed sha256=passed autorun-default-deny=passed\n");

View File

@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const ledgerPath = path.join(root, "docs/status/parity-ledger.json");
const outputPath = path.join(root, "docs/status/release-evidence.json");
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
const evidence = {
browser: { chromium: false },
runtime: { offline: false, workerRestart: false, opfsRecovery: false },
performance: { geometry1M: false, geometry10M: false, texture4K: false, texture8K: false, longMedia: false, simulationCache: false },
faults: { oom: false, deviceLoss: false, networkInterrupt: false, malformedBlend: false, zipBomb: false },
provenance: { license: false, sbom: false, sourceOffer: false, deterministicPackage: false },
records: [],
};
function run(id, fields, command, artifacts = []) {
const started = Date.now();
const result = spawnSync("bash", ["-lc", command], { cwd: root, encoding: "utf8", env: { ...process.env, WEB_TEST_PORT: process.env.WEB_TEST_PORT ?? "5326" }, maxBuffer: 16 * 1024 * 1024 });
const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
if (result.status !== 0) {
process.stderr.write(`${combined}\n`);
throw new Error(`${id} failed with exit code ${result.status}`);
}
for (const field of fields) {
const [group, key] = field.split(".");
evidence[group][key] = true;
}
evidence.records.push({ id, fields, command, exitCode: 0, durationMs: Date.now() - started, output: combined.slice(-4096) || `${id} passed`, artifactSha256: artifacts.filter((file) => fs.existsSync(path.join(root, file))).map((file) => sha256(path.join(root, file))) });
process.stdout.write(`${id}-ok durationMs=${Date.now() - started}\n`);
}
run("sbom", ["provenance.license", "provenance.sbom"], "npm --prefix web run release:sbom", ["docs/web/sbom.spdx.json", "docs/web/third-party-notices.json", "web/package-lock.json"]);
run("chromium", ["browser.chromium", "runtime.offline", "runtime.workerRestart", "runtime.opfsRecovery"], "npm --prefix web run test:e2e -- --workers=1 && npm --prefix web run test:browser", ["web/app/src/vendor/blender/web_engine.wasm"]);
run("geometry-1m", ["performance.geometry1M"], "npm --prefix web run test:release-performance", ["web/app/src/vendor/blender/web_engine.wasm"]);
run("malformed-blend", ["faults.malformedBlend"], "npm --prefix web run test:malicious-blends", ["tests/files/web/basic_scene.blend"]);
run("zip-bomb", ["faults.zipBomb"], "WEB_TEST_PORT=5323 npm --prefix web run test:asset-library", ["web/protocol/asset-library-io.ts"]);
run("release-package", [], "npm --prefix web run test:release-package", ["docs/web/sbom.spdx.json", "web/app/src/vendor/blender/web_engine.wasm"]);
run("offline-reproducibility", ["provenance.sourceOffer", "provenance.deterministicPackage"], "npm --prefix web run release:offline", ["release/blender-web-offline.tar.gz", "release/blender-web-corresponding-source.tar.gz", "release/SHA256SUMS.txt"]);
const ledgerBytes = fs.readFileSync(ledgerPath);
const ledger = JSON.parse(ledgerBytes);
const manifest = { schemaVersion: 3, source: "docs/status/parity-ledger.json", sourceSha256: crypto.createHash("sha256").update(ledgerBytes).digest("hex"), generatedAt: new Date().toISOString(), families: ledger.families, evidence };
assert.ok(manifest.families.some((family) => family.status === "BLOCKED"));
assert.equal(manifest.evidence.performance.geometry10M, false);
assert.equal(manifest.evidence.faults.deviceLoss, false);
fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`);
process.stdout.write(`release-evidence-ok records=${evidence.records.length} status=BLOCKED sha256=${sha256(outputPath)}\n`);

View File

@@ -8,11 +8,13 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..")
const releaseRoot = path.join(root, "release"); const releaseRoot = path.join(root, "release");
if (path.basename(releaseRoot) !== "release" || path.dirname(releaseRoot) !== root) throw new Error("unsafe release target"); if (path.basename(releaseRoot) !== "release" || path.dirname(releaseRoot) !== root) throw new Error("unsafe release target");
const bundle = path.join(releaseRoot, "blender-web-offline"); const bundle = path.join(releaseRoot, "blender-web-offline");
execFileSync(process.execPath, [path.join(root, "tools/web/generate-sbom.mjs")], { stdio: "inherit" });
fs.rmSync(bundle, { recursive: true, force: true }); fs.rmSync(bundle, { recursive: true, force: true });
fs.mkdirSync(bundle, { recursive: true }); fs.mkdirSync(bundle, { recursive: true });
fs.cpSync(path.join(root, "web/dist"), path.join(bundle, "app"), { recursive: true }); fs.cpSync(path.join(root, "web/dist"), path.join(bundle, "app"), { recursive: true });
fs.copyFileSync(path.join(root, "blender-5.2.0/COPYING"), path.join(bundle, "COPYING")); fs.copyFileSync(path.join(root, "blender-5.2.0/COPYING"), path.join(bundle, "COPYING"));
fs.copyFileSync(path.join(root, "docs/web/third-party-notices.json"), path.join(bundle, "third-party-notices.json")); fs.copyFileSync(path.join(root, "docs/web/third-party-notices.json"), path.join(bundle, "third-party-notices.json"));
fs.copyFileSync(path.join(root, "docs/web/sbom.spdx.json"), path.join(bundle, "sbom.spdx.json"));
fs.copyFileSync(path.join(root, "blender-5.2.0/extern/opensubdiv-source/LICENSE.txt"), path.join(bundle, "LICENSE-OpenSubdiv.txt")); fs.copyFileSync(path.join(root, "blender-5.2.0/extern/opensubdiv-source/LICENSE.txt"), path.join(bundle, "LICENSE-OpenSubdiv.txt"));
fs.copyFileSync(path.join(root, "blender-5.2.0/extern/gmp-source/COPYING.LESSERv3"), path.join(bundle, "LICENSE-GMP-LGPLv3.txt")); fs.copyFileSync(path.join(root, "blender-5.2.0/extern/gmp-source/COPYING.LESSERv3"), path.join(bundle, "LICENSE-GMP-LGPLv3.txt"));
fs.writeFileSync(path.join(bundle, "README.txt"), [ fs.writeFileSync(path.join(bundle, "README.txt"), [

View File

@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const lockPath = path.join(root, "web/package-lock.json");
const outputPath = path.join(root, "docs/web/sbom.spdx.json");
const lockBytes = fs.readFileSync(lockPath);
const lock = JSON.parse(lockBytes);
const noticesBytes = fs.readFileSync(path.join(root, "docs/web/third-party-notices.json"));
const notices = JSON.parse(noticesBytes);
const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
const spdxId = (value) => `SPDXRef-${value.replace(/[^A-Za-z0-9.-]/g, "-")}-${sha256(value).slice(0, 12)}`;
const packages = [];
const rootId = "SPDXRef-Package-blender-web-editor";
packages.push({ SPDXID: rootId, name: lock.name, versionInfo: lock.version, downloadLocation: "NOASSERTION", filesAnalyzed: false, licenseConcluded: "NOASSERTION", licenseDeclared: "NOASSERTION", copyrightText: "NOASSERTION", checksums: [{ algorithm: "SHA256", checksumValue: sha256(lockBytes) }] });
for (const [packagePath, entry] of Object.entries(lock.packages).sort(([left], [right]) => left.localeCompare(right))) {
if (!packagePath || !entry.version) continue;
const marker = packagePath.lastIndexOf("node_modules/");
const name = entry.name ?? packagePath.slice(marker + "node_modules/".length);
const item = { SPDXID: spdxId(`npm-${packagePath}`), name, versionInfo: entry.version, downloadLocation: entry.resolved ?? "NOASSERTION", filesAnalyzed: false, licenseConcluded: "NOASSERTION", licenseDeclared: "NOASSERTION", copyrightText: "NOASSERTION", externalRefs: [{ referenceCategory: "PACKAGE-MANAGER", referenceType: "purl", referenceLocator: `pkg:npm/${encodeURIComponent(name)}@${entry.version}` }] };
if (typeof entry.integrity === "string" && entry.integrity.startsWith("sha512-")) item.checksums = [{ algorithm: "SHA512", checksumValue: Buffer.from(entry.integrity.slice(7), "base64").toString("hex") }];
packages.push(item);
}
for (const notice of notices.packages.filter((item) => !item.source.includes("node_modules"))) {
packages.push({ SPDXID: spdxId(`vendored-${notice.name}-${notice.version}`), name: notice.name, versionInfo: notice.version, downloadLocation: "NOASSERTION", filesAnalyzed: false, licenseConcluded: "NOASSERTION", licenseDeclared: notice.license, copyrightText: "NOASSERTION", sourceInfo: notice.source });
}
packages.sort((left, right) => left.SPDXID.localeCompare(right.SPDXID));
const document = {
spdxVersion: "SPDX-2.3",
dataLicense: "CC0-1.0",
SPDXID: "SPDXRef-DOCUMENT",
name: "blender-web-editor-sbom",
documentNamespace: `https://blender-web.local/spdx/${sha256(Buffer.concat([lockBytes, noticesBytes]))}`,
creationInfo: { created: "1970-01-01T00:00:00Z", creators: ["Tool: tools/web/generate-sbom.mjs"] },
documentDescribes: [rootId],
packages,
relationships: packages.filter((item) => item.SPDXID !== rootId).map((item) => ({ spdxElementId: rootId, relationshipType: "DEPENDS_ON", relatedSpdxElement: item.SPDXID })),
};
fs.writeFileSync(outputPath, `${JSON.stringify(document, null, 2)}\n`);
assert.equal(new Set(packages.map((item) => item.SPDXID)).size, packages.length);
assert.ok(packages.length >= Object.keys(lock.packages).length);
process.stdout.write(`sbom-ok packages=${packages.length} sha256=${sha256(fs.readFileSync(outputPath))}\n`);

View File

@@ -0,0 +1,32 @@
import pathlib
import sys
import bpy
SOURCES = {
"InternalSafe.py": "value = 7\nprint(value)\n",
"ModuleAutorun.py": "def register():\n return 'blocked'\n",
"ExternalProject.py": "message = 'project relative'\n",
}
def main(output_path):
bpy.ops.wm.read_factory_settings(use_empty=True)
for name, source in SOURCES.items():
text = bpy.data.texts.new(name)
text.write(source)
bpy.data.texts["ModuleAutorun.py"].use_module = True
bpy.data.texts["ExternalProject.py"].filepath = "//scripts/external_project.py"
path = pathlib.Path(output_path).resolve()
path.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=str(path), compress=False)
print(f"script-fixture-generated path={path} sources={len(SOURCES)}")
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1:]
if len(arguments) != 1:
raise SystemExit("usage: blender -b --python generate-script-fixture.py -- output.blend")
main(arguments[0])

View File

@@ -13,7 +13,7 @@
"id": "web-engine-bootstrap", "id": "web-engine-bootstrap",
"fileName": "web_engine.wasm", "fileName": "web_engine.wasm",
"url": "/vendor/blender/web_engine.wasm", "url": "/vendor/blender/web_engine.wasm",
"sha256": "acc2d6808dac4590aa3c3915495a6e24396a946b257a2d137f64ccf4c3ba6fbf", "sha256": "7176272d381a53d559d9b6ebe7ca8653ed32199ef6ddd4e2779637954f2c9ac3",
"required": true "required": true
} }
] ]

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -1,15 +1,17 @@
import { gateRelease, parseReleaseManifest, serializeReleaseManifest } from "../../../protocol/release-gate"; import { gateRelease, parseReleaseManifest, serializeReleaseManifest } from "../../../protocol/release-gate";
const family = (id: string, dependencies: string[] = []) => ({ id, name: id, status: "BLOCKED", roadmapStatus: "planned", completedSlices: ["schema"], blockedSlices: ["A", "B"], acceptance: [], dependencies }); const family = (id: string, dependencies: string[] = []) => ({ id, name: id, status: "BLOCKED", roadmapStatus: "planned", completedSlices: ["schema"], blockedSlices: ["A", "B"], acceptance: [], dependencies });
const evidence = { browser: { chromium: false }, runtime: { offline: true, workerRestart: false, opfsRecovery: false }, performance: { geometry1M: true, geometry10M: false, texture4K: false, texture8K: false, longMedia: false, simulationCache: false }, faults: { oom: false, deviceLoss: false, networkInterrupt: false, malformedBlend: true, zipBomb: true }, provenance: { license: true, sbom: false, sourceOffer: false, deterministicPackage: false } }; const evidenceRecord = { id: "fixture", fields: ["runtime.offline", "performance.geometry1M", "faults.malformedBlend", "faults.zipBomb", "provenance.license"], command: "fixture", exitCode: 0, durationMs: 1, output: "fixture passed", artifactSha256: ["a".repeat(64)] };
const base = { schemaVersion: 2, source: "docs/status/parity-ledger.json", generatedAt: "2026-08-11T00:00:00Z", families: [family("N-015"), family("N-016", ["N-015"])], evidence }; const evidence = { browser: { chromium: false }, runtime: { offline: true, workerRestart: false, opfsRecovery: false }, performance: { geometry1M: true, geometry10M: false, texture4K: false, texture8K: false, longMedia: false, simulationCache: false }, faults: { oom: false, deviceLoss: false, networkInterrupt: false, malformedBlend: true, zipBomb: true }, provenance: { license: true, sbom: false, sourceOffer: false, deterministicPackage: false }, records: [evidenceRecord] };
const base = { schemaVersion: 3, source: "docs/status/parity-ledger.json", sourceSha256: "b".repeat(64), generatedAt: "2026-08-11T00:00:00Z", families: [family("N-015"), family("N-016", ["N-015"])], evidence };
self.onmessage = () => { self.onmessage = () => {
const result: Record<string, unknown> = {}; const result: Record<string, unknown> = {};
try { const parsed = parseReleaseManifest(base); result.valid = [parsed.families.length, serializeReleaseManifest(base) === serializeReleaseManifest({ ...base, families: [...base.families].reverse() })]; } catch (error) { result.valid = error instanceof Error ? error.message : String(error); } try { const parsed = parseReleaseManifest(base); result.valid = [parsed.families.length, serializeReleaseManifest(base) === serializeReleaseManifest({ ...base, families: [...base.families].reverse() })]; } catch (error) { result.valid = error instanceof Error ? error.message : String(error); }
const gate = gateRelease(base); result.gate = [gate.status, gate.issues.map((issue) => issue.code)]; const gate = gateRelease(base); result.gate = [gate.status, gate.issues.map((issue) => issue.code)];
try { parseReleaseManifest({ ...base, schemaVersion: 1 }); } catch (error) { result.oldSchema = error instanceof Error ? error.message : String(error); } try { parseReleaseManifest({ ...base, schemaVersion: 2 }); } catch (error) { result.oldSchema = error instanceof Error ? error.message : String(error); }
try { parseReleaseManifest({ ...base, families: [family("N-015", ["N-016"]), family("N-016", ["N-015"])] }); } catch (error) { result.cycle = error instanceof Error ? error.message : String(error); } try { parseReleaseManifest({ ...base, families: [family("N-015", ["N-016"]), family("N-016", ["N-015"])] }); } catch (error) { result.cycle = error instanceof Error ? error.message : String(error); }
try { parseReleaseManifest({ ...base, families: [{ ...family("N-015"), status: "LOCAL_EXACT", completedSlices: [] }] }); } catch (error) { result.status = error instanceof Error ? error.message : String(error); } try { parseReleaseManifest({ ...base, families: [{ ...family("N-015"), status: "LOCAL_EXACT", completedSlices: [] }] }); } catch (error) { result.status = error instanceof Error ? error.message : String(error); }
try { parseReleaseManifest({ ...base, evidence: { ...evidence, browser: { chromium: true } } }); } catch (error) { result.unbound = error instanceof Error ? error.message : String(error); }
self.postMessage(result); self.postMessage(result);
}; };

View File

@@ -36,8 +36,11 @@
"test:tracking-mask": "playwright test --config playwright.config.ts -g \"N-022 tracking\"", "test:tracking-mask": "playwright test --config playwright.config.ts -g \"N-022 tracking\"",
"test:mask-main-reader": "node ../tools/web/check-mask-main-reader.mjs", "test:mask-main-reader": "node ../tools/web/check-mask-main-reader.mjs",
"test:asset-library": "playwright test --config playwright.config.ts -g \"N-023 asset\"", "test:asset-library": "playwright test --config playwright.config.ts -g \"N-023 asset\"",
"test:library-main-reader": "node ../tools/web/check-library-main-reader.mjs",
"test:editor-workflow": "playwright test --config playwright.config.ts -g \"N-024 editor\"", "test:editor-workflow": "playwright test --config playwright.config.ts -g \"N-024 editor\"",
"test:editor-main-reader": "node ../tools/web/check-editor-main-reader.mjs",
"test:scripting-platform": "playwright test --config playwright.config.ts -g \"N-025 script\"", "test:scripting-platform": "playwright test --config playwright.config.ts -g \"N-025 script\"",
"test:script-main-reader": "node ../tools/web/check-script-main-reader.mjs",
"test:release-gate": "playwright test --config playwright.config.ts -g \"N-026 release\"", "test:release-gate": "playwright test --config playwright.config.ts -g \"N-026 release\"",
"test:browser-smoke": "playwright test --config playwright.release.config.ts -g \"boots the offline engine\"", "test:browser-smoke": "playwright test --config playwright.release.config.ts -g \"boots the offline engine\"",
"test:cross-browser-smoke": "npm run test:browser-smoke", "test:cross-browser-smoke": "npm run test:browser-smoke",
@@ -52,7 +55,10 @@
"test:nonmesh-usd-blender-roundtrip": "USD_DESKTOP_REQUIRED=1 node ../tools/web/check-nonmesh-usd-blender-roundtrip.mjs", "test:nonmesh-usd-blender-roundtrip": "USD_DESKTOP_REQUIRED=1 node ../tools/web/check-nonmesh-usd-blender-roundtrip.mjs",
"test:release-performance": "node ../tools/web/check-release-performance.mjs", "test:release-performance": "node ../tools/web/check-release-performance.mjs",
"test:malicious-blends": "node ../tools/web/check-malicious-blends.mjs", "test:malicious-blends": "node ../tools/web/check-malicious-blends.mjs",
"test:release-package": "npm run build && node ../tools/web/check-release-package.mjs", "test:release-package": "npm run build && npm run release:sbom && node ../tools/web/check-release-package.mjs",
"release:sbom": "node ../tools/web/generate-sbom.mjs",
"release:evidence": "node ../tools/web/collect-release-evidence.mjs",
"test:release-evidence": "node ../tools/web/check-release-evidence.mjs",
"release:offline": "npm run build && node ../tools/web/check-offline-reproducibility.mjs", "release:offline": "npm run build && node ../tools/web/check-offline-reproducibility.mjs",
"diagnose:depsgraph": "node ../tools/web/diagnose-depsgraph.mjs" "diagnose:depsgraph": "node ../tools/web/diagnose-depsgraph.mjs"
}, },

View File

@@ -1,7 +1,7 @@
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates"; import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
import type { ErrorCode } from "./error"; import type { ErrorCode } from "./error";
export const RELEASE_GATE_SCHEMA = 2 as const; export const RELEASE_GATE_SCHEMA = 3 as const;
export type ParityStatus = "LOCAL_EXACT" | "LOCAL_BOUNDED" | "SERVER" | "BLOCKED"; export type ParityStatus = "LOCAL_EXACT" | "LOCAL_BOUNDED" | "SERVER" | "BLOCKED";
export interface ParityFamilyEvidenceIR { id: string; name: string; status: ParityStatus; roadmapStatus: "completed" | "in_progress" | "planned"; completedSlices: string[]; blockedSlices: string[]; acceptance: string[]; dependencies: string[] } export interface ParityFamilyEvidenceIR { id: string; name: string; status: ParityStatus; roadmapStatus: "completed" | "in_progress" | "planned"; completedSlices: string[]; blockedSlices: string[]; acceptance: string[]; dependencies: string[] }
export interface ReleaseEvidenceIR { export interface ReleaseEvidenceIR {
@@ -10,8 +10,10 @@ export interface ReleaseEvidenceIR {
performance: { geometry1M: boolean; geometry10M: boolean; texture4K: boolean; texture8K: boolean; longMedia: boolean; simulationCache: boolean }; performance: { geometry1M: boolean; geometry10M: boolean; texture4K: boolean; texture8K: boolean; longMedia: boolean; simulationCache: boolean };
faults: { oom: boolean; deviceLoss: boolean; networkInterrupt: boolean; malformedBlend: boolean; zipBomb: boolean }; faults: { oom: boolean; deviceLoss: boolean; networkInterrupt: boolean; malformedBlend: boolean; zipBomb: boolean };
provenance: { license: boolean; sbom: boolean; sourceOffer: boolean; deterministicPackage: boolean }; provenance: { license: boolean; sbom: boolean; sourceOffer: boolean; deterministicPackage: boolean };
records: ReleaseEvidenceRecordIR[];
} }
export interface ReleaseManifestIR { schemaVersion: typeof RELEASE_GATE_SCHEMA; source: string; generatedAt: string; families: ParityFamilyEvidenceIR[]; evidence: ReleaseEvidenceIR } export interface ReleaseEvidenceRecordIR { id: string; fields: string[]; command: string; exitCode: 0; durationMs: number; output: string; artifactSha256: string[] }
export interface ReleaseManifestIR { schemaVersion: typeof RELEASE_GATE_SCHEMA; source: string; sourceSha256: string; generatedAt: string; families: ParityFamilyEvidenceIR[]; evidence: ReleaseEvidenceIR }
export interface ReleaseGateEvaluationIR { status: "READY" | "BLOCKED"; issueCodes: ErrorCode[]; missing: string[] } export interface ReleaseGateEvaluationIR { status: "READY" | "BLOCKED"; issueCodes: ErrorCode[]; missing: string[] }
export class ReleaseGateValidationError extends Error { export class ReleaseGateValidationError extends Error {
@@ -28,7 +30,21 @@ function strings(value: unknown, name: string, maximum = 100_000): string[] { if
function parseEvidence(value: unknown): ReleaseEvidenceIR { function parseEvidence(value: unknown): ReleaseEvidenceIR {
if (!record(value) || !record(value.browser) || !record(value.runtime) || !record(value.performance) || !record(value.faults) || !record(value.provenance)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", "Release evidence groups are missing"); if (!record(value) || !record(value.browser) || !record(value.runtime) || !record(value.performance) || !record(value.faults) || !record(value.provenance)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", "Release evidence groups are missing");
const group = (name: string, keys: readonly string[]): Record<string, boolean> => { const item = value[name]; if (!record(item)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `evidence.${name} is invalid`); return Object.fromEntries(keys.map((key) => [key, bool(item[key], `evidence.${name}.${key}`)])); }; const group = (name: string, keys: readonly string[]): Record<string, boolean> => { const item = value[name]; if (!record(item)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `evidence.${name} is invalid`); return Object.fromEntries(keys.map((key) => [key, bool(item[key], `evidence.${name}.${key}`)])); };
return { browser: group("browser", ["chromium"]) as ReleaseEvidenceIR["browser"], runtime: group("runtime", ["offline", "workerRestart", "opfsRecovery"]) as ReleaseEvidenceIR["runtime"], performance: group("performance", ["geometry1M", "geometry10M", "texture4K", "texture8K", "longMedia", "simulationCache"]) as ReleaseEvidenceIR["performance"], faults: group("faults", ["oom", "deviceLoss", "networkInterrupt", "malformedBlend", "zipBomb"]) as ReleaseEvidenceIR["faults"], provenance: group("provenance", ["license", "sbom", "sourceOffer", "deterministicPackage"]) as ReleaseEvidenceIR["provenance"] }; if (!Array.isArray(value.records) || value.records.length > 1024) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", "evidence.records is invalid");
const recordIds = new Set<string>();
const records = value.records.map((item, index): ReleaseEvidenceRecordIR => {
const name = `evidence.records[${index}]`; if (!record(item)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`);
const id = text(item.id, `${name}.id`); if (recordIds.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate evidence record ${id}`); recordIds.add(id);
const fields = strings(item.fields, `${name}.fields`, 64); if (new Set(fields).size !== fields.length || fields.some((field) => !/^(browser|runtime|performance|faults|provenance)\.[A-Za-z0-9]+$/.test(field))) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name}.fields is invalid`);
if (item.exitCode !== 0 || typeof item.durationMs !== "number" || !Number.isSafeInteger(item.durationMs) || item.durationMs < 0 || item.durationMs > 86_400_000) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} did not complete successfully`);
const artifactSha256 = strings(item.artifactSha256, `${name}.artifactSha256`, 1024); if (artifactSha256.some((digest) => !/^[a-f0-9]{64}$/.test(digest))) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name}.artifactSha256 is invalid`);
return { id, fields, command: text(item.command, `${name}.command`, 2048), exitCode: 0, durationMs: item.durationMs, output: text(item.output, `${name}.output`, 4096), artifactSha256 };
});
const parsed = { browser: group("browser", ["chromium"]) as ReleaseEvidenceIR["browser"], runtime: group("runtime", ["offline", "workerRestart", "opfsRecovery"]) as ReleaseEvidenceIR["runtime"], performance: group("performance", ["geometry1M", "geometry10M", "texture4K", "texture8K", "longMedia", "simulationCache"]) as ReleaseEvidenceIR["performance"], faults: group("faults", ["oom", "deviceLoss", "networkInterrupt", "malformedBlend", "zipBomb"]) as ReleaseEvidenceIR["faults"], provenance: group("provenance", ["license", "sbom", "sourceOffer", "deterministicPackage"]) as ReleaseEvidenceIR["provenance"], records };
for (const [groupName, groupValues] of Object.entries(parsed).filter(([name]) => name !== "records") as Array<[string, Record<string, boolean>]>) {
for (const [key, enabled] of Object.entries(groupValues)) if (enabled && !records.some((item) => item.fields.includes(`${groupName}.${key}`))) throw new ReleaseGateValidationError("RELEASE_EVIDENCE_MISSING", `Enabled evidence ${groupName}.${key} has no successful record`);
}
return parsed;
} }
function assertDependencies(families: readonly ParityFamilyEvidenceIR[]): void { function assertDependencies(families: readonly ParityFamilyEvidenceIR[]): void {
@@ -41,7 +57,8 @@ export function parseReleaseManifest(value: unknown): ReleaseManifestIR {
if (!record(value) || value.schemaVersion !== RELEASE_GATE_SCHEMA || !Array.isArray(value.families)) throw new ReleaseGateValidationError("PROTOCOL_MISMATCH", "Unsupported release manifest schema"); if (!record(value) || value.schemaVersion !== RELEASE_GATE_SCHEMA || !Array.isArray(value.families)) throw new ReleaseGateValidationError("PROTOCOL_MISMATCH", "Unsupported release manifest schema");
const ids = new Set<string>(); const families = value.families.map((item, index): ParityFamilyEvidenceIR => { const name = `families[${index}]`; if (!record(item) || !STATUSES.has(item.status as ParityStatus) || !["completed", "in_progress", "planned"].includes(item.roadmapStatus as string)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); const id = text(item.id, `${name}.id`); if (ids.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate family ${id}`); ids.add(id); const completedSlices = strings(item.completedSlices, `${name}.completedSlices`); const blockedSlices = strings(item.blockedSlices, `${name}.blockedSlices`); if (item.status !== "BLOCKED" && completedSlices.length === 0) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must declare completed slices`); return { id, name: text(item.name, `${name}.name`), status: item.status as ParityStatus, roadmapStatus: item.roadmapStatus as ParityFamilyEvidenceIR["roadmapStatus"], completedSlices, blockedSlices, acceptance: strings(item.acceptance, `${name}.acceptance`), dependencies: strings(item.dependencies, `${name}.dependencies`) }; }); const ids = new Set<string>(); const families = value.families.map((item, index): ParityFamilyEvidenceIR => { const name = `families[${index}]`; if (!record(item) || !STATUSES.has(item.status as ParityStatus) || !["completed", "in_progress", "planned"].includes(item.roadmapStatus as string)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} is invalid`); const id = text(item.id, `${name}.id`); if (ids.has(id)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `Duplicate family ${id}`); ids.add(id); const completedSlices = strings(item.completedSlices, `${name}.completedSlices`); const blockedSlices = strings(item.blockedSlices, `${name}.blockedSlices`); if (item.status !== "BLOCKED" && completedSlices.length === 0) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", `${name} must declare completed slices`); return { id, name: text(item.name, `${name}.name`), status: item.status as ParityStatus, roadmapStatus: item.roadmapStatus as ParityFamilyEvidenceIR["roadmapStatus"], completedSlices, blockedSlices, acceptance: strings(item.acceptance, `${name}.acceptance`), dependencies: strings(item.dependencies, `${name}.dependencies`) }; });
assertDependencies(families); assertDependencies(families);
return { schemaVersion: RELEASE_GATE_SCHEMA, source: text(value.source, "source", 2048), generatedAt: text(value.generatedAt, "generatedAt", 128), families, evidence: parseEvidence(value.evidence) }; const sourceSha256 = text(value.sourceSha256, "sourceSha256", 64); if (!/^[a-f0-9]{64}$/.test(sourceSha256)) throw new ReleaseGateValidationError("RELEASE_MANIFEST_INVALID", "sourceSha256 is invalid");
return { schemaVersion: RELEASE_GATE_SCHEMA, source: text(value.source, "source", 2048), sourceSha256, generatedAt: text(value.generatedAt, "generatedAt", 128), families, evidence: parseEvidence(value.evidence) };
} }
export function evaluateReleaseManifest(value: unknown): ReleaseGateEvaluationIR { export function evaluateReleaseManifest(value: unknown): ReleaseGateEvaluationIR {

View File

@@ -3,6 +3,9 @@ import { parseGreasePencilData, type GreasePencilDataIR } from "./grease-pencil"
import { parseCompositorGraph, type CompositorGraphIR } from "./compositor"; import { parseCompositorGraph, type CompositorGraphIR } from "./compositor";
import { parseSequencerTimeline, type SequencerTimelineIR } from "./sequencer"; import { parseSequencerTimeline, type SequencerTimelineIR } from "./sequencer";
import { parseTrackingMaskProject, type TrackingMaskProjectIR } from "./tracking-mask"; import { parseTrackingMaskProject, type TrackingMaskProjectIR } from "./tracking-mask";
import { normalizeProjectAssetPath } from "./asset-path";
import { parseEditorWorkflow, type EditorWorkflowIR } from "./editor-workflow";
import { parseScriptSourceInventory, type ScriptSourceInventoryIR } from "./scripting-platform";
export type SceneNodeType = export type SceneNodeType =
| "EMPTY" | "EMPTY"
@@ -474,6 +477,11 @@ export interface SceneSnapshotIR {
greasePencils?: GreasePencilDataIR[]; greasePencils?: GreasePencilDataIR[];
trackingMasks?: TrackingMaskProjectIR; trackingMasks?: TrackingMaskProjectIR;
trackingMaskStatus?: "AVAILABLE" | "BLOCKED"; trackingMaskStatus?: "AVAILABLE" | "BLOCKED";
libraryStatus?: "AVAILABLE" | "BLOCKED";
editorWorkflow?: EditorWorkflowIR;
editorWorkflowStatus?: "AVAILABLE" | "BLOCKED";
scriptSources?: ScriptSourceInventoryIR;
scriptSourceStatus?: "AVAILABLE" | "BLOCKED";
libraries?: Array<{ libraries?: Array<{
id: string; id: string;
name: string; name: string;
@@ -482,6 +490,8 @@ export interface SceneSnapshotIR {
packedByteLength?: number; packedByteLength?: number;
status: "PACKED" | "EXTERNAL_REQUIRED"; status: "PACKED" | "EXTERNAL_REQUIRED";
errorCode?: "LINKED_LIBRARY_RESOURCE_REQUIRED"; errorCode?: "LINKED_LIBRARY_RESOURCE_REQUIRED";
dependencyIds: string[];
readOnly: true;
}>; }>;
animations: AnimationIR[]; animations: AnimationIR[];
nlaTracks?: NlaTrackIR[]; nlaTracks?: NlaTrackIR[];
@@ -1055,5 +1065,72 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
parseTrackingMaskProject(value.trackingMasks); parseTrackingMaskProject(value.trackingMasks);
if (value.trackingMaskStatus !== "AVAILABLE") throw new Error("SceneIR.trackingMaskStatus must be AVAILABLE when trackingMasks is present"); if (value.trackingMaskStatus !== "AVAILABLE") throw new Error("SceneIR.trackingMaskStatus must be AVAILABLE when trackingMasks is present");
} }
if (value.libraryStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.libraryStatus as string)) {
throw new Error("SceneIR.libraryStatus is invalid");
}
if (value.libraries !== undefined) {
const libraries = value.libraries as unknown[];
if (libraries.length > 0 && value.libraryStatus !== "AVAILABLE") throw new Error("SceneIR.libraryStatus must be AVAILABLE when libraries are present");
if (libraries.length > 1024) throw new Error("SceneIR.libraries exceeds the budget");
const libraryIds = new Set<string>();
for (const [index, library] of libraries.entries()) {
if (!isRecord(library)) throw new Error(`SceneIR.libraries[${index}] must be an object`);
const id = requireString(library.id, `libraries[${index}].id`);
if (!id || id.length > 256 || libraryIds.has(id)) throw new Error(`SceneIR.libraries[${index}].id is invalid`);
libraryIds.add(id);
requireString(library.name, `libraries[${index}].name`);
const sourcePath = requireString(library.sourcePath, `libraries[${index}].sourcePath`);
if (typeof library.packed !== "boolean" || library.readOnly !== true || !Array.isArray(library.dependencyIds) ||
library.dependencyIds.length > 1024 || library.dependencyIds.some((dependency) => typeof dependency !== "string") ||
new Set(library.dependencyIds).size !== library.dependencyIds.length ||
!["PACKED", "EXTERNAL_REQUIRED"].includes(library.status as string)) {
throw new Error(`SceneIR.libraries[${index}] is invalid`);
}
let projectPath = true;
try { normalizeProjectAssetPath(sourcePath); } catch { projectPath = false; }
if (!projectPath) {
throw new Error(`SceneIR.libraries[${index}].sourcePath is outside the project`);
}
if (library.status === "PACKED" && (!library.packed || !Number.isSafeInteger(library.packedByteLength) || (library.packedByteLength as number) <= 0)) {
throw new Error(`SceneIR.libraries[${index}] packed payload is invalid`);
}
if (library.status === "EXTERNAL_REQUIRED" && (library.packed || library.errorCode !== "LINKED_LIBRARY_RESOURCE_REQUIRED")) {
throw new Error(`SceneIR.libraries[${index}] external resource state is invalid`);
}
}
for (const [index, library] of libraries.entries()) {
const dependencies = (library as Record<string, unknown>).dependencyIds as string[];
if (dependencies.some((dependency) => !libraryIds.has(dependency))) throw new Error(`SceneIR.libraries[${index}] references a missing dependency`);
}
const byId = new Map(libraries.map((library) => {
const record = library as Record<string, unknown>;
return [record.id as string, record.dependencyIds as string[]] as const;
}));
const active = new Set<string>();
const complete = new Set<string>();
const visit = (id: string): void => {
if (active.has(id)) throw new Error(`SceneIR.libraries dependency cycle includes ${id}`);
if (complete.has(id)) return;
active.add(id);
for (const dependency of byId.get(id) ?? []) visit(dependency);
active.delete(id);
complete.add(id);
};
byId.forEach((_dependencies, id) => visit(id));
}
if (value.editorWorkflowStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.editorWorkflowStatus as string)) {
throw new Error("SceneIR.editorWorkflowStatus is invalid");
}
if (value.editorWorkflow !== undefined) {
parseEditorWorkflow(value.editorWorkflow);
if (value.editorWorkflowStatus !== "AVAILABLE") throw new Error("SceneIR.editorWorkflowStatus must be AVAILABLE when editorWorkflow is present");
}
if (value.scriptSourceStatus !== undefined && !["AVAILABLE", "BLOCKED"].includes(value.scriptSourceStatus as string)) {
throw new Error("SceneIR.scriptSourceStatus is invalid");
}
if (value.scriptSources !== undefined) {
parseScriptSourceInventory(value.scriptSources);
if (value.scriptSourceStatus !== "AVAILABLE") throw new Error("SceneIR.scriptSourceStatus must be AVAILABLE when scriptSources is present");
}
return value as unknown as SceneSnapshotIR; return value as unknown as SceneSnapshotIR;
} }

View File

@@ -3,7 +3,8 @@ import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } fr
import type { ErrorCode } from "./error"; import type { ErrorCode } from "./error";
export const SCRIPTING_PLATFORM_SCHEMA = 1 as const; export const SCRIPTING_PLATFORM_SCHEMA = 1 as const;
export const SCRIPTING_BUDGET = { maxScripts: 1_024, maxPermissions: 64, maxDependencies: 128, maxCpuMs: 60_000, maxMemoryBytes: 512 * 1024 * 1024, maxWallMs: 300_000 } as const; export const SCRIPT_SOURCE_SCHEMA = 1 as const;
export const SCRIPTING_BUDGET = { maxScripts: 1_024, maxPermissions: 64, maxDependencies: 128, maxCpuMs: 60_000, maxMemoryBytes: 512 * 1024 * 1024, maxWallMs: 300_000, maxSourceBytes: 1024 * 1024, maxSourceLines: 65_536 } as const;
export const SCRIPT_PERMISSIONS = ["READ_MAIN", "WRITE_MAIN", "READ_ASSET", "WRITE_ASSET", "SUBMIT_SERVER_JOB"] as const; export const SCRIPT_PERMISSIONS = ["READ_MAIN", "WRITE_MAIN", "READ_ASSET", "WRITE_ASSET", "SUBMIT_SERVER_JOB"] as const;
export type ScriptPermission = typeof SCRIPT_PERMISSIONS[number]; export type ScriptPermission = typeof SCRIPT_PERMISSIONS[number];
@@ -27,6 +28,21 @@ export interface ScriptManifestIR {
addonInstall: false; addonInstall: false;
} }
export interface ScriptingManifestIR { schemaVersion: typeof SCRIPTING_PLATFORM_SCHEMA; scripts: ScriptManifestIR[] } export interface ScriptingManifestIR { schemaVersion: typeof SCRIPTING_PLATFORM_SCHEMA; scripts: ScriptManifestIR[] }
export interface ScriptSourceIR {
id: string;
name: string;
source: string;
sourceSha256: string;
byteLength: number;
lineCount: number;
sourcePath?: string;
internal: boolean;
moduleAutorunRequested: boolean;
readOnly: true;
executionStatus: "BLOCKED";
errorCode: "SCRIPT_POLICY_DENIED" | "SCRIPT_SANDBOX_UNAVAILABLE";
}
export interface ScriptSourceInventoryIR { schemaVersion: typeof SCRIPT_SOURCE_SCHEMA; sources: ScriptSourceIR[] }
export interface ServerScriptJobIR { scriptId: string; sourceSha256: string; inputBlendSha256: string; outputBlendSha256?: string; status: "QUEUED" | "RUNNING" | "COMPLETE" | "FAILED" } export interface ServerScriptJobIR { scriptId: string; sourceSha256: string; inputBlendSha256: string; outputBlendSha256?: string; status: "QUEUED" | "RUNNING" | "COMPLETE" | "FAILED" }
export class ScriptingPlatformValidationError extends Error { export class ScriptingPlatformValidationError extends Error {
@@ -41,6 +57,31 @@ function digest(value: unknown, name: string): string { if (typeof value !== "st
function path(value: unknown, name: string): string { try { return normalizeProjectAssetPath(text(value, name, 2048)); } catch { throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is outside the project`); } } function path(value: unknown, name: string): string { try { return normalizeProjectAssetPath(text(value, name, 2048)); } catch { throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is outside the project`); } }
function integer(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", `${name} exceeds the budget`); return value; } function integer(value: unknown, name: string, minimum: number, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", `${name} exceeds the budget`); return value; }
export function parseScriptSourceInventory(value: unknown): ScriptSourceInventoryIR {
if (!record(value) || value.schemaVersion !== SCRIPT_SOURCE_SCHEMA || !Array.isArray(value.sources)) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported script source inventory schema");
if (value.sources.length > SCRIPTING_BUDGET.maxScripts) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Script source count exceeds the budget");
const ids = new Set<string>(); let totalBytes = 0;
const sources = value.sources.map((item, index): ScriptSourceIR => {
const name = `sources[${index}]`; if (!record(item) || typeof item.source !== "string") throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name} is invalid`);
const id = text(item.id, `${name}.id`); if (!id.startsWith("text:") || ids.has(id)) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name}.id is invalid`); ids.add(id);
const byteLength = integer(item.byteLength, `${name}.byteLength`, 0, SCRIPTING_BUDGET.maxSourceBytes); totalBytes += byteLength;
if (totalBytes > SCRIPTING_BUDGET.maxSourceBytes || new TextEncoder().encode(item.source).byteLength !== byteLength) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", `${name}.source exceeds or disagrees with the byte budget`);
const moduleAutorunRequested = item.moduleAutorunRequested === true;
if (item.readOnly !== true || item.executionStatus !== "BLOCKED" || item.internal !== (item.sourcePath === undefined) ||
item.errorCode !== (moduleAutorunRequested ? "SCRIPT_POLICY_DENIED" : "SCRIPT_SANDBOX_UNAVAILABLE")) throw new ScriptingPlatformValidationError("SCRIPT_POLICY_DENIED", `${name} execution policy is invalid`);
const sourcePath = item.sourcePath === undefined ? undefined : path(item.sourcePath, `${name}.sourcePath`);
const lineCount = integer(item.lineCount, `${name}.lineCount`, 1, SCRIPTING_BUDGET.maxSourceLines);
if (item.source.split("\n").length !== lineCount) throw new ScriptingPlatformValidationError("SCRIPT_MANIFEST_INVALID", `${name}.lineCount disagrees with source`);
return { id, name: text(item.name, `${name}.name`), source: item.source, sourceSha256: digest(item.sourceSha256, `${name}.sourceSha256`), byteLength, lineCount, sourcePath, internal: item.internal as boolean, moduleAutorunRequested, readOnly: true, executionStatus: "BLOCKED", errorCode: item.errorCode as ScriptSourceIR["errorCode"] };
});
return { schemaVersion: SCRIPT_SOURCE_SCHEMA, sources };
}
export async function verifyScriptSource(source: ScriptSourceIR): Promise<boolean> {
const digestBytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(source.source));
return [...new Uint8Array(digestBytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("") === source.sourceSha256;
}
export function parseScriptingManifest(value: unknown): ScriptingManifestIR { export function parseScriptingManifest(value: unknown): ScriptingManifestIR {
if (!record(value) || value.schemaVersion !== SCRIPTING_PLATFORM_SCHEMA || !Array.isArray(value.scripts)) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported scripting manifest schema"); if (!record(value) || value.schemaVersion !== SCRIPTING_PLATFORM_SCHEMA || !Array.isArray(value.scripts)) throw new ScriptingPlatformValidationError("PROTOCOL_MISMATCH", "Unsupported scripting manifest schema");
if (value.scripts.length > SCRIPTING_BUDGET.maxScripts) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Script count exceeds the budget"); if (value.scripts.length > SCRIPTING_BUDGET.maxScripts) throw new ScriptingPlatformValidationError("SCRIPT_BUDGET_EXCEEDED", "Script count exceeds the budget");

View File

@@ -690,6 +690,7 @@ test("keeps N-026 release manifest deterministic and blocks missing evidence", a
expect(result.oldSchema).toContain("PROTOCOL_MISMATCH"); expect(result.oldSchema).toContain("PROTOCOL_MISMATCH");
expect(result.cycle).toContain("RELEASE_DEPENDENCY_CYCLE"); expect(result.cycle).toContain("RELEASE_DEPENDENCY_CYCLE");
expect(result.status).toContain("RELEASE_MANIFEST_INVALID"); expect(result.status).toContain("RELEASE_MANIFEST_INVALID");
expect(result.unbound).toContain("RELEASE_EVIDENCE_MISSING");
}); });
test("keeps N-015 selection history bounded and rejects stale raycast hits", async ({ page }) => { test("keeps N-015 selection history bounded and rejects stale raycast hits", async ({ page }) => {