@runeditrun/rer 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/cli/README.md +21 -0
- package/cli/container.mjs +11 -10
- package/cli/contract.mjs +2 -2
- package/cli/demo-host.mjs +47 -0
- package/cli/demo.mjs +47 -0
- package/cli/dependencies.mjs +26 -4
- package/cli/deploy-command.mjs +94 -0
- package/cli/deploy-inputs.mjs +22 -0
- package/cli/deploy-observation.mjs +28 -0
- package/cli/deploy-owner.mjs +52 -37
- package/cli/deploy-readiness.mjs +16 -0
- package/cli/deploy.mjs +20 -10
- package/cli/detached-server.mjs +1 -1
- package/cli/export.mjs +1 -1
- package/cli/hosted-output.mjs +2 -1
- package/cli/lifecycle.mjs +20 -7
- package/cli/local.mjs +7 -0
- package/cli/main.mjs +1 -1
- package/cli/managed-server.mjs +27 -18
- package/cli/parse.mjs +7 -2
- package/cli/pnpm-probe.mjs +4 -1
- package/cli/portability.mjs +4 -1
- package/cli/scheduled.mjs +2 -2
- package/cli/setup-input.mjs +1 -1
- package/global/CHANGELOG.md +46 -0
- package/global/README.md +9 -2
- package/global/launch/capacity.mjs +1 -1
- package/global/launch/facts.mjs +12 -6
- package/global/launch/install-pinned.mjs +26 -2
- package/global/launch/prepare.mjs +20 -8
- package/global/launch/state.mjs +54 -1
- package/global/main.mjs +18 -20
- package/global/update/command.mjs +2 -1
- package/modules/container/build.mjs +4 -2
- package/modules/container/host.mjs +1 -1
- package/modules/container/ingress.mjs +4 -1
- package/modules/container/migrations.mjs +2 -1
- package/modules/container/worker-engine.mjs +3 -1
- package/modules/demo-host/README.md +30 -0
- package/modules/demo-host/capacity.mjs +19 -0
- package/modules/demo-host/claim.mjs +21 -0
- package/modules/demo-host/container.mjs +74 -0
- package/modules/demo-host/pages.mjs +56 -0
- package/modules/demo-host/plan.mjs +52 -0
- package/modules/demo-host/worker.mjs +72 -0
- package/modules/deploy-kit/README.md +27 -0
- package/modules/deploy-kit/env-file.mjs +2 -1
- package/modules/deploy-kit/health-request.mjs +7 -2
- package/modules/deploy-kit/migration-ledger.mjs +38 -1
- package/modules/deploy-kit/migrations.mjs +38 -2
- package/modules/deploy-kit/owner-cli.mjs +30 -9
- package/modules/deploy-kit/owner-remove.mjs +5 -2
- package/modules/deploy-kit/owner-secret.mjs +67 -22
- package/modules/deploy-kit/owner.mjs +71 -24
- package/modules/deploy-kit/prepare.mjs +3 -1
- package/modules/deploy-kit/provider.mjs +3 -0
- package/modules/erasure/README.md +2 -3
- package/modules/operator-auth/accounts/settings-page.mjs +8 -3
- package/modules/operator-auth/csp.mjs +26 -0
- package/modules/operator-auth/dev/badge.mjs +1 -22
- package/modules/operator-auth/page.mjs +2 -2
- package/modules/operator-auth/routes.mjs +1 -1
- package/modules/portability/README.md +9 -0
- package/modules/portability/bridge.mjs +4 -1
- package/modules/portability/catalogue.mjs +9 -2
- package/modules/portability/index.d.mts +4 -0
- package/modules/portability/index.mjs +1 -0
- package/modules/portability/lines.mjs +3 -1
- package/modules/release/produce.mjs +1 -1
- package/modules/release/source.mjs +5 -2
- package/onboarding/adapter-labels.mjs +1 -0
- package/onboarding/adapter.mjs +9 -4
- package/onboarding/dependency-environment.mjs +33 -0
- package/onboarding/local/owner.mjs +1 -1
- package/onboarding/operator-installer.mjs +10 -4
- package/onboarding/redaction.mjs +22 -3
- package/package.json +6 -1
- package/scripts/publish-check/README.md +22 -1
- package/scripts/publish-check/c2pa.py +121 -0
- package/scripts/publish-check/content_identities.py +3 -1
- package/scripts/publish-check/controller_metadata.py +31 -0
- package/scripts/publish-check/history_scan.py +13 -3
- package/scripts/publish-check/media.py +283 -0
- package/scripts/publish-check/png_text.py +34 -0
- package/scripts/publish-check/public_hygiene.py +39 -4
|
@@ -19,13 +19,16 @@ export function operatorInstaller({ database, features, prepare, prepareData })
|
|
|
19
19
|
values: ({ origin }) => ({ AUTH_LOCAL: 'true', AUTH_ORIGIN: origin, MAIL_PROVIDER: 'development', MAIL_FROM: 'auth@localhost.test' }),
|
|
20
20
|
},
|
|
21
21
|
build: ['pnpm', ['run', 'build:app']],
|
|
22
|
-
|
|
22
|
+
// A demo migrates its own fresh state folder, never the checkout's.
|
|
23
|
+
migrate: ({ demo }) => ['pnpm', ['exec', 'wrangler', 'd1', 'migrations', 'apply', database, '--local', '--persist-to', demo?.state ?? '.wrangler/state', '--config', 'dist/server/wrangler.json']],
|
|
23
24
|
// Setup uses preview's production runtime behind the same public proxy.
|
|
24
25
|
start: previewTarget,
|
|
25
26
|
owner: { existingOwnerViaSignIn: true, displayName: input => input.displayName || 'Owner' },
|
|
26
27
|
signIn: { emailKey: 'OWNER_EMAIL', returnTo: '/setup' },
|
|
27
28
|
});
|
|
28
|
-
|
|
29
|
+
// A demo migrates and claims the owner in its own state, so it never opens the
|
|
30
|
+
// checkout's database: even read-only, SQLite rewrites its -shm index.
|
|
31
|
+
return {...lifecycle,localPrerequisites:async({root,vars,email,demo})=>vars.AUTH_LOCAL==='true'&&vars.MAIL_PROVIDER==='development'&&Boolean(vars.SESSION_SECRET)&&(demo||await operatorPrerequisites(root,email)),install:(root,input,store,onApp,signal)=>installOperator(root,input,store,onApp,signal,lifecycle.hooks)};
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
// Only local runtimes receive the dev key; vite.config.ts passes it to `vite dev` alone.
|
|
@@ -33,7 +36,10 @@ export function operatorInstaller({ database, features, prepare, prepareData })
|
|
|
33
36
|
// without the key. trafficProxy also refuses /cdn-cgi/* on the public listener.
|
|
34
37
|
const devBindings = (devKey, devLogin) => devKey ? { RER_DEV_KEY: devKey, RER_DEV_IDENTITY: devLogin ? 'off' : 'on' } : {};
|
|
35
38
|
const internalPort = port => { if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('A local runtime requires the traffic proxy internal port.'); return String(port); };
|
|
36
|
-
const developmentEnvironment = () => { const { RER_MODELS_SERVICE, RER_MODELS_KEY, ...env } = process.env; return env; };
|
|
37
|
-
|
|
39
|
+
const developmentEnvironment = () => { const { RER_MODELS_SERVICE, RER_MODELS_KEY, RER_DEMO, RER_DEMO_EXPIRES, RER_STATE_DIR, ...env } = process.env; return env; };
|
|
40
|
+
// A demo's Vite persists to its own folder (the seed's vite.config reads RER_STATE_DIR)
|
|
41
|
+
// and its Worker sees RER_DEMO=1 and the demo's end (modules/demo).
|
|
42
|
+
const demoBindings = demo => demo ? { RER_DEMO: '1', RER_DEMO_EXPIRES: demo.ends, RER_STATE_DIR: demo.state } : {};
|
|
43
|
+
export const developmentTarget = ({ root, origin, runtimePort, devKey, devLogin, modelsLive, demo }) => [process.execPath, [projectViteBinary(root), 'dev', '--host', '127.0.0.1', '--port', internalPort(runtimePort), '--strictPort'], { env: { ...developmentEnvironment(), PATH: join(root, 'node_modules', '.bin') + delimiter + (process.env.PATH ?? ''), RER_DEV_ORIGIN: origin, WRANGLER_SEND_METRICS: 'false', X_LOCAL_EXPLORER: 'false', ...devBindings(devKey, devLogin), ...(modelsLive ? { RER_MODELS_SERVICE: modelsLive.baseUrl, RER_MODELS_KEY: modelsLive.apiKey } : {}), ...demoBindings(demo) } }];
|
|
38
44
|
|
|
39
45
|
export const previewTarget = ({ origin, runtimePort, devKey, scheduled }) => [process.execPath, [fileURLToPath(new URL('./production-runtime.mjs', import.meta.url))], { env: { ...process.env, RER_DEV_ORIGIN: origin, RER_RUNTIME_HOST: '127.0.0.1', RER_RUNTIME_PORT: internalPort(runtimePort), WRANGLER_SEND_METRICS: 'false', CLOUDFLARE_INCLUDE_PROCESS_ENV: 'false', X_LOCAL_EXPLORER: 'false', RER_RUNTIME_TRIGGERS: scheduled ? '1' : '0', ...(devKey ? { RER_DEV_KEY: devKey } : {}) } }];
|
package/onboarding/redaction.mjs
CHANGED
|
@@ -35,7 +35,9 @@ const DOUBLE_QUOTED = /"(?:\\[^\r\n]|[^"\\\r\n])*(?:\\(?=[\r\n]|$))?/.source;
|
|
|
35
35
|
const SINGLE_QUOTED = /'(?:\\[^\r\n]|[^'\\\r\n])*(?:\\(?=[\r\n]|$))?/.source;
|
|
36
36
|
const PRIVATE_VALUE = `(?:${DOUBLE_QUOTED}(?:"|(?=[\\r\\n]|$))|${SINGLE_QUOTED}(?:'|(?=[\\r\\n]|$))|[^\\s"'<>;&]+)`;
|
|
37
37
|
const FIELD_VALUE = '["\']?\\s*[=:]\\s*' + NOT_FLAG + PRIVATE_VALUE;
|
|
38
|
-
|
|
38
|
+
// These deploy flags take a binding name, never the secret value. Explicitly
|
|
39
|
+
// registered private bytes remain redacted by the exact-value pass.
|
|
40
|
+
const CLI_PREFIX = '(?<![A-Za-z0-9_-])--(?!(?:set|unset)-secret(?:\\s|$))(?:[A-Za-z0-9]+[_-])*';
|
|
39
41
|
const CLI_ARGUMENT = CLI_PREFIX + '(' + FIELD_NAME + ')\\s+' + NOT_FLAG + '(' + PRIVATE_VALUE + ')';
|
|
40
42
|
// A bare Bearer/Basic value must look like a credential, not the next word of
|
|
41
43
|
// prose such as "Basic checks passed"; after Authorization any value is private.
|
|
@@ -57,9 +59,12 @@ const COOKIE_HEADER = '(?<![A-Za-z0-9_-])(?:Set-)?Cookie["\']?\\s*:\\s*(?:[^\\s=
|
|
|
57
59
|
const SCHEME = '(?<![a-z0-9+.-])[a-z][a-z0-9+.-]*://';
|
|
58
60
|
const USERINFO = SCHEME + '([^\\s/?#"<>`]*)@';
|
|
59
61
|
const SLASHED_USERINFO = SCHEME + '([^\\s/?#:@"<>`\\[\\]]*:(?![0-9]*(?:[/?#\\s"<>`]|$))(?=(?<password>[^\\s"<>`@]{0,128}))\\k<password>)@';
|
|
62
|
+
// Provider token formats recognisable by their prefix alone.
|
|
63
|
+
const TOKEN_PREFIX = '(?:gh[pousr]_|github_pat_|sk-(?:proj-)?|sk_live_|sk_test_|rk_live_|rk_test_|pk_live_|whsec_|SG\\.|xox[baprs]-|AKIA|ASIA|AIza|npm_|glpat-|pypi-|hf_|re_)';
|
|
64
|
+
const KNOWN_TOKEN = '(?<![A-Za-z0-9_])' + TOKEN_PREFIX + '[A-Za-z0-9_./+=-]+';
|
|
60
65
|
const PATTERNS = new RegExp('(?:' + [
|
|
61
66
|
AUTH_HEADER, BARE_AUTH,
|
|
62
|
-
|
|
67
|
+
KNOWN_TOKEN,
|
|
63
68
|
FIELD + FIELD_VALUE, COOKIE_HEADER,
|
|
64
69
|
USERINFO, SLASHED_USERINFO,
|
|
65
70
|
].join('|') + ')', 'gi');
|
|
@@ -287,12 +292,26 @@ export function createRedaction({mask = MASK} = {}) {
|
|
|
287
292
|
return {addSecrets,redact,stream};
|
|
288
293
|
}
|
|
289
294
|
|
|
295
|
+
// A variable whose name marks a credential, or whose value is a URL carrying one.
|
|
296
|
+
export const credentialName = key => CREDENTIAL_NAME.test(key) || CAMEL_CREDENTIAL_NAME.test(key);
|
|
297
|
+
export const credentialUrl = value => CREDENTIAL_URL.test(value);
|
|
298
|
+
// A value that carries a credential whatever its variable is called: a known
|
|
299
|
+
// provider token of at least 16 characters, a JWT, or a URL with userinfo or a
|
|
300
|
+
// credential query or connection-string parameter (libsql://db?authToken=...,
|
|
301
|
+
// ...;AccountKey=...), a private key, or an Authorization header value.
|
|
302
|
+
// A token never continues a path or word (/opt/hf_models_cache is a path).
|
|
303
|
+
const TOKEN_VALUE = new RegExp('(?<![A-Za-z0-9_./-])' + TOKEN_PREFIX + '[A-Za-z0-9_./+=-]{12,}');
|
|
304
|
+
const CREDENTIAL_QUERY = /[?&;](?:auth[_-]?token|access[_-]?token|api[_-]?key|account[_-]?key|shared[_-]?access[_-]?key|token|key|secret|password|sig|signature)=[^&#\s]/i;
|
|
305
|
+
const PRIVATE_KEY_VALUE = /-----BEGIN [A-Z0-9 ]{0,64}PRIVATE KEY/;
|
|
306
|
+
const AUTHORIZATION_VALUE = new RegExp(AUTH_HEADER + '|' + BARE_AUTH, 'i');
|
|
307
|
+
export const credentialValue = value => TOKEN_VALUE.test(value) || jwtRanges(value).length > 0 || CREDENTIAL_URL.test(value) || CREDENTIAL_QUERY.test(value) || PRIVATE_KEY_VALUE.test(value) || AUTHORIZATION_VALUE.test(value);
|
|
308
|
+
|
|
290
309
|
export function credentialValues(input, declared = []) {
|
|
291
310
|
if (!input || typeof input !== 'object') return [];
|
|
292
311
|
return Object.entries(input).flatMap(([key,value]) => {
|
|
293
312
|
// An explicit model declaration remains an exact secret contract, including
|
|
294
313
|
// short values. Environment heuristics must not turn boolean flags into masks.
|
|
295
|
-
if (typeof value === 'string') return declared.includes(key) || (value.length>=8 && (
|
|
314
|
+
if (typeof value === 'string') return declared.includes(key) || (value.length>=8 && (credentialName(key) || credentialUrl(value))) ? [value] : [];
|
|
296
315
|
return value && typeof value==='object' ? credentialValues(value,declared) : [];
|
|
297
316
|
});
|
|
298
317
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runeditrun/rer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "The rer command: install, set up, run and launch Run Edit Run checkouts.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"./modules/portability/migration-history": "./modules/portability/migration-history.mjs",
|
|
64
64
|
"./modules/portability/storage": "./modules/portability/storage.mjs",
|
|
65
65
|
"./modules/erasure/fence": "./modules/erasure/fence.mjs",
|
|
66
|
+
"./modules/deploy-kit/migration-ledger": "./modules/deploy-kit/migration-ledger.mjs",
|
|
66
67
|
"./modules/deploy-kit/owner": "./modules/deploy-kit/owner.mjs",
|
|
67
68
|
"./modules/deploy-kit/owner-cli": "./modules/deploy-kit/owner-cli.mjs",
|
|
68
69
|
"./modules/deploy-kit/prepare": "./modules/deploy-kit/prepare.mjs",
|
|
@@ -84,5 +85,9 @@
|
|
|
84
85
|
},
|
|
85
86
|
"engines": {
|
|
86
87
|
"node": ">=22"
|
|
88
|
+
},
|
|
89
|
+
"repository": {
|
|
90
|
+
"type": "git",
|
|
91
|
+
"url": "git+https://github.com/runeditrun/base.git"
|
|
87
92
|
}
|
|
88
93
|
}
|
|
@@ -21,7 +21,20 @@ bounded public coauthor address rule described in the command guide; a file name
|
|
|
21
21
|
masks only the accepted address for email inspection, leaving names, other
|
|
22
22
|
addresses and every other rule intact.
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
`png_text.py` separates the fields of framed PNG `tEXt` and uncompressed `iTXt`
|
|
25
|
+
chunks before text hygiene inspection. Container length, type and CRC bytes
|
|
26
|
+
cannot extend a mailbox, and these explicit text fields do not use the binary
|
|
27
|
+
mixed-case host heuristic. Raw-byte hygiene inspection retains the original
|
|
28
|
+
binary classification outside those chunks; checksums do not suppress text
|
|
29
|
+
inspection. Gitleaks still receives the original bytes. Compressed metadata is
|
|
30
|
+
not decoded. `media.py` walks image and video containers to find their
|
|
31
|
+
pixel and sample spans; only those spans skip text hygiene, never
|
|
32
|
+
environment or Gitleaks inspection, and metadata around them is still text.
|
|
33
|
+
`c2pa.py` finds the strictly parsed X.509 certificates inside a PNG's C2PA
|
|
34
|
+
manifest store (`caBX`) and returns their Name and rfc822Name value spans,
|
|
35
|
+
which skip text hygiene the same way.
|
|
36
|
+
|
|
37
|
+
Other binary metadata decoding, Git protocol/object/ref modelling, frozen comparison,
|
|
25
38
|
resumable inspection and their tests have been removed. Tests live in `tests.py`
|
|
26
39
|
and `coverage_tests.py` and construct disposable repositories with actual Git.
|
|
27
40
|
|
|
@@ -80,3 +93,11 @@ The build that produces the artifacts (`modules/egress-service/tls/build.mjs`)
|
|
|
80
93
|
regenerates the manifest, and its `--check` mode rejects a stale one. An
|
|
81
94
|
alphabetic top-level domain label ends before a following digit, so a match
|
|
82
95
|
ends at the DER string's boundary rather than absorbing the next tag byte.
|
|
96
|
+
|
|
97
|
+
`controller_metadata.py` recognizes the standalone controller review manifest by
|
|
98
|
+
its exact four-field structure, immutable source revision, descriptor digest,
|
|
99
|
+
relative directory list and path-to-SHA-256 file map, at any JSON path. Only
|
|
100
|
+
those digest value locations qualify. Extra fields, malformed paths or digests
|
|
101
|
+
retain findings. The live-proof controller still independently checks all copied
|
|
102
|
+
file bytes against the pinned source before execution; the scanner does not claim
|
|
103
|
+
to fetch that source. Provider tokens and known environment values remain findings.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Certificate metadata inside a PNG's C2PA manifest store (caBX chunk).
|
|
2
|
+
|
|
3
|
+
Image generators sign a C2PA manifest and embed the signing certificate chain
|
|
4
|
+
in it; a certificate names its holder, often by address. Only values that a
|
|
5
|
+
strict X.509 parse places in an issuer or subject Name, or in a subject or
|
|
6
|
+
issuer alternative name's rfc822Name, are certificate metadata. Every other
|
|
7
|
+
byte of the manifest, assertions included, remains text to inspect.
|
|
8
|
+
"""
|
|
9
|
+
from der import OID, SEQUENCE, BIT_STRING, rdn_values, tlv
|
|
10
|
+
|
|
11
|
+
INTEGER, OCTET_STRING, VERSION, EXTENSIONS, RFC822_NAME = 0x02, 0x04, 0xA0, 0xA3, 0x81
|
|
12
|
+
ALTERNATIVE_NAMES = (bytes.fromhex('551d11'), bytes.fromhex('551d12'))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def manifest_payloads(data):
|
|
16
|
+
if not data.startswith(b'\x89PNG\r\n\x1a\n'):
|
|
17
|
+
return
|
|
18
|
+
offset = 8
|
|
19
|
+
while offset + 12 <= len(data):
|
|
20
|
+
length = int.from_bytes(data[offset:offset + 4], 'big')
|
|
21
|
+
kind, start, end = data[offset + 4:offset + 8], offset + 8, offset + 8 + length
|
|
22
|
+
if end + 4 > len(data):
|
|
23
|
+
return
|
|
24
|
+
if kind == b'caBX':
|
|
25
|
+
yield start, end
|
|
26
|
+
if kind == b'IEND':
|
|
27
|
+
return
|
|
28
|
+
offset = end + 4
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def name_values(data, name):
|
|
32
|
+
# A Name is RDN SETs filling its SEQUENCE exactly.
|
|
33
|
+
values, cursor = [], name[1]
|
|
34
|
+
while cursor < name[2]:
|
|
35
|
+
rdn = rdn_values(data, cursor, name[2])
|
|
36
|
+
if not rdn:
|
|
37
|
+
return None
|
|
38
|
+
cursor, found = rdn
|
|
39
|
+
values += found
|
|
40
|
+
return values
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def alternative_names(data, extensions):
|
|
44
|
+
spans, outer = [], tlv(data, extensions[1], extensions[2])
|
|
45
|
+
if not outer or outer[0] != SEQUENCE or outer[2] != extensions[2]:
|
|
46
|
+
return None
|
|
47
|
+
cursor = outer[1]
|
|
48
|
+
while cursor < outer[2]:
|
|
49
|
+
extension = tlv(data, cursor, outer[2])
|
|
50
|
+
oid = extension and extension[0] == SEQUENCE and tlv(data, extension[1], extension[2])
|
|
51
|
+
if not oid or oid[0] != OID:
|
|
52
|
+
return None
|
|
53
|
+
value = tlv(data, oid[2], extension[2])
|
|
54
|
+
if value and value[0] == 0x01:
|
|
55
|
+
value = tlv(data, value[2], extension[2])
|
|
56
|
+
if not value or value[0] != OCTET_STRING or value[2] != extension[2]:
|
|
57
|
+
return None
|
|
58
|
+
if data[oid[1]:oid[2]] in ALTERNATIVE_NAMES:
|
|
59
|
+
general = tlv(data, value[1], value[2])
|
|
60
|
+
if not general or general[0] != SEQUENCE:
|
|
61
|
+
return None
|
|
62
|
+
entry = general[1]
|
|
63
|
+
while entry < general[2]:
|
|
64
|
+
item = tlv(data, entry, general[2])
|
|
65
|
+
if not item:
|
|
66
|
+
return None
|
|
67
|
+
if item[0] == RFC822_NAME:
|
|
68
|
+
spans.append((item[1], item[2]))
|
|
69
|
+
entry = item[2]
|
|
70
|
+
cursor = extension[2]
|
|
71
|
+
return spans
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def certificate(data, offset, end):
|
|
75
|
+
"""Name and alternative-name value spans of one X.509 Certificate, or None."""
|
|
76
|
+
outer = tlv(data, offset, end)
|
|
77
|
+
if not outer or outer[0] != SEQUENCE:
|
|
78
|
+
return None
|
|
79
|
+
tbs = tlv(data, outer[1], outer[2])
|
|
80
|
+
algorithm = tbs and tbs[0] == SEQUENCE and tlv(data, tbs[2], outer[2])
|
|
81
|
+
signature = algorithm and algorithm[0] == SEQUENCE and tlv(data, algorithm[2], outer[2])
|
|
82
|
+
if not signature or signature[0] != BIT_STRING or signature[2] != outer[2]:
|
|
83
|
+
return None
|
|
84
|
+
fields, cursor = [], tbs[1]
|
|
85
|
+
while cursor < tbs[2]:
|
|
86
|
+
field = tlv(data, cursor, tbs[2])
|
|
87
|
+
if not field:
|
|
88
|
+
return None
|
|
89
|
+
fields.append(field)
|
|
90
|
+
cursor = field[2]
|
|
91
|
+
if fields and fields[0][0] == VERSION:
|
|
92
|
+
fields = fields[1:]
|
|
93
|
+
# serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo.
|
|
94
|
+
if len(fields) < 6 or [field[0] for field in fields[:6]] != [INTEGER] + [SEQUENCE] * 5:
|
|
95
|
+
return None
|
|
96
|
+
issuer, subject = name_values(data, fields[2]), name_values(data, fields[4])
|
|
97
|
+
if issuer is None or subject is None:
|
|
98
|
+
return None
|
|
99
|
+
spans = issuer + subject
|
|
100
|
+
for field in fields[6:]:
|
|
101
|
+
if field[0] == EXTENSIONS:
|
|
102
|
+
names = alternative_names(data, field)
|
|
103
|
+
if names is None:
|
|
104
|
+
return None
|
|
105
|
+
spans += names
|
|
106
|
+
return spans
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def certificate_spans(data):
|
|
110
|
+
"""Value spans of the certificates a PNG's C2PA manifest store carries."""
|
|
111
|
+
spans = []
|
|
112
|
+
for start, end in manifest_payloads(data):
|
|
113
|
+
offset = data.find(SEQUENCE.to_bytes(1, 'big'), start, end)
|
|
114
|
+
while 0 <= offset < end:
|
|
115
|
+
found = certificate(data, offset, end)
|
|
116
|
+
if found is None:
|
|
117
|
+
offset = data.find(SEQUENCE.to_bytes(1, 'big'), offset + 1, end)
|
|
118
|
+
continue
|
|
119
|
+
spans += found
|
|
120
|
+
offset = data.find(SEQUENCE.to_bytes(1, 'big'), tlv(data, offset, end)[2], end)
|
|
121
|
+
return spans
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
from json_identity import Document
|
|
3
3
|
from provenance_pins import module_pins
|
|
4
4
|
from copy_identities import copy_pointers
|
|
5
|
+
from controller_metadata import controller_pointers
|
|
5
6
|
from source_metadata import ARCHIVE, source_pointers
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
def candidate(path):
|
|
9
|
-
return path in ('edition.json', 'sources.lock.json', 'base-copies.json', 'base/PROVENANCE.json') or bool(ARCHIVE.fullmatch(path))
|
|
10
|
+
return path in ('edition.json', 'sources.lock.json', 'base-copies.json', 'base/PROVENANCE.json') or bool(ARCHIVE.fullmatch(path)) or path.endswith('.json')
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class ContentIdentities:
|
|
@@ -22,6 +23,7 @@ class ContentIdentities:
|
|
|
22
23
|
self.documents[path] = document
|
|
23
24
|
pointers = set(module_pins(path, document.value))
|
|
24
25
|
pointers.update(source_pointers(path, document.value))
|
|
26
|
+
pointers.update(controller_pointers(document.value))
|
|
25
27
|
if path in ('base-copies.json', 'base/PROVENANCE.json') and read_regular is not None:
|
|
26
28
|
pointers.update(copy_pointers(document.value, read_regular))
|
|
27
29
|
self.allowed[path] = pointers
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Exact digest locations in immutable standalone-controller review manifests.
|
|
2
|
+
|
|
3
|
+
The live-proof controller verifies these digests against its pinned Git source
|
|
4
|
+
before executing any copied file. This identifies the documented format; it does
|
|
5
|
+
not claim to perform that remote integrity check during publication inspection.
|
|
6
|
+
"""
|
|
7
|
+
from copy_identities import relative_path
|
|
8
|
+
from json_identity import HEX, GIT_REVISION
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def controller_pointers(value):
|
|
12
|
+
if not isinstance(value, dict) or set(value) != {
|
|
13
|
+
'sourceSha', 'descriptorSha256', 'controllerDirectories', 'controllerFiles'}:
|
|
14
|
+
return
|
|
15
|
+
if (not isinstance(value['sourceSha'], str) or not GIT_REVISION.fullmatch(value['sourceSha'])
|
|
16
|
+
or not isinstance(value['descriptorSha256'], str)
|
|
17
|
+
or not HEX.fullmatch(value['descriptorSha256'])):
|
|
18
|
+
return
|
|
19
|
+
directories, files = value['controllerDirectories'], value['controllerFiles']
|
|
20
|
+
if (not isinstance(directories, list) or not all(relative_path(p) for p in directories)
|
|
21
|
+
or len(set(directories)) != len(directories)
|
|
22
|
+
or not isinstance(files, dict) or not files
|
|
23
|
+
or not all(relative_path(p) and isinstance(digest, str) and HEX.fullmatch(digest)
|
|
24
|
+
for p, digest in files.items())
|
|
25
|
+
or any(directory == path or directory.startswith(path + '/')
|
|
26
|
+
for directory in directories for path in files)):
|
|
27
|
+
return
|
|
28
|
+
yield ('sourceSha',)
|
|
29
|
+
yield ('descriptorSha256',)
|
|
30
|
+
for path in files:
|
|
31
|
+
yield ('controllerFiles', path)
|
|
@@ -38,6 +38,9 @@ def scan(repo, ref='HEAD', public_base=None, env_file=None, project=None, reposi
|
|
|
38
38
|
recorded = registry() + exceptions(history.selected(EXCEPTIONS))
|
|
39
39
|
resolver = GitHubResolver()
|
|
40
40
|
public_blobs = {e['oid'] for c in history.public_commits for e in history.entries(c)}
|
|
41
|
+
# A file byte-identical to the same path on the public base is already public.
|
|
42
|
+
public_paths = {e['path']: e['oid'] for e in history.entries(history.public_commit)
|
|
43
|
+
if e['kind'] == 'blob'} if history.public_commit else {}
|
|
41
44
|
report['history']['preExistingCommits'] = sorted(history.public_commits)
|
|
42
45
|
report['history']['newlyPublicCommits'] = [c for c in history.commits if c not in history.public_commits]
|
|
43
46
|
seen_trees, seen_blobs, inspected = set(), set(), {}
|
|
@@ -73,7 +76,7 @@ def scan(repo, ref='HEAD', public_base=None, env_file=None, project=None, reposi
|
|
|
73
76
|
if key not in inspected:
|
|
74
77
|
inspected[key] = text_rules(history.blob(oid), path, config_tree, resolver, hashes)
|
|
75
78
|
rules, provenance = inspected[key]
|
|
76
|
-
entry_scope = 'pre-existing' if scope != 'release-tree' and oid in public_blobs else scope
|
|
79
|
+
entry_scope = 'pre-existing' if (scope != 'release-tree' and oid in public_blobs) or public_paths.get(path) == oid else scope
|
|
77
80
|
for rule in rules:
|
|
78
81
|
add(rule, path, commit, oid, entry_scope)
|
|
79
82
|
if provenance:
|
|
@@ -92,8 +95,10 @@ def scan(repo, ref='HEAD', public_base=None, env_file=None, project=None, reposi
|
|
|
92
95
|
entry = entries_by_commit[history.commit].get(path)
|
|
93
96
|
oid = entry['oid'] if entry and entry['kind'] == 'blob' and history.blob(entry['oid']) == data else None
|
|
94
97
|
rules, _ = text_rules(data, path, selected_context, resolver, hashes)
|
|
98
|
+
public_oid = public_paths.get(path)
|
|
99
|
+
scope = 'pre-existing' if public_oid and (public_oid == oid or history.blob(public_oid) == data) else 'working-tree'
|
|
95
100
|
for rule in rules:
|
|
96
|
-
add(rule, path, history.commit, oid,
|
|
101
|
+
add(rule, path, history.commit, oid, scope)
|
|
97
102
|
if path == 'LICENSE':
|
|
98
103
|
for rule in license_rules(data, recorded):
|
|
99
104
|
add(rule, path, history.commit, oid, 'working-tree')
|
|
@@ -109,7 +114,12 @@ def scan(repo, ref='HEAD', public_base=None, env_file=None, project=None, reposi
|
|
|
109
114
|
path = Path(path).resolve().relative_to(Path(history.repo).resolve()).as_posix()
|
|
110
115
|
entry = entries_by_commit.get(commit, {}).get(path)
|
|
111
116
|
oid = entry['oid'] if entry and entry['kind'] == 'blob' else None
|
|
112
|
-
|
|
117
|
+
if mode == 'dir':
|
|
118
|
+
public_oid = public_paths.get(path)
|
|
119
|
+
held = working_regular(Path(history.repo), path) if public_oid else None
|
|
120
|
+
scope = 'pre-existing' if held and history.blob(public_oid) == held[0] else 'working-tree'
|
|
121
|
+
else:
|
|
122
|
+
scope = 'pre-existing' if (oid and public_paths.get(path) == oid) or (commit != history.commit and oid in public_blobs) else 'release-tree' if commit == history.commit else 'newly-public'
|
|
113
123
|
if hit['RuleID'] in ('generic-api-key', 'hex-api-key'):
|
|
114
124
|
identity_scope = 'working-tree' if mode == 'dir' else commit
|
|
115
125
|
scoped_entries = entries_by_commit.get(commit, {})
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Find the pixel and sample data inside an image or video file.
|
|
2
|
+
|
|
3
|
+
A file is media only when its bytes walk as one complete container, from the
|
|
4
|
+
signature to the end of the file, allowing only NUL padding after it. The path
|
|
5
|
+
and extension never decide, so text renamed .gif, or a valid image with text
|
|
6
|
+
appended, is not media. Each walker returns where its container ends and the
|
|
7
|
+
(start, end) spans of its pixel or sample data (compressed samples and colour
|
|
8
|
+
tables), or None. Everything outside those spans, such as comments, XMP and
|
|
9
|
+
tags, is still text to inspect.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def gif(data):
|
|
14
|
+
if data[:6] not in (b'GIF87a', b'GIF89a') or len(data) < 13:
|
|
15
|
+
return None
|
|
16
|
+
# Colour tables are pixel data too: palette bytes can spell an address.
|
|
17
|
+
offset = 13 + ((3 << ((data[10] & 7) + 1)) if data[10] & 0x80 else 0)
|
|
18
|
+
samples = [(13, offset)]
|
|
19
|
+
while offset < len(data):
|
|
20
|
+
kind = data[offset]
|
|
21
|
+
if kind == 0x3B:
|
|
22
|
+
return offset + 1, samples
|
|
23
|
+
if kind == 0x21:
|
|
24
|
+
offset += 2
|
|
25
|
+
elif kind == 0x2C:
|
|
26
|
+
if offset + 10 > len(data):
|
|
27
|
+
return None
|
|
28
|
+
flags = data[offset + 9]
|
|
29
|
+
# Descriptor, local colour table, then the LZW minimum code size.
|
|
30
|
+
table = offset + 10 + ((3 << ((flags & 7) + 1)) if flags & 0x80 else 0)
|
|
31
|
+
samples.append((offset + 10, table))
|
|
32
|
+
offset = table + 1
|
|
33
|
+
else:
|
|
34
|
+
return None
|
|
35
|
+
# Data sub-blocks: a length byte, that many bytes, ending at length zero.
|
|
36
|
+
start = offset
|
|
37
|
+
while True:
|
|
38
|
+
if offset >= len(data):
|
|
39
|
+
return None
|
|
40
|
+
size = data[offset]
|
|
41
|
+
offset += 1 + size
|
|
42
|
+
if not size:
|
|
43
|
+
break
|
|
44
|
+
if kind == 0x2C:
|
|
45
|
+
samples.append((start, offset))
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def png(data):
|
|
50
|
+
if not data.startswith(b'\x89PNG\r\n\x1a\n'):
|
|
51
|
+
return None
|
|
52
|
+
offset = 8
|
|
53
|
+
samples = []
|
|
54
|
+
while offset + 12 <= len(data):
|
|
55
|
+
length = int.from_bytes(data[offset:offset + 4], 'big')
|
|
56
|
+
kind = data[offset + 4:offset + 8]
|
|
57
|
+
if kind in (b'PLTE', b'IDAT', b'fdAT'):
|
|
58
|
+
samples.append((offset + 8, offset + 8 + length))
|
|
59
|
+
offset += 12 + length
|
|
60
|
+
if kind == b'IEND':
|
|
61
|
+
return offset, samples
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def jpeg(data):
|
|
66
|
+
# A phone stores more images after the first one's end (MPF depth and
|
|
67
|
+
# gain maps): each is walked, and their samples are pixels too.
|
|
68
|
+
walked = jpeg_image(data, 0)
|
|
69
|
+
if walked is None:
|
|
70
|
+
return None
|
|
71
|
+
end, samples = walked
|
|
72
|
+
while data.startswith(b'\xff\xd8', end) and (following := jpeg_image(data, end)):
|
|
73
|
+
end, more = following
|
|
74
|
+
samples += more
|
|
75
|
+
return end, samples
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def jpeg_image(data, offset):
|
|
79
|
+
if not data.startswith(b'\xff\xd8', offset):
|
|
80
|
+
return None
|
|
81
|
+
offset += 2
|
|
82
|
+
samples = []
|
|
83
|
+
while offset + 2 <= len(data):
|
|
84
|
+
if data[offset] != 0xFF:
|
|
85
|
+
return None
|
|
86
|
+
marker = data[offset + 1]
|
|
87
|
+
if marker == 0xFF:
|
|
88
|
+
offset += 1
|
|
89
|
+
continue
|
|
90
|
+
if marker == 0xD9:
|
|
91
|
+
return offset + 2, samples
|
|
92
|
+
if 0xD0 <= marker <= 0xD7 or marker == 0x01:
|
|
93
|
+
offset += 2
|
|
94
|
+
continue
|
|
95
|
+
if offset + 4 > len(data):
|
|
96
|
+
return None
|
|
97
|
+
offset += 2 + int.from_bytes(data[offset + 2:offset + 4], 'big')
|
|
98
|
+
if marker == 0xDA:
|
|
99
|
+
# Entropy-coded data runs to the next marker other than a stuffed
|
|
100
|
+
# zero or a restart marker.
|
|
101
|
+
start = offset
|
|
102
|
+
while True:
|
|
103
|
+
offset = data.find(b'\xff', offset)
|
|
104
|
+
if offset < 0 or offset + 1 >= len(data):
|
|
105
|
+
return None
|
|
106
|
+
if data[offset + 1] == 0 or 0xD0 <= data[offset + 1] <= 0xD7:
|
|
107
|
+
offset += 2
|
|
108
|
+
else:
|
|
109
|
+
break
|
|
110
|
+
samples.append((start, offset))
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
WEBP_SAMPLES = (b'VP8 ', b'VP8L', b'ALPH', b'ANMF')
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def webp(data):
|
|
118
|
+
if len(data) < 20 or data[:4] != b'RIFF' or data[8:12] != b'WEBP':
|
|
119
|
+
return None
|
|
120
|
+
end = int.from_bytes(data[4:8], 'little') + 8
|
|
121
|
+
offset = 12
|
|
122
|
+
samples = []
|
|
123
|
+
while offset + 8 <= end:
|
|
124
|
+
size = int.from_bytes(data[offset + 4:offset + 8], 'little')
|
|
125
|
+
if data[offset:offset + 4] in WEBP_SAMPLES:
|
|
126
|
+
samples.append((offset + 8, offset + 8 + size))
|
|
127
|
+
offset += 8 + size + (size & 1)
|
|
128
|
+
return (end, samples) if offset == end else None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Major brands of MP4, QuickTime, 3GPP and HEIF/AVIF files.
|
|
132
|
+
ISO_BRANDS = {b'isom', b'iso2', b'iso4', b'iso5', b'iso6', b'mp41', b'mp42', b'avc1', b'dash',
|
|
133
|
+
b'M4V ', b'M4A ', b'M4VH', b'M4VP', b'qt ', b'3gp4', b'3gp5', b'3gp6', b'3g2a',
|
|
134
|
+
b'mif1', b'msf1', b'avif', b'avis', b'heic', b'heix', b'heim', b'heis', b'hevc', b'hevx'}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def iso_media(data):
|
|
138
|
+
# MP4 and AVIF share ISO base media boxes, opened by a file-type box with
|
|
139
|
+
# an explicit size and a known major brand.
|
|
140
|
+
if len(data) < 16 or data[4:8] != b'ftyp' or data[8:12] not in ISO_BRANDS:
|
|
141
|
+
return None
|
|
142
|
+
if int.from_bytes(data[:4], 'big') < 16:
|
|
143
|
+
return None
|
|
144
|
+
offset = 0
|
|
145
|
+
kinds, samples = set(), []
|
|
146
|
+
while offset + 8 <= len(data):
|
|
147
|
+
size = int.from_bytes(data[offset:offset + 4], 'big')
|
|
148
|
+
kind = data[offset + 4:offset + 8]
|
|
149
|
+
header = 8
|
|
150
|
+
if size == 1:
|
|
151
|
+
if offset + 16 > len(data):
|
|
152
|
+
return None
|
|
153
|
+
size, header = int.from_bytes(data[offset + 8:offset + 16], 'big'), 16
|
|
154
|
+
elif size == 0:
|
|
155
|
+
# Only a last box after the file type may run to the end.
|
|
156
|
+
size = len(data) - offset
|
|
157
|
+
if size < header:
|
|
158
|
+
return None
|
|
159
|
+
kinds.add(kind)
|
|
160
|
+
if kind == b'mdat':
|
|
161
|
+
samples.append((offset + header, offset + size))
|
|
162
|
+
offset += size
|
|
163
|
+
# A movie has its moov box; a still image (HEIF/AVIF) has meta.
|
|
164
|
+
if offset != len(data) or not kinds & {b'moov', b'meta'}:
|
|
165
|
+
return None
|
|
166
|
+
return offset, samples
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def ebml_number(data, offset, marker):
|
|
170
|
+
# A variable-length EBML number; the marker bit stays in IDs, not sizes.
|
|
171
|
+
if offset >= len(data) or not data[offset]:
|
|
172
|
+
return None, offset
|
|
173
|
+
length = 9 - data[offset].bit_length()
|
|
174
|
+
if offset + length > len(data):
|
|
175
|
+
return None, offset
|
|
176
|
+
value = int.from_bytes(data[offset:offset + length], 'big')
|
|
177
|
+
if not marker:
|
|
178
|
+
value &= (1 << (7 * length)) - 1
|
|
179
|
+
if value == (1 << (7 * length)) - 1:
|
|
180
|
+
value = -1
|
|
181
|
+
return value, offset + length
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
SEGMENT = 0x18538067
|
|
185
|
+
CLUSTER = 0x1F43B675
|
|
186
|
+
# Segment children: a Cluster of unknown size ends at the next one of these.
|
|
187
|
+
SEGMENT_CHILDREN = {CLUSTER, 0x114D9B74, 0x1549A966, 0x1654AE6B, 0x1C53BB6B,
|
|
188
|
+
0x1941A469, 0x1043A770, 0x1254C367}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def ebml_element(data, offset):
|
|
192
|
+
element, content = ebml_number(data, offset, True)
|
|
193
|
+
size, content = ebml_number(data, content, False)
|
|
194
|
+
return element, content, size
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def open_cluster(data, offset, end):
|
|
198
|
+
# A live Cluster of unknown size runs to the next Segment child.
|
|
199
|
+
start = offset
|
|
200
|
+
while offset < end:
|
|
201
|
+
element, content, size = ebml_element(data, offset)
|
|
202
|
+
if element in SEGMENT_CHILDREN:
|
|
203
|
+
break
|
|
204
|
+
if element is None or size is None or size == -1:
|
|
205
|
+
return None
|
|
206
|
+
offset = content + size
|
|
207
|
+
return offset - start
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def webm_segment(data, offset, end):
|
|
211
|
+
# Each Cluster's blocks are the samples; other Segment children are not.
|
|
212
|
+
samples = []
|
|
213
|
+
while offset < end:
|
|
214
|
+
element, content, size = ebml_element(data, offset)
|
|
215
|
+
if element is None or size is None:
|
|
216
|
+
return None
|
|
217
|
+
if size == -1:
|
|
218
|
+
size = open_cluster(data, content, end) if element == CLUSTER else None
|
|
219
|
+
if size is None:
|
|
220
|
+
return None
|
|
221
|
+
if element == CLUSTER:
|
|
222
|
+
samples.append((content, content + size))
|
|
223
|
+
offset = content + size
|
|
224
|
+
return samples if offset == end else None
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def webm(data):
|
|
228
|
+
if not data.startswith(b'\x1a\x45\xdf\xa3') or b'webm' not in data[:64]:
|
|
229
|
+
return None
|
|
230
|
+
offset, samples = 0, []
|
|
231
|
+
while offset < len(data):
|
|
232
|
+
element, content, size = ebml_element(data, offset)
|
|
233
|
+
if element is None or size is None:
|
|
234
|
+
return None
|
|
235
|
+
# Only a live Segment may leave its size unknown: it runs to the end.
|
|
236
|
+
if size == -1:
|
|
237
|
+
if element != SEGMENT:
|
|
238
|
+
return None
|
|
239
|
+
size = len(data) - content
|
|
240
|
+
if element == SEGMENT:
|
|
241
|
+
segment = webm_segment(data, content, content + size)
|
|
242
|
+
if segment is None:
|
|
243
|
+
return None
|
|
244
|
+
samples += segment
|
|
245
|
+
offset = content + size
|
|
246
|
+
return (offset, samples) if offset == len(data) else None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def ico(data):
|
|
250
|
+
if len(data) < 6 or data[:4] != b'\0\0\1\0':
|
|
251
|
+
return None
|
|
252
|
+
count = int.from_bytes(data[4:6], 'little')
|
|
253
|
+
images = []
|
|
254
|
+
for index in range(count):
|
|
255
|
+
entry = 6 + 16 * index
|
|
256
|
+
if entry + 16 > len(data):
|
|
257
|
+
return None
|
|
258
|
+
images.append((int.from_bytes(data[entry + 12:entry + 16], 'little'),
|
|
259
|
+
int.from_bytes(data[entry + 8:entry + 12], 'little')))
|
|
260
|
+
# Image data follows the directory with no gap. An embedded PNG keeps its
|
|
261
|
+
# own compressed samples; bitmap pixels are stored as they are.
|
|
262
|
+
offset = 6 + 16 * count
|
|
263
|
+
samples = []
|
|
264
|
+
for start, size in sorted(images):
|
|
265
|
+
if start != offset or not size:
|
|
266
|
+
return None
|
|
267
|
+
image = png(data[start:start + size])
|
|
268
|
+
if image and image[0] == size:
|
|
269
|
+
samples += [(start + a, start + b) for a, b in image[1]]
|
|
270
|
+
offset += size
|
|
271
|
+
return (offset, samples) if count else None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
FORMATS = (gif, png, jpeg, webp, iso_media, webm, ico)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def sample_spans(data):
|
|
278
|
+
"""The pixel and sample spans of a complete media file, or None."""
|
|
279
|
+
for walk in FORMATS:
|
|
280
|
+
walked = walk(data)
|
|
281
|
+
if walked and walked[0] <= len(data) and not data[walked[0]:].strip(b'\0'):
|
|
282
|
+
return [(start, min(end, walked[0])) for start, end in walked[1]]
|
|
283
|
+
return None
|