405 Commits

Author SHA1 Message Date
Michael Muré
3b8fb6d34d add a new Attestation token, for proving claims or DID ownership
Specification is TBD
2026-01-07 14:06:08 +01:00
Michael Muré
4b99c9f1df Merge pull request #118 from alanshaw/ash/fix/sort-tokens
fix: sort tokens
2025-12-03 17:04:04 +01:00
Michael Muré
39694340cd Merge pull request #117 from ucan-wg/ash/fix/licence-name
fix: license name
2025-12-03 13:32:54 +01:00
Alan Shaw
e93e464977 fix: sort tokens 2025-12-03 12:19:27 +00:00
ash
823af27272 fix: license name
License is Apache-2.0 **OR** MIT i.e. not "and"/"+"

Signed-off-by: ash <alan138@gmail.com>
2025-11-02 22:02:28 +00:00
Michael Muré
70f039a654 update Readme 2025-10-29 15:48:15 +01:00
Michael Muré
d56db6722c Merge pull request #115 from ucan-wg/tooling
Add a somewhat complete toolkit
2025-08-07 14:39:42 +02:00
Michael Muré
0647e4ff8a adjust the toolkit to the new location 2025-08-07 14:28:16 +02:00
Steve Moyer
06f478b9c3 feat(client): add delegation bundle(s) to client.pool 2025-08-05 16:57:19 +02:00
Michael Muré
4aedc4de39 client: add a convenient PrepareDelegation function 2025-08-05 16:57:19 +02:00
Michael Muré
0fd71612d3 exectx: DX improvement: catch proof chain issue early 2025-08-05 16:57:19 +02:00
Michael Muré
29ccdb700e exectx: more tests 2025-08-05 16:57:19 +02:00
Michael Muré
2d031fdbdb exectx: easy access to the root delegation 2025-08-05 16:57:19 +02:00
Michael Muré
1c4a0a9c81 exectx: add test for the main middleware, adjust some HTTP status 2025-08-05 16:57:19 +02:00
Michael Muré
df6dfee210 example: finish readme, small tuning 2025-08-05 16:57:19 +02:00
Michael Muré
c670433335 example: add sub-delegation 2025-08-05 16:57:19 +02:00
Michael Muré
55f38fef4a add server/issuer/client examples, and lots of sanding 2025-08-05 16:57:19 +02:00
Michael Muré
1098a834fb bearer: add some tests 2025-08-05 16:57:19 +02:00
Michael Muré
b95e525cfb exectx: add a minimal middleware to extract the HTTP bearer, and make the UCAN ctx 2025-08-05 16:57:19 +02:00
Michael Muré
cc207aa202 bearer: add a package to add/extract a HTTP Authorisation bearer header 2025-08-05 16:57:19 +02:00
Michael Muré
41d679dfab extargs: make the hash more convenient to use by directly returning the invocation.Option 2025-08-05 16:57:19 +02:00
Michael Muré
cc661f3936 infura requester: properly handle failure 2025-08-05 16:57:19 +02:00
Michael Muré
07d2745966 client,step1: add a KS compatible client, and a script to package tokens for PC step1 2025-08-05 16:57:19 +02:00
rarquevaux
cd9ee535ad fix(ucanCtx): update test 2025-08-05 16:57:19 +02:00
rarquevaux
9e062b0cc7 fix(ucanCtx): pointer receiver and fix to verifyHttp 2025-08-05 16:57:19 +02:00
Michael Muré
cf3eb1b3f7 bump go-ucan 2025-08-05 16:57:19 +02:00
Michael Muré
3b6d70f47a extargs: add ".inf" external args for arbitrary Infura args 2025-08-05 16:57:19 +02:00
Michael Muré
09c8815755 update go-ucan, with the now spec defined container
https://github.com/ucan-wg/go-ucan/blob/main/pkg/container/SPEC.md
2025-08-05 16:57:19 +02:00
Michael Muré
f18ae547ab Update server/extargs/http.go
Co-authored-by: Steve Moyer <smoyer64@gmail.com>
2025-08-05 16:57:19 +02:00
Michael Muré
ad02aa8d4f server: rename "bearer" package to "extargs" 2025-08-05 16:57:19 +02:00
Michael Muré
9c8e9f17fa cli: update following go-ucan changes 2025-08-05 16:57:19 +02:00
Michael Muré
11b4352063 client: fix FindProof() 2025-08-05 16:57:19 +02:00
Michael Muré
b0783bf4a4 add a "core" implementation of an issuer 2025-08-05 16:57:19 +02:00
Michael Muré
2eeaaccc6d client: follow go-ucan changes, improvements, example 2025-08-05 16:57:19 +02:00
Michael Muré
1187674a24 server: minor code fixes 2025-08-05 16:57:19 +02:00
Michael Muré
4c08b22c61 add an early version of a UCAN client 2025-08-05 16:57:19 +02:00
Michael Muré
174bf01c64 client: simpler FindProof() 2025-08-05 16:57:19 +02:00
Michael Muré
4167bf44bd reorg packages, remove outdated server example 2025-08-05 16:57:19 +02:00
Michael Muré
4f4331b677 client: move FindProof testing directly where the code is 2025-08-05 16:57:19 +02:00
Michael Muré
547416e60d client: improve/modularize 2025-08-05 16:57:19 +02:00
Michael Muré
6c1602507b client: add a pool that can find a prood for an invocation 2025-08-05 16:57:19 +02:00
Michael Muré
6f4853cd2f bearer,context: support hash verification of the computed args 2025-08-05 16:57:19 +02:00
Michael Muré
4c25456583 server: update deps 2025-08-05 16:57:19 +02:00
Michael Muré
83f3e4c3b0 server: add a readme 2025-08-05 16:57:19 +02:00
Michael Muré
1178e51b18 server: add a WIP middleware example 2025-08-05 16:57:19 +02:00
Michael Muré
3ec8f56412 Merge pull request #114 from ucan-wg/varsig-did
Integrate go-varsig and go-did-it
2025-08-05 16:56:51 +02:00
Michael Muré
5891bdcd5d update go-varsig, go-did-it, add missing resolution options 2025-08-05 16:54:18 +02:00
Michael Muré
6fb25481ce delegation: add interop testing 2025-08-04 16:45:49 +02:00
Michael Muré
e7edccdd71 wire the DID resolution options 2025-07-31 17:49:01 +02:00
Michael Muré
33e8a8a821 Integrate go-varsig and go-did-it
- go-varsig provides a varsig V1 implementation
- go-did-it provides a complete and extensible DID implementation
2025-07-31 15:00:31 +02:00
Steve Moyer
947add66c5 Merge pull request #112 from ucan-wg/fix/invokedat-to-issued-at
fix(invocation): iat incorrectly named InvokedAt instead of IssuedAt
2025-03-11 13:20:25 -04:00
Steve Moyer
3faf9d598c fix(invocation): also update docs and examples 2025-03-11 13:14:53 -04:00
Steve Moyer
fbf55e98ba Update token/invocation/options.go
Co-authored-by: Michael Muré <batolettre@gmail.com>
Signed-off-by: Steve Moyer <smoyer64@gmail.com>
2025-03-11 12:57:08 -04:00
Steve Moyer
05c2573d95 Update token/invocation/options.go
Co-authored-by: Michael Muré <batolettre@gmail.com>
Signed-off-by: Steve Moyer <smoyer64@gmail.com>
2025-03-11 12:56:49 -04:00
Steve Moyer
d7472621ce Update token/invocation/invocation.go
Co-authored-by: Michael Muré <batolettre@gmail.com>
Signed-off-by: Steve Moyer <smoyer64@gmail.com>
2025-03-11 12:56:34 -04:00
Steve Moyer
7b44f480ee fix(invocation): iat incorrectly named InvokedAt instead of IssuedAt 2025-03-11 12:41:24 -04:00
Michael Muré
5eb7b1a8e4 Merge pull request #111 from ucan-wg/MichaelMure-patch-1
container: typo fix in SPEC.md
2025-03-11 17:34:08 +01:00
Michael Muré
e91afe29d8 Update SPEC.md
Signed-off-by: Michael Muré <batolettre@gmail.com>
2025-03-11 17:32:17 +01:00
Michael Muré
a82bce556f Merge pull request #108 from ucan-wg/cont-test
container: add a helper for test only
2025-02-27 15:00:47 +01:00
Michael Muré
a54d66afe5 Merge pull request #110 from ucan-wg/cleanups
minor cleanups
2025-02-27 14:58:24 +01:00
Michael Muré
14a57d7391 Merge pull request #109 from ucan-wg/downgrade-libp2p
deps: downgrade libp2p
2025-02-27 14:58:17 +01:00
Michael Muré
68469db91a minor cleanups 2025-02-27 14:35:53 +01:00
Michael Muré
cc1d68be0c deps: downgrade libp2p
We only use go-libp2p for the crypto wrappers (github.com/libp2p/go-libp2p/core/crypto), and those have not changed in a while.

We don't need the most recent version of go-libp2p. On the other hand, a too recent version can easily mess with project that aim for an older version of libp2p (often transitively). This PR gives some leeway for version resolution.
2025-02-27 14:28:23 +01:00
Michael Muré
6d3846ac62 container: add a helper for test only 2025-02-27 14:27:02 +01:00
Steve Moyer
6aa33b1547 Merge pull request #107 from ucan-wg/tinygo
fix(policy): make 53-bit limits portable across platforms
2025-02-24 06:25:06 -05:00
Steve Moyer
9589cc8b44 feat(tinygo): make 53-bit limits portable across platforms 2025-02-21 11:03:33 -05:00
Michael Muré
879c0ab03b Merge pull request #106 from ucan-wg/cont-type
container: use the correct type in GetAllDelegations
2025-02-03 15:13:05 +01:00
Michael Muré
fe14765c8d container: use the correct type in GetAllDelegations 2025-02-03 15:02:31 +01:00
Michael Muré
1b28cb49bf Merge pull request #105 from ucan-wg/containertest
rename testvectors into containertest to match previous namings
2025-01-29 15:04:33 +01:00
Michael Muré
e1fc838caf rename testvectors into containertest to match previous namings 2025-01-29 14:46:01 +01:00
Michael Muré
f29b9e94fc Merge pull request #104 from ucan-wg/bundle
token: ditch the generic bundle in favor of specialized struct
2025-01-29 14:37:29 +01:00
Michael Muré
506ed21b94 token: ditch the generic bundle in favor of specialized struct
It's kust cleaner that way, the generic has no upside.
2025-01-29 14:28:13 +01:00
Michael Muré
126177b9e5 Merge pull request #103 from ucan-wg/dlg-is
delegation: add predicates to check if a delegation is a root or powe…
2025-01-29 14:11:29 +01:00
Michael Muré
2bddab8b0c delegation: add predicates to check if a delegation is a root or powerline 2025-01-29 14:07:49 +01:00
Michael Muré
45ead12131 Merge pull request #102 from ucan-wg/cont-keep-sealed
container: Reader should keep around and expose the sealed bytes
2025-01-23 18:06:41 +01:00
Michael Muré
9d5e170409 Merge pull request #101 from ucan-wg/cont-testing
container: expose test vectors for easier testing
2025-01-23 18:06:27 +01:00
Michael Muré
9d047f038d container: Reader should keep around and expose the sealed bytes 2025-01-23 17:13:10 +01:00
Michael Muré
4c5afcb084 container: expose test vectors for easier testing 2025-01-23 17:10:34 +01:00
Michael Muré
e218b49577 Merge pull request #100 from ucan-wg/pol-neq
policy: implement the missing '!='
2025-01-23 12:38:17 +01:00
Michael Muré
4c81ac778e policy: fix tests now that selector evaluation is clearer
https://github.com/ucan-wg/delegation/pull/15
https://github.com/ucan-wg/delegation/pull/16
2025-01-23 12:28:02 +01:00
Michael Muré
7ae65e7c8e policy: fix some code style 2025-01-22 17:30:28 +01:00
Michael Muré
7f9cb6426c policy: implement the missing '!='
https://github.com/ucan-wg/delegation/pull/15
2025-01-22 14:12:41 +01:00
Michael Muré
9c141029c3 container: better naming 2025-01-20 12:14:45 +01:00
Michael Muré
0c6717cfbc Merge pull request #98 from ucan-wg/container-err
container: clarify returned error
2025-01-13 13:54:06 +01:00
Michael Muré
6d7fd28324 container: clarify returned error 2025-01-13 13:50:05 +01:00
Michael Muré
ccc0120e78 Merge pull request #99 from ucan-wg/persona-fix
didtest: offset persona constants
2025-01-13 13:44:18 +01:00
Michael Muré
bf27b97a57 didtest: offset persona constants
It seems like having a persona with a zero value confuses some static checker into warning against a possible nil pointer deref.

An easy fix is to just not have zero.
2025-01-13 13:30:03 +01:00
Michael Muré
10dd4fa6d1 Merge pull request #97 from ucan-wg/ctn-writer-no-cid
container: the writer should not ask for a CID, as its discarded
2025-01-13 12:41:10 +01:00
Michael Muré
5695609f8b container: the writer should not ask for a CID, as its discarded 2025-01-13 12:24:04 +01:00
Michael Muré
b7ddead7c8 Merge pull request #96 from ucan-wg/container-spec
container: add a specification
2025-01-09 13:30:48 +01:00
Michael Muré
9cbec37685 container: no extra keys in the packaging allowed 2025-01-08 21:14:30 +01:00
Michael Muré
00dbd975b1 container: add test vectors 2025-01-08 18:59:30 +01:00
Michael Muré
c792a4cce5 container: typo and clarity in the spec 2025-01-08 18:55:08 +01:00
Michael Muré
8f3f1c775e container: implement header based serialization, also add SPEC test vectors 2025-01-08 17:31:04 +01:00
Michael Muré
704ed25768 container: add a specification 2025-01-07 18:16:27 +01:00
Michael Muré
95bdbc4fc5 Merge pull request #95 from ucan-wg/minor-impro
Minor impro
2024-12-12 16:36:17 +01:00
Michael Muré
416345dba9 args,meta: add a Len() 2024-12-12 16:06:01 +01:00
Michael Muré
042d6dc52f didtest: complete the set of function, finish removing the dependency on testing.T 2024-12-12 16:05:13 +01:00
Michael Muré
8bb3a4f4d0 expose secretbox, notably for the GenerateKey() function that should be public 2024-12-12 16:04:31 +01:00
Michael Muré
47156a8ad6 Merge pull request #94 from ucan-wg/base64-string
container: I/O as strings for base64
2024-12-11 16:12:48 +01:00
Michael Muré
ce6d163627 container: I/O as strings for base64 2024-12-11 16:05:16 +01:00
Michael Muré
c3c2c96008 Merge pull request #93 from ucan-wg/proof-rephrase
invocation: rephrase slightly the proof rules to be less confusing down the line
2024-12-10 13:27:12 +01:00
Steve Moyer
903632695f test(delegation): calculate newCID from new.dagjson so tests are not brittle 2024-12-10 07:14:36 -05:00
Steve Moyer
f2d75b7815 test(delegation): fix newCID after new.dagjson update (brittle) 2024-12-10 07:03:06 -05:00
Steve Moyer
4f09829abe feat(delegationtest): update generator to changed delegation.New constructor 2024-12-10 06:53:01 -05:00
Michael Muré
5660df32b5 delegation,invocation: proper String() function 2024-12-10 12:20:18 +01:00
Michael Muré
2f2a74c7ec delegation: fix following constructor updates 2024-12-10 12:19:47 +01:00
Michael Muré
0592717637 (WIP) refine the token constructors:
- for invocation, reorder the parameters for a more "natural language" mental model
- for delegation, make "subject" a required parameter to avoid make powerline by mistake
- for delegation, implement powerline
2024-12-09 20:39:47 +01:00
Michael Muré
80c2d60ab3 invocation: rephrase slightly the proof rules to be less confusing down the line 2024-12-09 18:53:38 +01:00
Michael Muré
c518c6657a Merge pull request #92 from ucan-wg/generator-cleanup
delegationtest: run go fmt, less noisy code
2024-12-05 15:36:23 +01:00
Michael Muré
78825f4f55 delegationtest: run go fmt, less noisy code 2024-12-05 15:30:23 +01:00
Michael Muré
7f1adbd945 Merge pull request #91 from ucan-wg/fix-everything
Fix everything
2024-12-04 20:09:28 +01:00
Michael Muré
0f59088d0b test: improvement on the generator, small fixes 2024-12-04 19:55:29 +01:00
Michael Muré
72e0f353e7 delegation: add a Bundle to carry around decoded, sealed and Cid 2024-12-04 19:54:46 +01:00
Michael Muré
d0d4ec3abe delegation,invocation: meta can be nil in the model, but not in the view 2024-12-04 19:53:05 +01:00
Michael Muré
bb24081b28 Merge pull request #90 from ucan-wg/fix/extended-field-names
Add support for extended field names in selectors
2024-12-03 11:43:38 +01:00
Fabio Bozzo
3688ccea01 fieldRegex to be more restrictive and consistent 2024-12-02 19:18:01 +01:00
Fabio Bozzo
e9105896d7 Merge branch 'main' into fix/extended-field-names
# Conflicts:
#	pkg/policy/selector/parsing_test.go
2024-12-02 19:05:19 +01:00
Fabio Bozzo
15751c7362 regex to be more restrictive and consistent 2024-12-02 18:30:41 +01:00
Michael Muré
d52218fa5a Merge pull request #88 from ucan-wg/feat/secretbox-meta-encryption
feat(meta): secretbox encryption in place of aes-gcm
2024-12-02 17:34:29 +01:00
Fabio Bozzo
64d3024dec remove aes comparison and add ciphertext overhead comments 2024-12-02 17:31:19 +01:00
Michael Muré
78d37d92ef Merge pull request #89 from ucan-wg/fix/prevent-int-overflow
fix: prevent overflow of int values
2024-12-02 14:41:12 +01:00
Fabio Bozzo
da806b1bc5 remove TODO comment 2024-12-02 14:32:15 +01:00
Fabio Bozzo
311b942a6d validate invocation token args 2024-12-02 14:22:42 +01:00
Fabio Bozzo
56eab758ed move args int validation to their creation 2024-12-02 12:24:06 +01:00
Fabio Bozzo
105323b989 moved unit test 2024-12-02 12:13:56 +01:00
Fabio Bozzo
5b816ccc62 streamline int overflow check for token timestamps 2024-12-02 12:06:06 +01:00
Fabio Bozzo
28272e6900 move int validation to where a error can be returned 2024-12-02 11:59:16 +01:00
Fabio Bozzo
a854389e32 validate token timestamps integer limits 2024-12-02 11:37:03 +01:00
Fabio Bozzo
117a75e2c4 cleanup comments 2024-11-29 19:36:00 +01:00
Fabio Bozzo
a25bfbaf45 fix: extended field names 2024-11-29 19:32:31 +01:00
Fabio Bozzo
bff482f73b add constants.go 2024-11-29 13:04:14 +01:00
Fabio Bozzo
ff79bbb443 go fmt 2024-11-29 13:03:48 +01:00
Fabio Bozzo
3997a86184 fix: prevent overflow of int values 2024-11-29 13:00:00 +01:00
Fabio Bozzo
200d6a8ae2 benchmarks vs aes-gcm 2024-11-28 17:17:10 +01:00
Fabio Bozzo
0349e7e463 feat(meta): secretbox encryption in place of aes-gcm 2024-11-28 16:16:04 +01:00
Steve Moyer
dff52f80c4 Merge pull request #87 from ucan-wg/chore-eliminate-jen-dependency
chore(delegationtest): eliminate dependency on dave/jennifer/jen package
2024-11-28 06:06:39 -05:00
Steve Moyer
5b7a63a2c6 style(delegationtest/generator): adopt suggestions from PR 2024-11-28 05:37:05 -05:00
Steve Moyer
66675f7030 build: tidy Go module files 2024-11-27 15:06:32 -05:00
Steve Moyer
7e54be49e1 chore(delegationtest): eliminate dependency on dave/jennifer/jen package
Resolves #68
2024-11-27 15:01:16 -05:00
Steve Moyer
15535d3474 Merge pull request #86 from ucan-wg/test/invocation-verifies-args-vs-pols
test(invocation): verify arguments versus aggregated policies
2024-11-27 14:56:05 -05:00
Steve Moyer
170e597e71 feat(args): export fluent builder 2024-11-27 12:05:00 -05:00
Steve Moyer
ce1a4b6e32 test(invocation): verify arguments versus aggregated policies 2024-11-27 10:20:40 -05:00
Michael Muré
d1d047cd9e Merge pull request #85 from ucan-wg/container-accessor
container: add a way to check for a single invocation, also iterator
2024-11-27 16:13:29 +01:00
Michael Muré
3680637090 container: add a way to check for a single invocation, also iterator 2024-11-27 16:13:15 +01:00
Michael Muré
1166a68e5c Merge pull request #84 from ucan-wg/args-minor
args: add simple value accessor
2024-11-27 12:26:19 +01:00
Michael Muré
ba4db9bce8 args: add simple value accessor 2024-11-27 12:23:10 +01:00
Michael Muré
20369dba49 Merge pull request #83 from ucan-wg/feat/invocation-witharguments
feat(invocation): add WithArguments Option to set all arguments at once
2024-11-27 10:10:19 +01:00
Steve Moyer
ade2c7f858 feat(invocation): add WithArguments Option to set all arguments at once 2024-11-26 14:39:10 -05:00
Michael Muré
943a318b26 Merge commit '60bdc8873b3d259ff297be4873a9623fcebb7f75' 2024-11-26 11:44:27 +01:00
Michael Muré
2d79cdc54e Merge pull request #82 from ucan-wg/cont-io
container: streamed and non-streamed IO, documentation
2024-11-26 11:37:46 +01:00
Steve Moyer
60bdc8873b feat(delegation): make Token constructors resemble invocation.New() signature 2024-11-25 15:12:29 -05:00
Michael Muré
820057e41e container: streamed and non-streamed IO, documentation 2024-11-21 15:49:29 +01:00
Michael Muré
ba0038b0ae Merge pull request #79 from ucan-wg/meta-args
meta,args: add missing Include, add iterator to use normal or Readonly the same way
2024-11-21 11:27:37 +01:00
Michael Muré
4a4b200312 Merge pull request #81 from ucan-wg/fix/66/allow-dash-in-policy-segment
fix(selector): allow dashes in selector names
2024-11-21 11:27:14 +01:00
Michael Muré
caae2f58bf meta,args: add missing Include, add iterator to use normal or Readonly the same way 2024-11-21 11:25:15 +01:00
Steve Moyer
ec627138cb fix(selector): allow dashes in selector names
This functionality will be further enhanced in #80.

Resolves #66
2024-11-20 12:37:29 -05:00
Michael Muré
4ec409edc6 Merge pull request #74 from ucan-wg/ci
ci: fix after merging to main
2024-11-20 16:11:07 +01:00
Michael Muré
c61fc8d8b3 ci: fix after merging to main 2024-11-20 16:07:49 +01:00
Michael Muré
d90715d1fe delegation: WIP harmonisation of the constructors, issuer verification 2024-11-20 15:59:13 +01:00
Michael Muré
5f8536e480 Merge pull request #60 from ucan-wg/wip
feat(invocation): add token validation prior to execution
2024-11-20 15:48:43 +01:00
Michael Muré
c19e38356d delegationtest: make the generator a main() 2024-11-20 15:35:33 +01:00
Michael Muré
aea1880386 tests: lots of small asjustement 2024-11-20 14:55:48 +01:00
Michael Muré
2fb5a3dc01 Merge pull request #65 from ucan-wg/minor_improv
various sanding everywhere towards building the tookit
2024-11-20 13:44:26 +01:00
Michael Muré
e980d6c0b9 various sanding everywhere towards building the tookit 2024-11-20 12:34:24 +01:00
Steve Moyer
1098e76cba test(invocation): add command.Covers and subject consistency tests
Also improve the maintainability of the tests by a) providing a set of fixed Personas
and then generating a slew of valid delegation tokens, invalid delegation tokens
and proof-chains thereof.
2024-11-19 14:35:46 -05:00
Michael Muré
bb36d61d93 invocation: rework the validation doc, fix missing invocation time check 2024-11-14 16:45:54 +01:00
Michael Muré
417ef78570 fix(invocation): cleanup proof verification algo 2024-11-14 15:03:49 +01:00
Steve Moyer
00d2380f14 fix(invocation): change verifyProof to chain the Issuer field 2024-11-14 07:21:27 -05:00
Michael Muré
8ca088bf27 Merge pull request #61 from ucan-wg/v1-token-container-versioning
feat(container): versioning
2024-11-14 12:40:07 +01:00
Fabio Bozzo
25ca34923f feat(container): versioning for the CBOR container 2024-11-14 12:38:26 +01:00
Steve Moyer
fc4c8f2de1 fix: issues discovered by invocation validation tests 2024-11-13 12:40:25 -05:00
Michael Muré
64b989452f invocation: split out the delegation chain control 2024-11-13 16:58:48 +01:00
Michael Muré
92065ca0d3 invocation: fix proof documentation 2024-11-13 14:50:59 +01:00
Michael Muré
814cec1495 wip API exploration 2024-11-13 12:47:42 +01:00
Steve Moyer
0f70557309 feat(invocation): validate delegation proof chain 2024-11-13 12:43:30 +01:00
Michael Muré
89e4d5d419 wip API exploration 2024-11-13 12:43:29 +01:00
Michael Muré
9057cbcba6 Merge pull request #59 from ucan-wg/command-covers
command: add Covers() for attenuation test, fix incorrect Segments()
2024-11-13 12:38:24 +01:00
Michael Muré
98d9cadcbd command: fast-path for Covers() 2024-11-12 19:01:02 +01:00
Michael Muré
e938d64220 command: add Covers() for attenuation test, fix incorrect Segments() 2024-11-12 18:42:59 +01:00
Michael Muré
c577d73f3e improve readme 2024-11-12 18:15:45 +01:00
Michael Muré
be185a8496 Merge pull request #58 from ucan-wg/readme
add OK readme
2024-11-12 17:19:49 +01:00
Michael Muré
17a57c622a add OK readme 2024-11-12 16:58:17 +01:00
Fabio Bozzo
6298fa28bd Merge pull request #51 from ucan-wg/v1-meta-encryption
feat(meta): values symmetric encryption
2024-11-12 16:48:04 +01:00
Fabio Bozzo
d3e97aaa08 AddEncrypted adds ciphertext always as bytes 2024-11-12 16:37:53 +01:00
Fabio Bozzo
fdff79d23a split WithEncryptedMeta in options.go by type 2024-11-12 16:07:39 +01:00
Fabio Bozzo
a26d836025 validate non-zero aes key and other refactoring 2024-11-12 16:04:33 +01:00
Fabio Bozzo
9f47418bdf fix merge conflict 2024-11-12 15:31:21 +01:00
Fabio Bozzo
81c7a0f80d Merge branch 'v1' into v1-meta-encryption
# Conflicts:
#	pkg/meta/meta.go
2024-11-12 15:30:54 +01:00
Fabio Bozzo
3987e8649c refactor meta/internal/crypto and add key generation method 2024-11-12 15:29:48 +01:00
Michael Muré
17a1d54b6f Merge pull request #57 from ucan-wg/simplify-args
args: simplify API + code
2024-11-12 15:08:14 +01:00
Fabio Bozzo
7cb0f97b30 Merge branch 'v1' into v1-meta-encryption
Signed-off-by: Fabio Bozzo <fabio.bozzo@gmail.com>
2024-11-12 15:07:19 +01:00
Michael Muré
c4a53f42b6 args,meta: harmonize supported types, with fast paths 2024-11-12 13:09:07 +01:00
Michael Muré
522181b16a args: simplify API + code 2024-11-12 12:14:58 +01:00
Michael Muré
633b3d210a token: move nonce generation to a shared space 2024-11-12 10:38:25 +01:00
Steve Moyer
3c705ca150 Merge pull request #49 from ucan-wg/feat/complete-invocation-stub
feat: complete invocation stub
2024-11-07 13:51:47 -05:00
Steve Moyer
1fa2b5e6fc feat(invocation): use dedicated type for invocation.Token.Arguments 2024-11-07 13:50:20 -05:00
Steve Moyer
11bc085c60 test(policy): update to handel statement returned from Match 2024-11-07 13:17:22 -05:00
Steve Moyer
a4a8634eb8 Merge branch 'v1' into feat/complete-invocation-stub 2024-11-07 13:14:03 -05:00
Steve Moyer
d353dfe652 feat(args): create a specialized type to manage invocation Arguments 2024-11-07 12:58:53 -05:00
Michael Muré
1e5ecdc205 Merge pull request #56 from ucan-wg/match-return
policy: make Match also return the failing statement
2024-11-07 15:40:57 +01:00
Michael Muré
f9065d39d8 policy: make Match also return the failing statement
It's a powerful capability, so let's expose it.

I also found a bug in the process.
2024-11-07 15:33:21 +01:00
Michael Muré
cddade4670 Merge pull request #55 from ucan-wg/flacky-test
literal: fix flacky test
2024-11-07 12:07:43 +01:00
Michael Muré
948087744d literal: fix flacky test
also: make tests less noisy everywhere
2024-11-07 12:01:29 +01:00
Steve Moyer
bfb93d6988 Merge branch 'feat/complete-invocation-stub' of github.com:ucan-wg/go-ucan into feat/complete-invocation-stub 2024-11-06 12:57:13 -05:00
Michael Muré
cfcb199818 meta: prevent overwrite of values 2024-11-06 12:57:04 -05:00
Steve Moyer
85557ab6b5 test(policy): refactor matching test from delegation spec 2024-11-06 12:57:01 -05:00
Steve Moyer
adc2b8d0da test(policy): adds example test case from invocation specification 2024-11-06 12:55:49 -05:00
Michael Muré
bcdaf0cca3 invocation: round of cleanups/fixes 2024-11-06 12:55:45 -05:00
Steve Moyer
d754c5837b test(invocation): adds schema round-trip test 2024-11-06 12:51:24 -05:00
Steve Moyer
d89fb395e3 feat(invocation): ipld unseal to invocation 2024-11-06 12:48:41 -05:00
Steve Moyer
4932e32052 feat(invocation): produce example output similar to spec 2024-11-06 12:44:48 -05:00
Steve Moyer
a52b48cf47 feat(invocation): provide New constructor and encoding to wire-format 2024-11-06 12:41:36 -05:00
Steve Moyer
e6e4d85381 Merge branch 'feat/complete-invocation-stub' of github.com:ucan-wg/go-ucan into feat/complete-invocation-stub 2024-11-06 12:22:19 -05:00
Steve Moyer
962e897ff5 Otest(policy): refactor matching test from delegation spec 2024-11-06 12:18:15 -05:00
Steve Moyer
58bb5cdb8f test(policy): adds example test case from invocation specification 2024-11-06 12:16:49 -05:00
Steve Moyer
ce7f653ab0 chore(invocation): clean up left-over (and unneeded) conversion of prf 2024-11-06 12:16:49 -05:00
Michael Muré
7d4f973171 invocation: round of cleanups/fixes 2024-11-06 12:16:48 -05:00
Steve Moyer
3dc0011628 docs(invocation): edit (and finish) Go docs for exported types 2024-11-06 12:16:47 -05:00
Steve Moyer
08f821f23d test(invocation): adds schema round-trip test 2024-11-06 12:16:46 -05:00
Steve Moyer
1b61f2e4db docs(invocation): fix truncated WithEmptyNonce description 2024-11-06 12:16:45 -05:00
Steve Moyer
187e7a869c feat(invocation): ipld unseal to invocation 2024-11-06 12:16:44 -05:00
Steve Moyer
a98653b769 feat(invocation): produce example output similar to spec 2024-11-06 12:16:43 -05:00
Steve Moyer
31d16ac468 feat(invocation): provide New constructor and encoding to wire-format 2024-11-06 12:16:42 -05:00
Michael Muré
d2b004c405 meta: prevent overwrite of values 2024-11-06 18:06:46 +01:00
Michael Muré
884d63a689 Merge pull request #53 from ucan-wg/test-literal
literal: add test suite
2024-11-06 16:45:19 +01:00
Michael Muré
c9f3a6033a delegation: minor fix around meta 2024-11-06 16:43:57 +01:00
Michael Muré
8447499c5a literal: add test suite 2024-11-06 16:42:45 +01:00
Steve Moyer
b4e222f8a0 test(policy): refactor matching test from delegation spec 2024-11-06 10:17:18 -05:00
Michael Muré
41b8600fbc Merge pull request #52 from ucan-wg/meta-readonly
meta: make a read-only version to enforce token immutability
2024-11-06 15:28:19 +01:00
Steve Moyer
824c8fe523 test(policy): adds example test case from invocation specification 2024-11-06 09:25:51 -05:00
Michael Muré
6aeb6a8b70 meta: make a read-only version to enforce token immutability 2024-11-06 15:17:35 +01:00
Steve Moyer
a1aaf47d7c chore(invocation): clean up left-over (and unneeded) conversion of prf 2024-11-05 15:28:31 -05:00
Michael Muré
728696f169 invocation: round of cleanups/fixes 2024-11-05 17:39:39 +01:00
Michael Muré
cfb4446a05 Merge pull request #48 from ucan-wg/pol-partial
policy: implement partial matching, to evaluate in multiple steps with fail early
2024-11-05 16:31:52 +01:00
Michael Muré
06a72868a5 container: add a delegation iterator 2024-11-05 16:26:53 +01:00
Michael Muré
6f9a6fa5c1 literal: make Map and List generic, to avoid requiring conversions 2024-11-05 16:26:14 +01:00
Steve Moyer
f2b4c3ac20 docs(invocation): edit (and finish) Go docs for exported types 2024-11-05 09:35:55 -05:00
Steve Moyer
7a7db684c3 test(invocation): adds schema round-trip test 2024-11-05 08:39:43 -05:00
Steve Moyer
d7454156d2 docs(invocation): fix truncated WithEmptyNonce description 2024-11-05 07:39:51 -05:00
Steve Moyer
d3ad6715d9 feat(invocation): ipld unseal to invocation 2024-11-04 16:07:11 -05:00
Fabio Bozzo
602bdf9c7a fix broken meta_test.gotest 2024-11-04 19:15:34 +01:00
Fabio Bozzo
d21c17c4ca address pr remarks 2024-11-04 19:11:25 +01:00
Michael Muré
72f4ef7b5e policy: fix incorrect test for PartialMatch 2024-11-04 19:07:36 +01:00
Fabio Bozzo
02be4010d6 add array quantifiers tests and tiny fix 2024-11-04 18:50:30 +01:00
Michael Muré
61e031529f policy: use "any" 2024-11-04 18:41:18 +01:00
Michael Muré
19721027e4 literal: rewrite Map() to cover more types 2024-11-04 18:34:31 +01:00
Fabio Bozzo
bc847ee027 fix literal.Map to handle list values too 2024-11-04 17:10:57 +01:00
Fabio Bozzo
5bfe430934 add test cases for missing optional values for all operators 2024-11-04 17:07:32 +01:00
Fabio Bozzo
10b5e1e603 add test cases for optional, like pattern, nested policy 2024-11-04 13:04:54 +01:00
Michael Muré
3cf1de6b67 policy: fix distrinction between "no data" and "optional not data" 2024-11-04 11:15:32 +01:00
Michael Muré
400f689a85 literal: some better typing 2024-11-04 11:15:12 +01:00
Fabio Bozzo
6717a3a89c refactor: simplify optional selector handling
Let Select() handle optional selectors by checking its nil return value, rather than explicitly checking IsOptional()
Applied this pattern consistently across all statement kinds (Equal, Like, All, Any, etc)
2024-11-04 10:56:06 +01:00
Fabio Bozzo
9e9c632ded revert typo 2024-11-01 17:47:47 +01:00
Fabio Bozzo
b210c69173 tests for partial match 2024-11-01 17:43:55 +01:00
Fabio Bozzo
6d85b2ba3c additional tests for optional selectors 2024-11-01 13:07:46 +01:00
Fabio Bozzo
76c015e78b feat(meta): values symmetric encryption 2024-10-31 18:24:54 +01:00
Steve Moyer
fcb527cc52 Merge pull request #50 from ucan-wg/fix/meta-optional-in-delegation
fix: change meta optional in `delegation` `Token`, model and schema
2024-10-24 18:10:30 -04:00
Steve Moyer
89f648a94e docs(did): re-fix typo RE coercion of secp256k1 public keys 2024-10-24 13:44:32 -04:00
Steve Moyer
e1d771333c fix(delegation): meta is optional 2024-10-24 13:43:52 -04:00
Steve Moyer
f44cf8af78 feat(invocation): produce example output similar to spec 2024-10-24 12:59:38 -04:00
Steve Moyer
2b2fc4a13f Merge branch 'v1' into feat/complete-invocation-stub 2024-10-24 11:11:21 -04:00
Michael Muré
6b72799818 Merge pull request #41 from ucan-wg/streamline-did
did: simplify public API, add missing required algorithms
2024-10-24 17:09:10 +02:00
Steve Moyer
ccc85d4697 docs(did): correct typos 2024-10-24 10:59:27 -04:00
Steve Moyer
0d63e90b67 docs(did): add comment explaining why some ECDSA public keys are "coerced" to Secp256k1 public keys 2024-10-24 10:54:43 -04:00
Steve Moyer
d784c92c29 feat(invocation): provide New constructor and encoding to wire-format 2024-10-24 10:44:38 -04:00
Michael Muré
7662fe34db policy: implement partial matching, to evaluate in multiple steps with fail early 2024-10-24 16:21:57 +02:00
Michael Muré
6d0fbd4d5a minor cleanups 2024-10-24 14:46:01 +02:00
Michael Muré
e76354fb0a Merge pull request #47 from ucan-wg/simplify-literal
literal: simplify package with built-in functions
2024-10-24 14:33:33 +02:00
Michael Muré
deaf9c4fe9 Merge pull request #46 from ucan-wg/rework-policies
selector: rework to match the spec, cleanup lots of edge cases
2024-10-24 13:01:10 +02:00
Michael Muré
a1c2c5c067 literal: simplify package with built-in functions 2024-10-24 12:55:06 +02:00
Michael Muré
2c58fedfd5 did: last cleanups 2024-10-24 12:51:21 +02:00
Fabio Bozzo
2ea9f8c93b clamp start idx to length for out of bound 2024-10-24 12:44:25 +02:00
Fabio Bozzo
00ff88ef23 add slicing/indexing for bytes kind 2024-10-24 12:27:01 +02:00
Michael Muré
2ffdf004ac test(did): split the test vector reading code in a separate file/package
tests are much cleaner and explicit now
2024-10-24 12:03:23 +02:00
Michael Muré
a8780f750c policy: remove remnant of policy matching, that concept doesn't really work with complex policies
Maybe it will be revived later.
2024-10-24 11:17:38 +02:00
Michael Muré
c70f68b886 selector: remove incorrect tests
https://github.com/ucan-wg/delegation/issues/5#issuecomment-2434713564

My understanding is that a few of those example are now incorrect/obsolete:

String Index: as per @expede, indexing on strings is now disallowed, to match jq
Optional Iterator, .[][]?: the spec says that iterator on arrays are no-op. It makes sense imho, as jq output multiple values with that, which we can't.
Nested Iterator, .[][]: same reason
2024-10-24 11:11:25 +02:00
Michael Muré
a27eb258e5 selector: remove remnant of policy matching, that concept doesn't really work with complex policies
Maybe it will be revived later.
2024-10-24 11:07:00 +02:00
Fabio Bozzo
866683347f disable support for string indexing 2024-10-23 18:25:16 +02:00
Fabio Bozzo
2fafbe7bf3 enable string indexing/slicing and new selector tests 2024-10-23 17:01:00 +02:00
Fabio Bozzo
1728bf29b8 add more parsing tests 2024-10-23 16:37:35 +02:00
Michael Muré
8fac97b7e7 fix some edge cases:
- slicing is start:end, not start:length, but importantly start to *excluded* end
- backward slicing is illegal
- slicing on string is allowed
- slicing on strings operates on runes, not bytes
2024-10-23 12:18:03 +02:00
Michael Muré
7ad940844c selector: disallow backward slicing 2024-10-23 11:31:41 +02:00
Fabio Bozzo
52ae2eaf60 fix inconsistent test expectations 2024-10-23 11:25:07 +02:00
Michael Muré
570bcdcb6c policy: comment out "filtering" of policies, concept that doesn't really work 2024-10-23 11:25:07 +02:00
Michael Muré
5abb870462 policy: follow the changes in selector, operate on a single returned node 2024-10-23 11:25:07 +02:00
Fabio Bozzo
4ec675861d propose fix for inconsistent test expectations 2024-10-22 17:33:28 +02:00
Fabio Bozzo
b941b507e0 fix optional identity parsing (no idempotent) 2024-10-22 14:13:12 +02:00
Michael Muré
e66beb662e selector: rework to match the spec, cleanup lots of edge cases
- removed field selection against a list
- a selector return exactly one node, or nil
- implemented the full set of slicing, including negative indexes
- plenty of other cleanup and simplification
2024-10-22 13:47:53 +02:00
Steve Moyer
87e25090bb test(did): verifies that ECDSA keys with the secp256k1 curve are "coerced" into crypto.Secp256k1 keys 2024-10-21 15:29:51 -04:00
Steve Moyer
6011f0740a fix(did): finish the GenerateECDSAWithCurve function 2024-10-21 11:03:43 -04:00
Steve Moyer
2bd177ce4d fix(did): correct function names for key/DID generators 2024-10-21 10:59:27 -04:00
Steve Moyer
abda49061d test(did): add test vectors from did:key specification 2024-10-17 15:19:49 -04:00
Steve Moyer
fb978ee574 feat(did): strengthen DID crypto 2024-10-17 15:18:31 -04:00
Steve Moyer
da1310b78a feat(did): strengthens crypto for public key handliing 2024-10-17 07:42:40 -04:00
Michael Muré
ac1b03f144 Merge pull request #44 from ucan-wg/policy-filtering
policy: add a way to filter policies with a path
2024-10-16 14:29:57 +02:00
Michael Muré
7fa3ba1492 policy: add a way to filter policies with a path
Based on exploration work https://github.com/ucan-wg/go-ucan/pull/27
2024-10-16 13:48:01 +02:00
Michael Muré
081d382028 selector: Select is now a method 2024-10-15 17:26:49 +02:00
Michael Muré
2ad3aeb6da policy: match is now a method of Policy 2024-10-15 16:53:06 +02:00
Michael Muré
030db7ec0d invocation: fix comment 2024-10-15 15:41:14 +02:00
Michael Muré
aa4ad2fc10 Merge pull request #42 from ucan-wg/fluent-policy
policy: fluent construction
2024-10-15 13:06:56 +02:00
Michael Muré
51e8d5ce04 policy: fluent construction 2024-10-15 13:06:46 +02:00
Michael Muré
f8b5fa3a32 did: simplify public API, add missing required algorithm 2024-10-15 12:20:35 +02:00
Michael Muré
59da2d1a2c Merge pull request #43 from ucan-wg/dlg-options
delegation: tune Nbf & Exp options
2024-10-15 10:38:50 +02:00
Michael Muré
88ed55b252 delegation: tune Nbf & Exp options 2024-10-14 20:13:49 +02:00
Michael Muré
9051e5250b Merge pull request #40 from ucan-wg/dlg-example
delegation: make the examples more examply, less testy
2024-10-14 12:52:52 +02:00
Michael Muré
5f2877f0ff delegation: make the examples more examply, less testy 2024-10-14 12:46:28 +02:00
Michael Muré
100a510097 pkg/container: harden the CAR file round-trip with fuzzing 2024-10-09 18:38:35 +02:00
Michael Muré
2a51d61b46 Merge pull request #39 from ucan-wg/command-as-string
command: make the type a string, for easier equality test
2024-10-09 18:30:45 +02:00
Michael Muré
3e3c5a83cc command: make the type a string, for easier equality test 2024-10-09 17:53:05 +02:00
Michael Muré
d60fb71156 Merge pull request #22 from ucan-wg/container
add a token container with serialization as CARv1 file
2024-10-08 11:40:00 +02:00
Michael Muré
40639b6715 container: add readme, remove extra formats, remove go-ipld-cbor dependency 2024-10-07 18:46:19 +02:00
Michael Muré
60922ced96 container: split into reader+writer 2024-10-02 13:43:17 +02:00
Michael Muré
f7b4b48791 container: more experiments 2024-10-02 13:43:17 +02:00
Michael Muré
346efbd31d container: add cbor serialisation 2024-10-02 13:43:17 +02:00
Michael Muré
df9beadf9c add a token container with serialization as CARv1 file 2024-10-02 13:43:16 +02:00
Michael Muré
8615f6c72b Merge pull request #35 from ucan-wg/cid-proposal
token: don't store the CID in the token, symmetric API for sealed
2024-10-02 13:42:06 +02:00
Michael Muré
d9739a3bab token: don't store the CID in the token, symmetric API for sealed 2024-10-02 13:40:29 +02:00
Michael Muré
6b8fbcee0a Merge pull request #34 from ucan-wg/invocation-stub
token: add invocation partial stub
2024-10-02 11:02:32 +02:00
Michael Muré
a7037dbc47 token: add invocation partial stub 2024-10-02 10:53:30 +02:00
Michael Muré
50ea43e3fa Merge pull request #33 from ucan-wg/continuous-bench
gha: add a continuous benchmark action
2024-10-01 17:40:50 +02:00
Michael Muré
0ec16a085c gha: add a continuous benchmark action 2024-10-01 17:28:19 +02:00
Michael Muré
2b45f7630e Merge pull request #32 from ucan-wg/cleanups
Cleanups and Token interface
2024-10-01 17:20:52 +02:00
Michael Muré
637973b10b add a token interface 2024-10-01 17:08:57 +02:00
Michael Muré
bb4725d87c rename tokens to token 2024-10-01 17:02:49 +02:00
Michael Muré
8782554a7b cleanup some obsolete testdata 2024-10-01 16:24:17 +02:00
Michael Muré
a8302ad441 Merge pull request #31 from ucan-wg/read-arbitrary
Read arbitrary
2024-10-01 15:32:47 +02:00
Michael Muré
b4dd8c0757 envelope: fuzz Inspect and FindTag 2024-10-01 15:04:46 +02:00
Michael Muré
4201ab2dca tokens: expose Inspect and FindTag 2024-10-01 14:51:44 +02:00
Michael Muré
1b7059c029 token/read: add the usual collections of readers 2024-10-01 14:17:02 +02:00
Michael Muré
f3a5209cec tokens: read arbitrary token 2024-10-01 13:51:56 +02:00
Steve Moyer
a2822f02c7 feat(envelope): expose an Inspect function 2024-09-30 09:58:08 -04:00
Steve Moyer
79955057a3 Merge branch 'v1' into feat/delegation/examples 2024-09-25 15:14:37 -04:00
Steve Moyer
59cebf8e74 style(delegation): display DAG-CBOR encoded as base64 2024-09-25 15:08:42 -04:00
Michael Muré
952a6bb922 did: add a MustParse function 2024-09-25 15:46:01 +02:00
Steve Moyer
2089aa2a6a docs(delegation): add examples for New(), Root() and delegation.Token.FromSeald() 2024-09-24 15:31:34 -04:00
Steve Moyer
4a655506f9 Merge pull request #29 from ucan-wg/feat/reorganize-packages
feat: reorganize packages
2024-09-24 12:52:39 -04:00
Steve Moyer
93dd3ef719 refactor(delegation): merge in options file creation 2024-09-24 11:49:03 -04:00
Steve Moyer
6075c19957 feat: reorganize packages 2024-09-24 11:40:28 -04:00
Michael Muré
6161f2e440 delegation: split options into their own file for API readability 2024-09-24 15:43:46 +02:00
Steve Moyer
5202056cc7 Merge pull request #26 from ucan-wg/feat/calculate-cid
feat(cid): calculate the CID for decoded and newly created Tokeners
2024-09-24 09:39:53 -04:00
Steve Moyer
f779477118 chore(envelope): clean up TODOs 2024-09-24 09:08:07 -04:00
Steve Moyer
4974fed931 docs(delegation): add Go doc for Tag 2024-09-24 09:00:59 -04:00
Steve Moyer
0d9955b7b0 refactor(delegation): align Seal/Unseal names to rest of encode/decode names 2024-09-24 08:49:31 -04:00
Steve Moyer
6dd6f8a229 Merge branch 'feat/calculate-cid' of github.com:ucan-wg/go-ucan into feat/calculate-cid 2024-09-24 08:21:23 -04:00
Steve Moyer
5509cce513 docs: finish Go docs for CID and delegation.Token 2024-09-24 08:20:42 -04:00
Michael Muré
f4ad97679c delegation: add bench for the round-trip steps 2024-09-24 14:09:56 +02:00
Steve Moyer
41c8bc7218 Merge pull request #28 from ucan-wg/feat/calculate-cid2
delegation/envelope: small cleanups
2024-09-24 07:13:36 -04:00
Michael Muré
371bf3b9f5 delegation/envelope: small cleanups 2024-09-24 13:03:35 +02:00
Steve Moyer
b14671009c refactor(delegate): calculate CID in methods that explicitly state that they include that function 2024-09-24 06:46:48 -04:00
Steve Moyer
043c9b160d feat(cid): calculate the CID for decoded and newly created Tokeners 2024-09-19 13:29:33 -04:00
Michael Muré
130168809b Merge pull request #25 from ucan-wg/tune-validation
delegation: tune the validation step
2024-09-19 12:36:41 +02:00
Michael Muré
20886f1b5f Merge pull request #24 from ucan-wg/meta-delegation
delegation: use the fancy Meta
2024-09-19 12:28:42 +02:00
Michael Muré
684c21c7a4 delegation: tune the validation step
- avoid a double parsing when the flow already parsed (command, policy)
- don't require a did:key, as other types are legal (but might require a resolver, TODO)
- make the command a struct instead of a pointer: we don't need to avoid copy, and the pointer can be interpreted as nil
- make the nonce parameter optional, but generate one if none is given
2024-09-19 11:16:33 +02:00
Michael Muré
4749243e3c delegation: use the fancy Meta 2024-09-19 10:48:25 +02:00
Steve Moyer
c7f6034376 test(delegation): move the other relevant tests from the envelope branch 2024-09-18 15:54:46 -04:00
Steve Moyer
55070dcb43 fix(delegation): finish (haha) validation for tokens coming off the wire and for newly constructed tokens 2024-09-18 15:53:29 -04:00
Steve Moyer
fe594e9906 feat(delegation): copy Option plus New and Root constructors from the envelope branch 2024-09-18 15:48:07 -04:00
Steve Moyer
0781b84937 chore(delegation): remove empty file 2024-09-18 15:43:29 -04:00
Steve Moyer
f44b6ec2c3 feat(delegation): Rename View -> Token and make immutable (unexported fields and accessors) 2024-09-18 14:21:53 -04:00
Steve Moyer
abe8a8150a feat(delegation): make model(s) unexported 2024-09-18 13:57:40 -04:00
Steve Moyer
f44b5cb921 feat(delegation): update to provide encoding/decoding straight from/to View 2024-09-18 12:20:54 -04:00
Steve Moyer
baf3edcf88 Merge pull request #23 from ucan-wg/envelope2
refactor(envelope): more tests/docs and functions not a type
2024-09-18 12:14:36 -04:00
Steve Moyer
7107d6bc85 fix(envelope): address PR comments RE IPLD iteration 2024-09-18 12:12:44 -04:00
Steve Moyer
c66dd5b2a4 feat(envelope): decode functions also return the Envelope's CID 2024-09-18 08:22:28 -04:00
Steve Moyer
70dc12d68e refactor(envelope): more tests/docs and functions not a type 2024-09-18 07:50:02 -04:00
Michael Muré
dd1f54694f Merge pull request #20 from ucan-wg/v1-fuzz-match-and-simple-glob
Rewrite simple glob match + FuzzMatch
2024-09-18 11:30:47 +02:00
Michael Muré
ac73cae3ec glob: a bit of reshaping, and a benchmark 2024-09-18 11:24:37 +02:00
Fabio Bozzo
a19d3505fe validateGlobPattern is now responsibility of the caller 2024-09-18 11:12:46 +02:00
Michael Muré
526a34b45d Merge pull request #21 from ucan-wg/meta
add a pkg to handle meta values
2024-09-18 10:02:59 +02:00
Michael Muré
989f409fd0 add a pkg to handle meta values 2024-09-18 10:02:17 +02:00
Steve Moyer
40488dfc3d Merge branch 'v1' of github.com:ucan-wg/go-ucan into v1 2024-09-17 11:19:05 -04:00
Steve Moyer
84122e57bc fix(did): correct UCAN package name 2024-09-17 11:18:50 -04:00
Steve Moyer
4e15349c5e fix(did): correct UCAN package name 2024-09-17 11:17:24 -04:00
Steve Moyer
53cb82a2b4 feat(did): add accessor to report whether this DID is a did:key 2024-09-17 11:12:37 -04:00
Steve Moyer
64936fd061 feat(did): add ToPubKey() and improve crypto tests 2024-09-17 11:12:36 -04:00
Steve Moyer
30be95b20c feat(did): add to/from public key 2024-09-17 11:12:35 -04:00
Fabio Bozzo
16ba4b392d apply pr feedback 2024-09-17 14:15:36 +02:00
Fabio Bozzo
94a0d4d56e remove max input size check from fuzz test 2024-09-17 13:54:42 +02:00
Fabio Bozzo
53ef97231d iterative glob.go and limit FuzzMatch input size 2024-09-17 13:18:12 +02:00
Fabio Bozzo
c960481a10 remove gobwas dep 2024-09-16 18:55:04 +02:00
Fabio Bozzo
d4d4514971 few comments 2024-09-16 18:52:01 +02:00
Fabio Bozzo
282db65900 refactor simpler glob match with one wildcard only 2024-09-16 18:33:36 +02:00
Fabio Bozzo
2459f1a5c3 wip: simple glob match and FuzzMatch 2024-09-16 18:23:14 +02:00
Fabio Bozzo
37f5286315 fix length check per-kind bug 2024-09-16 14:04:45 +02:00
Fabio Bozzo
06e0674c46 Merge pull request #19 from ucan-wg/v1-fix-policy-examples-test
fix: TestPolicyExamples/Any case
2024-09-16 13:34:03 +02:00
Fabio Bozzo
ad03154b6e handle list nodes equality in selector 2024-09-16 13:00:13 +02:00
Michael Muré
700f130858 Merge pull request #18 from ucan-wg/v1-fix-selector-tests
fix(selector): handle broken "Pass" cases
2024-09-16 10:47:20 +02:00
Fabio Bozzo
d57d2a230b handle Optional Iterator selector 2024-09-13 16:26:46 +02:00
Fabio Bozzo
7060d4bb33 handle Optional Null Iterator selector 2024-09-13 15:33:11 +02:00
Fabio Bozzo
a183b627be handle String Slice selector 2024-09-13 14:49:52 +02:00
Fabio Bozzo
dbfff3f70c refactoring resolve func 2024-09-13 14:44:26 +02:00
Fabio Bozzo
cb45d9019b handle Array Slice selector 2024-09-13 14:18:59 +02:00
Fabio Bozzo
2e17ff8550 handle Bytes Index [0] selector and fix case input 2024-09-13 13:06:49 +02:00
Fabio Bozzo
e86e45be73 handle String Index selector 2024-09-12 18:19:50 +02:00
Michael Muré
97c9990045 policy: fix incorrect policy in tests 2024-09-09 19:32:53 +02:00
Michael Muré
6a17270545 fix import paths 2024-09-05 18:49:01 +02:00
Michael Muré
941e6366a3 fix go module path 2024-09-05 18:43:23 +02:00
Michael Muré
8bf7fa173c command: simplify errors 2024-09-05 16:25:22 +02:00
Michael Muré
f731f56dd2 policy: more tests and fixes 2024-09-04 19:08:21 +02:00
Michael Muré
d042b5cdfd policy: fix "all" and "any" to apply a single statement on a collection
Matching the spec
2024-09-04 15:58:08 +02:00
Steve Moyer
d51bbc303c Merge pull request #17 from ucan-wg/varsig
feat(varsig): adds statically generated Varsig headers
2024-09-04 09:04:13 -04:00
Steve Moyer
dfb09cadb9 chore(varsig): change header type for envelope compatibility 2024-09-04 09:03:03 -04:00
Steve Moyer
707ec46338 feat(varsig): adds statically generated Varsig headers
The package currently supports the four kinds of asymmetric key pairs supported
by the go-libp2p/core/crypto library.
2024-09-03 15:40:55 -04:00
Michael Muré
4dd91f95f7 selector: workaround panic() go-ipld-prime with negative index
Waiting on https://github.com/ipld/go-ipld-prime/pull/571 to be released.
2024-09-02 18:08:49 +02:00
190 changed files with 16762 additions and 2428 deletions

34
.github/workflows/bench.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: go continuous benchmark
on:
push:
branches:
- main
permissions:
contents: write
deployments: write
jobs:
benchmark:
name: Run Go continuous benchmark
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "stable"
- name: Run benchmark
run: go test -v ./... -bench=. -run=xxx -benchmem | tee output.txt
- name: Store benchmark result
uses: benchmark-action/github-action-benchmark@v1
with:
name: Go Benchmark
tool: 'go'
output-file-path: output.txt
github-token: ${{ secrets.GITHUB_TOKEN }}
# Push and deploy GitHub pages branch automatically
auto-push: true
# Show alert with commit comment on detecting possible performance regression
alert-threshold: '200%'
comment-on-alert: true

View File

@@ -7,14 +7,14 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
os: [ "ubuntu" ] os: [ "ubuntu" ]
go: [ "1.21.x", "1.22.x", "1.23.x", ] go: [ "1.22.x", "1.23.x", ]
env: env:
COVERAGES: "" COVERAGES: ""
runs-on: ${{ matrix.os }}-latest runs-on: ${{ matrix.os }}-latest
name: ${{ matrix.os}} (go ${{ matrix.go }}) name: ${{ matrix.os}} (go ${{ matrix.go }})
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- uses: actions/setup-go@v2 - uses: actions/setup-go@v5
with: with:
go-version: ${{ matrix.go }} go-version: ${{ matrix.go }}
- name: Go information - name: Go information
@@ -22,6 +22,6 @@ jobs:
go version go version
go env go env
- name: Run tests - name: Run tests
run: go test -v ./... run: go test -v ./... -tags jwx_es256k
- name: Check formatted - name: Check formatted
run: gofmt -l . run: gofmt -l .

View File

@@ -29,7 +29,7 @@ Verbatim copies of both licenses are included below:
``` ```
Apache License Apache License
Version 2.0, January 2004 Version 2.0, January 2004
http://www.apache.org/licenses/ https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

76
Readme.md Normal file
View File

@@ -0,0 +1,76 @@
<div align="center">
<a href="https://github.com/ucan-wg/go-ucan" target="_blank">
<img src="https://raw.githubusercontent.com/ucan-wg/go-ucan/v1/assets/logo.png" alt="go-ucan Logo" height="250"></img>
</a>
<h1 align="center">go-ucan</h1>
<p>
<img src="https://img.shields.io/badge/UCAN-v1.0.0--rc.1-blue" alt="UCAN v1.0.0-rc.1">
<a href="https://github.com/ucan-wg/go-ucan/tags">
<img alt="GitHub Tag" src="https://img.shields.io/github/v/tag/ucan-wg/go-ucan">
</a>
<a href="https://github.com/ucan-wg/go-ucan/actions?query=">
<img src="https://github.com/ucan-wg/go-ucan/actions/workflows/gotest.yml/badge.svg" alt="Build Status">
</a>
<a href="https://ucan-wg.github.io/go-ucan/dev/bench/">
<img alt="Go benchmarks" src="https://img.shields.io/badge/Benchmarks-go-blue">
</a>
<a href="https://github.com/ucan-wg/go-ucan/blob/v1/LICENSE.md">
<img alt="Apache 2.0 OR MIT License" src="https://img.shields.io/badge/License-Apache--2.0_OR_MIT-green">
</a>
<a href="https://pkg.go.dev/github.com/ucan-wg/go-ucan">
<img src="https://img.shields.io/badge/Docs-godoc-blue" alt="Docs">
</a>
<a href="https://discord.gg/JSyFG6XgVM">
<img src="https://img.shields.io/static/v1?label=Discord&message=join%20us!&color=mediumslateblue" alt="Discord">
</a>
</p>
</div>
This is a go library to help the next generation of web and decentralized applications make use
of UCANs in their authorization flows.
User Controlled Authorization Networks (UCANs) are a way of doing authorization where users are fully in control. OAuth is designed for a centralized world, UCAN is the distributed user controlled version.
## Resources
### Specifications
The UCAN specification is separated in multiple sub-spec:
- [Main specification](https://github.com/ucan-wg/spec)
- [Delegation](https://github.com/ucan-wg/delegation/tree/v1_ipld)
- [Invocation](https://github.com/ucan-wg/invocation)
- [Container](https://github.com/ucan-wg/container)
Not implemented yet:
- [Revocation](https://github.com/ucan-wg/revocation/tree/first-draft)
- [Promise](https://github.com/ucan-wg/promise/tree/v1-rc1)
### Talks
- [Decentralizing Auth, and UCAN Too - Brooklyn Zelenka (2023)](https://www.youtube.com/watch?v=MuHfrqw9gQA)
- [What's New in UCAN 1.0 - Brooklyn Zelenka (2024)](https://www.youtube.com/watch?v=-uohQzZcwF4)
## Status
`go-ucan` currently support the required parts of the UCAN specification: the main specification, delegation and invocation. It leverages the sibling project [`go-did-it`](https://github.com/MetaMask/go-did-it) for easy and extensible DID support.
Besides that, `go-ucan` also includes:
- support for encrypted values in token's metadata
## Getting Help
For usage questions, usecases, or issues reach out to us in our `go-ucan`
[Discord channel](https://discord.gg/3EHEQ6M8BC).
We would be happy to try to answer your question or try opening a new issue on
Github.
## UCAN Gopher
Artwork by [Bruno Monts](https://www.instagram.com/bruno_monts). Thank you [Renee French](http://reneefrench.blogspot.com/) for creating the [Go Gopher](https://blog.golang.org/gopher)
## License
This project is licensed under the dual license [Apache 2.0 OR MIT](https://github.com/ucan-wg/go-ucan/blob/v1/LICENSE.md).

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View File

@@ -1,142 +0,0 @@
package command
import (
"errors"
"fmt"
"strings"
)
const (
separator = "/"
)
// ErrNew indicates that the wrapped error was encountered while creating
// a new Command.
var ErrNew = errors.New("failed to create Command from elems")
// ErrParse indicates that the wrapped error was encountered while
// attempting to parse a string as a Command.
var ErrParse = errors.New("failed to parse Command")
// ErrorJoin indicates that the wrapped error was encountered while
// attempting to join a new segment to a Command.
var ErrJoin = errors.New("failed to join segments to Command")
// ErrRequiresLeadingSlash is returned when a parsing a string that
// doesn't start with a [leading slash character].
//
// [leading slash character]: https://github.com/ucan-wg/spec#segment-structure
var ErrRequiresLeadingSlash = parseError("a command requires a leading slash character")
// ErrDisallowsTrailingSlash is returned when parsing a string that [ends
// with a trailing slash character].
//
// [ends with a trailing slash character]: https://github.com/ucan-wg/spec#segment-structure
var ErrDisallowsTrailingSlash = parseError("a command must not include a trailing slash")
// ErrUCANNamespaceReserved is returned to indicate that a Command's
// first segment would contain the [reserved "ucan" namespace].
//
// [reserved "ucan" namespace]: https://github.com/ucan-wg/spec#ucan-namespace
var ErrUCANNamespaceReserved = errors.New("the UCAN namespace is reserved")
// ErrRequiresLowercase is returned if a Command contains, or would contain,
// [uppercase unicode characters].
//
// [uppercase unicode characters]: https://github.com/ucan-wg/spec#segment-structure
var ErrRequiresLowercase = parseError("UCAN path segments must must not contain upper-case characters")
func parseError(msg string) error {
return fmt.Errorf("%w: %s", ErrParse, msg)
}
var _ fmt.Stringer = (*Command)(nil)
// Command is a concrete messages (a "verb") that MUST be unambiguously
// interpretable by the Subject of a UCAN.
//
// A [Command] is composed of a leading slash which is optionally followed
// by one or more slash-separated Segments of lowercase characters.
//
// [Command]: https://github.com/ucan-wg/spec#command
type Command struct {
segments []string
}
// New creates a validated command from the provided list of segment
// strings. An error is returned if an invalid Command would be
// formed
func New(segments ...string) (*Command, error) {
return newCommand(ErrNew, segments...)
}
func newCommand(err error, segments ...string) (*Command, error) {
if len(segments) > 0 && segments[0] == "ucan" {
return nil, fmt.Errorf("%w: %w", err, ErrUCANNamespaceReserved)
}
cmd := Command{segments}
return &cmd, nil
}
// Parse verifies that the provided string contains the required
// [segment structure] and, if valid, returns the resulting
// Command.
//
// [segment structure]: https://github.com/ucan-wg/spec#segment-structure
func Parse(s string) (*Command, error) {
if !strings.HasPrefix(s, "/") {
return nil, ErrRequiresLeadingSlash
}
if len(s) > 1 && strings.HasSuffix(s, "/") {
return nil, ErrDisallowsTrailingSlash
}
if s != strings.ToLower(s) {
return nil, ErrRequiresLowercase
}
// The leading slash will result in the first element from strings.Split
// being an empty string which is removed as strings.Join will ignore it.
return newCommand(ErrParse, strings.Split(s, "/")[1:]...)
}
// [Top] is the most powerful capability.
//
// This function returns a Command that is a wildcard and therefore represents the
// most powerful abilily. As such it should be handle with care and used
// sparingly.
//
// [Top]: https://github.com/ucan-wg/spec#-aka-top
func Top() *Command {
cmd, _ := New()
return cmd
}
// IsValid returns true if the provided string is a valid UCAN command.
func IsValid(s string) bool {
_, err := Parse(s)
return err == nil
}
// Join appends segments to the end of this command using the required
// segment separator.
func (c *Command) Join(segments ...string) (*Command, error) {
return newCommand(ErrJoin, append(c.segments, segments...)...)
}
// Segments returns the ordered segments that comprise the Command as a
// slice of strings.
func (c *Command) Segments() []string {
return c.segments
}
// String returns the composed representation the command. This is also
// the required wire representation (before IPLD encoding occurs.)
func (c *Command) String() string {
return "/" + strings.Join([]string(c.segments), "/")
}

View File

@@ -1,52 +0,0 @@
package literal
import (
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/node/basicnode"
)
func Node(n ipld.Node) ipld.Node {
return n
}
func Link(cid ipld.Link) ipld.Node {
nb := basicnode.Prototype.Link.NewBuilder()
nb.AssignLink(cid)
return nb.Build()
}
func Bool(val bool) ipld.Node {
nb := basicnode.Prototype.Bool.NewBuilder()
nb.AssignBool(val)
return nb.Build()
}
func Int(val int64) ipld.Node {
nb := basicnode.Prototype.Int.NewBuilder()
nb.AssignInt(val)
return nb.Build()
}
func Float(val float64) ipld.Node {
nb := basicnode.Prototype.Float.NewBuilder()
nb.AssignFloat(val)
return nb.Build()
}
func String(val string) ipld.Node {
nb := basicnode.Prototype.String.NewBuilder()
nb.AssignString(val)
return nb.Build()
}
func Bytes(val []byte) ipld.Node {
nb := basicnode.Prototype.Bytes.NewBuilder()
nb.AssignBytes(val)
return nb.Build()
}
func Null() ipld.Node {
nb := basicnode.Prototype.Any.NewBuilder()
nb.AssignNull()
return nb.Build()
}

View File

@@ -1,163 +0,0 @@
package policy
import (
"cmp"
"fmt"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/must"
"github.com/ucan-wg/go-ucan/v1/capability/policy/selector"
)
// Match determines if the IPLD node matches the policy document.
func Match(policy Policy, node ipld.Node) bool {
for _, stmt := range policy {
ok := matchStatement(stmt, node)
if !ok {
return false
}
}
return true
}
func matchStatement(statement Statement, node ipld.Node) bool {
switch statement.Kind() {
case KindEqual:
if s, ok := statement.(equality); ok {
one, _, err := selector.Select(s.selector, node)
if err != nil || one == nil {
return false
}
return datamodel.DeepEqual(s.value, one)
}
case KindGreaterThan:
if s, ok := statement.(equality); ok {
one, _, err := selector.Select(s.selector, node)
if err != nil || one == nil {
return false
}
return isOrdered(s.value, one, gt)
}
case KindGreaterThanOrEqual:
if s, ok := statement.(equality); ok {
one, _, err := selector.Select(s.selector, node)
if err != nil || one == nil {
return false
}
return isOrdered(s.value, one, gte)
}
case KindLessThan:
if s, ok := statement.(equality); ok {
one, _, err := selector.Select(s.selector, node)
if err != nil || one == nil {
return false
}
return isOrdered(s.value, one, lt)
}
case KindLessThanOrEqual:
if s, ok := statement.(equality); ok {
one, _, err := selector.Select(s.selector, node)
if err != nil || one == nil {
return false
}
return isOrdered(s.value, one, lte)
}
case KindNot:
if s, ok := statement.(negation); ok {
return !matchStatement(s.statement, node)
}
case KindAnd:
if s, ok := statement.(connective); ok {
for _, cs := range s.statements {
r := matchStatement(cs, node)
if !r {
return false
}
}
return true
}
case KindOr:
if s, ok := statement.(connective); ok {
if len(s.statements) == 0 {
return true
}
for _, cs := range s.statements {
r := matchStatement(cs, node)
if r {
return true
}
}
return false
}
case KindLike:
if s, ok := statement.(wildcard); ok {
one, _, err := selector.Select(s.selector, node)
if err != nil || one == nil {
return false
}
v, err := one.AsString()
if err != nil {
return false
}
return s.glob.Match(v)
}
case KindAll:
if s, ok := statement.(quantifier); ok {
_, many, err := selector.Select(s.selector, node)
if err != nil || many == nil {
return false
}
for _, n := range many {
ok := Match(s.statements, n)
if !ok {
return false
}
}
return true
}
case KindAny:
if s, ok := statement.(quantifier); ok {
_, many, err := selector.Select(s.selector, node)
if err != nil || many == nil {
return false
}
for _, n := range many {
ok := Match(s.statements, n)
if ok {
return true
}
}
return false
}
}
panic(fmt.Errorf("unimplemented statement kind: %s", statement.Kind()))
}
func isOrdered(expected ipld.Node, actual ipld.Node, satisfies func(order int) bool) bool {
if expected.Kind() == ipld.Kind_Int && actual.Kind() == ipld.Kind_Int {
a := must.Int(actual)
b := must.Int(expected)
return satisfies(cmp.Compare(a, b))
}
if expected.Kind() == ipld.Kind_Float && actual.Kind() == ipld.Kind_Float {
a, err := actual.AsFloat()
if err != nil {
panic(fmt.Errorf("extracting node float: %w", err))
}
b, err := expected.AsFloat()
if err != nil {
panic(fmt.Errorf("extracting selector float: %w", err))
}
return satisfies(cmp.Compare(a, b))
}
return false
}
func gt(order int) bool { return order == 1 }
func gte(order int) bool { return order == 0 || order == 1 }
func lt(order int) bool { return order == -1 }
func lte(order int) bool { return order == 0 || order == -1 }

View File

@@ -1,420 +0,0 @@
package policy
import (
"fmt"
"testing"
"github.com/ipfs/go-cid"
"github.com/ipld/go-ipld-prime"
cidlink "github.com/ipld/go-ipld-prime/linking/cid"
"github.com/ipld/go-ipld-prime/node/basicnode"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/v1/capability/policy/literal"
"github.com/ucan-wg/go-ucan/v1/capability/policy/selector"
)
func TestMatch(t *testing.T) {
t.Run("equality", func(t *testing.T) {
t.Run("string", func(t *testing.T) {
np := basicnode.Prototype.String
nb := np.NewBuilder()
nb.AssignString("test")
nd := nb.Build()
pol := Policy{Equal(selector.MustParse("."), literal.String("test"))}
ok := Match(pol, nd)
require.True(t, ok)
pol = Policy{Equal(selector.MustParse("."), literal.String("test2"))}
ok = Match(pol, nd)
require.False(t, ok)
pol = Policy{Equal(selector.MustParse("."), literal.Int(138))}
ok = Match(pol, nd)
require.False(t, ok)
})
t.Run("int", func(t *testing.T) {
np := basicnode.Prototype.Int
nb := np.NewBuilder()
nb.AssignInt(138)
nd := nb.Build()
pol := Policy{Equal(selector.MustParse("."), literal.Int(138))}
ok := Match(pol, nd)
require.True(t, ok)
pol = Policy{Equal(selector.MustParse("."), literal.Int(1138))}
ok = Match(pol, nd)
require.False(t, ok)
pol = Policy{Equal(selector.MustParse("."), literal.String("138"))}
ok = Match(pol, nd)
require.False(t, ok)
})
t.Run("float", func(t *testing.T) {
np := basicnode.Prototype.Float
nb := np.NewBuilder()
nb.AssignFloat(1.138)
nd := nb.Build()
pol := Policy{Equal(selector.MustParse("."), literal.Float(1.138))}
ok := Match(pol, nd)
require.True(t, ok)
pol = Policy{Equal(selector.MustParse("."), literal.Float(11.38))}
ok = Match(pol, nd)
require.False(t, ok)
pol = Policy{Equal(selector.MustParse("."), literal.String("138"))}
ok = Match(pol, nd)
require.False(t, ok)
})
t.Run("IPLD Link", func(t *testing.T) {
l0 := cidlink.Link{Cid: cid.MustParse("bafybeif4owy5gno5lwnixqm52rwqfodklf76hsetxdhffuxnplvijskzqq")}
l1 := cidlink.Link{Cid: cid.MustParse("bafkreifau35r7vi37tvbvfy3hdwvgb4tlflqf7zcdzeujqcjk3rsphiwte")}
np := basicnode.Prototype.Link
nb := np.NewBuilder()
nb.AssignLink(l0)
nd := nb.Build()
pol := Policy{Equal(selector.MustParse("."), literal.Link(l0))}
ok := Match(pol, nd)
require.True(t, ok)
pol = Policy{Equal(selector.MustParse("."), literal.Link(l1))}
ok = Match(pol, nd)
require.False(t, ok)
pol = Policy{Equal(selector.MustParse("."), literal.String("bafybeif4owy5gno5lwnixqm52rwqfodklf76hsetxdhffuxnplvijskzqq"))}
ok = Match(pol, nd)
require.False(t, ok)
})
t.Run("string in map", func(t *testing.T) {
np := basicnode.Prototype.Map
nb := np.NewBuilder()
ma, _ := nb.BeginMap(1)
ma.AssembleKey().AssignString("foo")
ma.AssembleValue().AssignString("bar")
ma.Finish()
nd := nb.Build()
pol := Policy{Equal(selector.MustParse(".foo"), literal.String("bar"))}
ok := Match(pol, nd)
require.True(t, ok)
pol = Policy{Equal(selector.MustParse(".[\"foo\"]"), literal.String("bar"))}
ok = Match(pol, nd)
require.True(t, ok)
pol = Policy{Equal(selector.MustParse(".foo"), literal.String("baz"))}
ok = Match(pol, nd)
require.False(t, ok)
pol = Policy{Equal(selector.MustParse(".foobar"), literal.String("bar"))}
ok = Match(pol, nd)
require.False(t, ok)
})
t.Run("string in list", func(t *testing.T) {
np := basicnode.Prototype.List
nb := np.NewBuilder()
la, _ := nb.BeginList(1)
la.AssembleValue().AssignString("foo")
la.Finish()
nd := nb.Build()
pol := Policy{Equal(selector.MustParse(".[0]"), literal.String("foo"))}
ok := Match(pol, nd)
require.True(t, ok)
pol = Policy{Equal(selector.MustParse(".[1]"), literal.String("foo"))}
ok = Match(pol, nd)
require.False(t, ok)
})
})
t.Run("inequality", func(t *testing.T) {
t.Run("gt int", func(t *testing.T) {
np := basicnode.Prototype.Int
nb := np.NewBuilder()
nb.AssignInt(138)
nd := nb.Build()
pol := Policy{GreaterThan(selector.MustParse("."), literal.Int(1))}
ok := Match(pol, nd)
require.True(t, ok)
})
t.Run("gte int", func(t *testing.T) {
np := basicnode.Prototype.Int
nb := np.NewBuilder()
nb.AssignInt(138)
nd := nb.Build()
pol := Policy{GreaterThanOrEqual(selector.MustParse("."), literal.Int(1))}
ok := Match(pol, nd)
require.True(t, ok)
pol = Policy{GreaterThanOrEqual(selector.MustParse("."), literal.Int(138))}
ok = Match(pol, nd)
require.True(t, ok)
})
t.Run("gt float", func(t *testing.T) {
np := basicnode.Prototype.Float
nb := np.NewBuilder()
nb.AssignFloat(1.38)
nd := nb.Build()
pol := Policy{GreaterThan(selector.MustParse("."), literal.Float(1))}
ok := Match(pol, nd)
require.True(t, ok)
})
t.Run("gte float", func(t *testing.T) {
np := basicnode.Prototype.Float
nb := np.NewBuilder()
nb.AssignFloat(1.38)
nd := nb.Build()
pol := Policy{GreaterThanOrEqual(selector.MustParse("."), literal.Float(1))}
ok := Match(pol, nd)
require.True(t, ok)
pol = Policy{GreaterThanOrEqual(selector.MustParse("."), literal.Float(1.38))}
ok = Match(pol, nd)
require.True(t, ok)
})
t.Run("lt int", func(t *testing.T) {
np := basicnode.Prototype.Int
nb := np.NewBuilder()
nb.AssignInt(138)
nd := nb.Build()
pol := Policy{LessThan(selector.MustParse("."), literal.Int(1138))}
ok := Match(pol, nd)
require.True(t, ok)
})
t.Run("lte int", func(t *testing.T) {
np := basicnode.Prototype.Int
nb := np.NewBuilder()
nb.AssignInt(138)
nd := nb.Build()
pol := Policy{LessThanOrEqual(selector.MustParse("."), literal.Int(1138))}
ok := Match(pol, nd)
require.True(t, ok)
pol = Policy{LessThanOrEqual(selector.MustParse("."), literal.Int(138))}
ok = Match(pol, nd)
require.True(t, ok)
})
})
t.Run("negation", func(t *testing.T) {
np := basicnode.Prototype.Bool
nb := np.NewBuilder()
nb.AssignBool(false)
nd := nb.Build()
pol := Policy{Not(Equal(selector.MustParse("."), literal.Bool(true)))}
ok := Match(pol, nd)
require.True(t, ok)
pol = Policy{Not(Equal(selector.MustParse("."), literal.Bool(false)))}
ok = Match(pol, nd)
require.False(t, ok)
})
t.Run("conjunction", func(t *testing.T) {
np := basicnode.Prototype.Int
nb := np.NewBuilder()
nb.AssignInt(138)
nd := nb.Build()
pol := Policy{
And(
GreaterThan(selector.MustParse("."), literal.Int(1)),
LessThan(selector.MustParse("."), literal.Int(1138)),
),
}
ok := Match(pol, nd)
require.True(t, ok)
pol = Policy{
And(
GreaterThan(selector.MustParse("."), literal.Int(1)),
Equal(selector.MustParse("."), literal.Int(1138)),
),
}
ok = Match(pol, nd)
require.False(t, ok)
pol = Policy{And()}
ok = Match(pol, nd)
require.True(t, ok)
})
t.Run("disjunction", func(t *testing.T) {
np := basicnode.Prototype.Int
nb := np.NewBuilder()
nb.AssignInt(138)
nd := nb.Build()
pol := Policy{
Or(
GreaterThan(selector.MustParse("."), literal.Int(138)),
LessThan(selector.MustParse("."), literal.Int(1138)),
),
}
ok := Match(pol, nd)
require.True(t, ok)
pol = Policy{
Or(
GreaterThan(selector.MustParse("."), literal.Int(138)),
Equal(selector.MustParse("."), literal.Int(1138)),
),
}
ok = Match(pol, nd)
require.False(t, ok)
pol = Policy{Or()}
ok = Match(pol, nd)
require.True(t, ok)
})
t.Run("wildcard", func(t *testing.T) {
pattern := `Alice\*, Bob*, Carol.`
for _, s := range []string{
"Alice*, Bob, Carol.",
"Alice*, Bob, Dan, Erin, Carol.",
"Alice*, Bob , Carol.",
"Alice*, Bob*, Carol.",
} {
func(s string) {
t.Run(fmt.Sprintf("pass %s", s), func(t *testing.T) {
np := basicnode.Prototype.String
nb := np.NewBuilder()
nb.AssignString(s)
nd := nb.Build()
statement, err := Like(selector.MustParse("."), pattern)
require.NoError(t, err)
pol := Policy{statement}
ok := Match(pol, nd)
require.True(t, ok)
})
}(s)
}
for _, s := range []string{
"Alice*, Bob, Carol",
"Alice*, Bob*, Carol!",
"Alice Cooper, Bob, Carol.",
"Alice, Bob, Carol.",
" Alice*, Bob, Carol. ",
} {
func(s string) {
t.Run(fmt.Sprintf("fail %s", s), func(t *testing.T) {
np := basicnode.Prototype.String
nb := np.NewBuilder()
nb.AssignString(s)
nd := nb.Build()
statement, err := Like(selector.MustParse("."), pattern)
require.NoError(t, err)
pol := Policy{statement}
ok := Match(pol, nd)
require.False(t, ok)
})
}(s)
}
})
t.Run("quantification", func(t *testing.T) {
buildValueNode := func(v int64) ipld.Node {
np := basicnode.Prototype.Map
nb := np.NewBuilder()
ma, _ := nb.BeginMap(1)
ma.AssembleKey().AssignString("value")
ma.AssembleValue().AssignInt(v)
ma.Finish()
return nb.Build()
}
t.Run("all", func(t *testing.T) {
np := basicnode.Prototype.List
nb := np.NewBuilder()
la, _ := nb.BeginList(5)
la.AssembleValue().AssignNode(buildValueNode(5))
la.AssembleValue().AssignNode(buildValueNode(10))
la.AssembleValue().AssignNode(buildValueNode(20))
la.AssembleValue().AssignNode(buildValueNode(50))
la.AssembleValue().AssignNode(buildValueNode(100))
la.Finish()
nd := nb.Build()
pol := Policy{
All(
selector.MustParse(".[]"),
GreaterThan(selector.MustParse(".value"), literal.Int(2)),
),
}
ok := Match(pol, nd)
require.True(t, ok)
pol = Policy{
All(
selector.MustParse(".[]"),
GreaterThan(selector.MustParse(".value"), literal.Int(20)),
),
}
ok = Match(pol, nd)
require.False(t, ok)
})
t.Run("any", func(t *testing.T) {
np := basicnode.Prototype.List
nb := np.NewBuilder()
la, _ := nb.BeginList(5)
la.AssembleValue().AssignNode(buildValueNode(5))
la.AssembleValue().AssignNode(buildValueNode(10))
la.AssembleValue().AssignNode(buildValueNode(20))
la.AssembleValue().AssignNode(buildValueNode(50))
la.AssembleValue().AssignNode(buildValueNode(100))
la.Finish()
nd := nb.Build()
pol := Policy{
Any(
selector.MustParse(".[]"),
GreaterThan(selector.MustParse(".value"), literal.Int(10)),
LessThan(selector.MustParse(".value"), literal.Int(50)),
),
}
ok := Match(pol, nd)
require.True(t, ok)
pol = Policy{
Any(
selector.MustParse(".[]"),
GreaterThan(selector.MustParse(".value"), literal.Int(100)),
),
}
ok = Match(pol, nd)
require.False(t, ok)
})
})
}

View File

@@ -1,125 +0,0 @@
package policy
// https://github.com/ucan-wg/delegation/blob/4094d5878b58f5d35055a3b93fccda0b8329ebae/README.md#policy
import (
"github.com/gobwas/glob"
"github.com/ipld/go-ipld-prime"
"github.com/ucan-wg/go-ucan/v1/capability/policy/selector"
)
const (
KindEqual = "==" // implemented by equality
KindGreaterThan = ">" // implemented by equality
KindGreaterThanOrEqual = ">=" // implemented by equality
KindLessThan = "<" // implemented by equality
KindLessThanOrEqual = "<=" // implemented by equality
KindNot = "not" // implemented by negation
KindAnd = "and" // implemented by connective
KindOr = "or" // implemented by connective
KindLike = "like" // implemented by wildcard
KindAll = "all" // implemented by quantifier
KindAny = "any" // implemented by quantifier
)
type Policy []Statement
type Statement interface {
Kind() string
}
type equality struct {
kind string
selector selector.Selector
value ipld.Node
}
func (e equality) Kind() string {
return e.kind
}
func Equal(selector selector.Selector, value ipld.Node) Statement {
return equality{KindEqual, selector, value}
}
func GreaterThan(selector selector.Selector, value ipld.Node) Statement {
return equality{KindGreaterThan, selector, value}
}
func GreaterThanOrEqual(selector selector.Selector, value ipld.Node) Statement {
return equality{KindGreaterThanOrEqual, selector, value}
}
func LessThan(selector selector.Selector, value ipld.Node) Statement {
return equality{KindLessThan, selector, value}
}
func LessThanOrEqual(selector selector.Selector, value ipld.Node) Statement {
return equality{KindLessThanOrEqual, selector, value}
}
type negation struct {
statement Statement
}
func (n negation) Kind() string {
return KindNot
}
func Not(stmt Statement) Statement {
return negation{stmt}
}
type connective struct {
kind string
statements []Statement
}
func (c connective) Kind() string {
return c.kind
}
func And(stmts ...Statement) Statement {
return connective{KindAnd, stmts}
}
func Or(stmts ...Statement) Statement {
return connective{KindOr, stmts}
}
type wildcard struct {
selector selector.Selector
pattern string
glob glob.Glob // not serialized
}
func (n wildcard) Kind() string {
return KindLike
}
func Like(selector selector.Selector, pattern string) (Statement, error) {
g, err := glob.Compile(pattern)
if err != nil {
return nil, err
}
return wildcard{selector, pattern, g}, nil
}
type quantifier struct {
kind string
selector selector.Selector
statements []Statement
}
func (n quantifier) Kind() string {
return n.kind
}
func All(selector selector.Selector, policy ...Statement) Statement {
return quantifier{KindAll, selector, policy}
}
func Any(selector selector.Selector, policy ...Statement) Statement {
return quantifier{KindAny, selector, policy}
}

View File

@@ -1,160 +0,0 @@
package selector
import (
"fmt"
"strconv"
"strings"
)
func Parse(str string) (Selector, error) {
if len(str) == 0 {
return nil, newParseError("empty selector", str, 0, "")
}
if string(str[0]) != "." {
return nil, newParseError("selector must start with identity segment '.'", str, 0, string(str[0]))
}
col := 0
var sel Selector
for _, tok := range tokenize(str) {
seg := tok
opt := strings.HasSuffix(tok, "?")
if opt {
seg = tok[0 : len(tok)-1]
}
switch seg {
case ".":
if len(sel) > 0 && sel[len(sel)-1].Identity() {
return nil, newParseError("selector contains unsupported recursive descent segment: '..'", str, col, tok)
}
sel = append(sel, Identity)
case "[]":
sel = append(sel, segment{tok, false, opt, true, nil, "", 0})
default:
if strings.HasPrefix(seg, "[") && strings.HasSuffix(seg, "]") {
lookup := seg[1 : len(seg)-1]
if indexRegex.MatchString(lookup) { // index
idx, err := strconv.Atoi(lookup)
if err != nil {
return nil, newParseError("invalid index", str, col, tok)
}
sel = append(sel, segment{str: tok, optional: opt, index: idx})
} else if strings.HasPrefix(lookup, "\"") && strings.HasSuffix(lookup, "\"") { // explicit field
sel = append(sel, segment{str: tok, optional: opt, field: lookup[1 : len(lookup)-1]})
} else if sliceRegex.MatchString(lookup) { // slice [3:5] or [:5] or [3:]
var rng []int
splt := strings.Split(lookup, ":")
if splt[0] == "" {
rng = append(rng, 0)
} else {
i, err := strconv.Atoi(splt[0])
if err != nil {
return nil, newParseError("invalid slice index", str, col, tok)
}
rng = append(rng, i)
}
if splt[1] != "" {
i, err := strconv.Atoi(splt[1])
if err != nil {
return nil, newParseError("invalid slice index", str, col, tok)
}
rng = append(rng, i)
}
sel = append(sel, segment{str: tok, optional: opt, slice: rng})
} else {
return nil, newParseError(fmt.Sprintf("invalid segment: %s", seg), str, col, tok)
}
} else if fieldRegex.MatchString(seg) {
sel = append(sel, segment{str: tok, optional: opt, field: seg[1:]})
} else {
return nil, newParseError(fmt.Sprintf("invalid segment: %s", seg), str, col, tok)
}
}
col += len(tok)
}
return sel, nil
}
func MustParse(sel string) Selector {
s, err := Parse(sel)
if err != nil {
panic(err)
}
return s
}
func tokenize(str string) []string {
var toks []string
col := 0
ofs := 0
ctx := ""
for col < len(str) {
char := string(str[col])
if char == "\"" && string(str[col-1]) != "\\" {
col++
if ctx == "\"" {
ctx = ""
} else {
ctx = "\""
}
continue
}
if ctx == "\"" {
col++
continue
}
if char == "." || char == "[" {
if ofs < col {
toks = append(toks, str[ofs:col])
}
ofs = col
}
col++
}
if ofs < col && ctx != "\"" {
toks = append(toks, str[ofs:col])
}
return toks
}
type parseerr struct {
msg string
src string
col int
tok string
}
func (p parseerr) Name() string {
return "ParseError"
}
func (p parseerr) Message() string {
return p.msg
}
func (p parseerr) Column() int {
return p.col
}
func (p parseerr) Error() string {
return p.msg
}
func (p parseerr) Source() string {
return p.src
}
func (p parseerr) Token() string {
return p.tok
}
func newParseError(message string, source string, column int, token string) error {
return parseerr{message, source, column, token}
}

View File

@@ -1,253 +0,0 @@
package selector
import (
"fmt"
"regexp"
"strings"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/schema"
)
// Selector describes a UCAN policy selector, as specified here:
// https://github.com/ucan-wg/delegation/blob/4094d5878b58f5d35055a3b93fccda0b8329ebae/README.md#selectors
type Selector []segment
func (s Selector) String() string {
var res strings.Builder
for _, seg := range s {
res.WriteString(seg.String())
}
return res.String()
}
var Identity = segment{".", true, false, false, nil, "", 0}
var (
indexRegex = regexp.MustCompile(`^-?\d+$`)
sliceRegex = regexp.MustCompile(`^((\-?\d+:\-?\d*)|(\-?\d*:\-?\d+))$`)
fieldRegex = regexp.MustCompile(`^\.[a-zA-Z_]*?$`)
)
type segment struct {
str string
identity bool
optional bool
iterator bool
slice []int
field string
index int
}
// String returns the segment's string representation.
func (s segment) String() string {
return s.str
}
// Identity flags that this selector is the identity selector.
func (s segment) Identity() bool {
return s.identity
}
// Optional flags that this selector is optional.
func (s segment) Optional() bool {
return s.optional
}
// Iterator flags that this selector is an iterator segment.
func (s segment) Iterator() bool {
return s.iterator
}
// Slice flags that this segment targets a range of a slice.
func (s segment) Slice() []int {
return s.slice
}
// Field is the name of a field in a struct/map.
func (s segment) Field() string {
return s.field
}
// Index is an index of a slice.
func (s segment) Index() int {
return s.index
}
// Select uses a selector to extract an IPLD node or set of nodes from the
// passed subject node.
func Select(sel Selector, subject ipld.Node) (ipld.Node, []ipld.Node, error) {
return resolve(sel, subject, nil)
}
func resolve(sel Selector, subject ipld.Node, at []string) (ipld.Node, []ipld.Node, error) {
cur := subject
for i, seg := range sel {
if seg.Identity() {
continue
} else if seg.Iterator() {
if cur != nil && cur.Kind() == datamodel.Kind_List {
var many []ipld.Node
it := cur.ListIterator()
for {
if it.Done() {
break
}
k, v, err := it.Next()
if err != nil {
return nil, nil, err
}
key := fmt.Sprintf("%d", k)
o, m, err := resolve(sel[i+1:], v, append(at[:], key))
if err != nil {
return nil, nil, err
}
if m != nil {
many = append(many, m...)
} else {
many = append(many, o)
}
}
return nil, many, nil
} else if cur != nil && cur.Kind() == datamodel.Kind_Map {
var many []ipld.Node
it := cur.MapIterator()
for {
if it.Done() {
break
}
k, v, err := it.Next()
if err != nil {
return nil, nil, err
}
key, _ := k.AsString()
o, m, err := resolve(sel[i+1:], v, append(at[:], key))
if err != nil {
return nil, nil, err
}
if m != nil {
many = append(many, m...)
} else {
many = append(many, o)
}
}
return nil, many, nil
} else if seg.Optional() {
cur = nil
} else {
return nil, nil, newResolutionError(fmt.Sprintf("can not iterate over kind: %s", kindString(cur)), at)
}
} else if seg.Field() != "" {
at = append(at, seg.Field())
if cur != nil && cur.Kind() == datamodel.Kind_Map {
n, err := cur.LookupByString(seg.Field())
if err != nil {
if isMissing(err) {
if seg.Optional() {
cur = nil
} else {
return nil, nil, newResolutionError(fmt.Sprintf("object has no field named: %s", seg.Field()), at)
}
} else {
return nil, nil, err
}
}
cur = n
} else if seg.Optional() {
cur = nil
} else {
return nil, nil, newResolutionError(fmt.Sprintf("can not access field: %s on kind: %s", seg.Field(), kindString(cur)), at)
}
} else if seg.Slice() != nil {
if cur != nil && cur.Kind() == datamodel.Kind_List {
return nil, nil, newResolutionError("list slice selection not yet implemented", at)
} else if cur != nil && cur.Kind() == datamodel.Kind_Bytes {
return nil, nil, newResolutionError("bytes slice selection not yet implemented", at)
} else if seg.Optional() {
cur = nil
} else {
return nil, nil, newResolutionError(fmt.Sprintf("can not index: %s on kind: %s", seg.Field(), kindString(cur)), at)
}
} else {
at = append(at, fmt.Sprintf("%d", seg.Index()))
if cur != nil && cur.Kind() == datamodel.Kind_List {
idx := int64(seg.Index())
if idx < 0 {
idx = cur.Length() + idx
}
n, err := cur.LookupByIndex(idx)
if err != nil {
if isMissing(err) {
if seg.Optional() {
cur = nil
} else {
return nil, nil, newResolutionError(fmt.Sprintf("index out of bounds: %d", seg.Index()), at)
}
} else {
return nil, nil, err
}
}
cur = n
} else if seg.Optional() {
cur = nil
} else {
return nil, nil, newResolutionError(fmt.Sprintf("can not access field: %s on kind: %s", seg.Field(), kindString(cur)), at)
}
}
}
return cur, nil, nil
}
func kindString(n datamodel.Node) string {
if n == nil {
return "null"
}
return n.Kind().String()
}
func isMissing(err error) bool {
if _, ok := err.(datamodel.ErrNotExists); ok {
return true
}
if _, ok := err.(schema.ErrNoSuchField); ok {
return true
}
if _, ok := err.(schema.ErrInvalidKey); ok {
return true
}
return false
}
type resolutionerr struct {
msg string
at []string
}
func (r resolutionerr) Name() string {
return "ResolutionError"
}
func (r resolutionerr) Message() string {
return fmt.Sprintf("can not resolve path: .%s", strings.Join(r.at, "."))
}
func (r resolutionerr) At() []string {
return r.at
}
func (r resolutionerr) Error() string {
return r.Message()
}
func newResolutionError(message string, at []string) error {
return resolutionerr{message, at}
}

View File

@@ -1,499 +0,0 @@
package selector
import (
"fmt"
"strings"
"testing"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec/dagjson"
"github.com/ipld/go-ipld-prime/must"
basicnode "github.com/ipld/go-ipld-prime/node/basic"
"github.com/ipld/go-ipld-prime/node/bindnode"
"github.com/ipld/go-ipld-prime/printer"
"github.com/stretchr/testify/require"
)
func TestParse(t *testing.T) {
t.Run("identity", func(t *testing.T) {
sel, err := Parse(".")
require.NoError(t, err)
require.Equal(t, 1, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
})
t.Run("field", func(t *testing.T) {
sel, err := Parse(".foo")
require.NoError(t, err)
require.Equal(t, 1, len(sel))
require.False(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Equal(t, sel[0].Field(), "foo")
require.Empty(t, sel[0].Index())
})
t.Run("explicit field", func(t *testing.T) {
sel, err := Parse(`.["foo"]`)
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.False(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Empty(t, sel[1].Slice())
require.Equal(t, sel[1].Field(), "foo")
require.Empty(t, sel[1].Index())
})
t.Run("index", func(t *testing.T) {
sel, err := Parse(".[138]")
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.False(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Empty(t, sel[1].Slice())
require.Empty(t, sel[1].Field())
require.Equal(t, sel[1].Index(), 138)
})
t.Run("negative index", func(t *testing.T) {
sel, err := Parse(".[-138]")
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.False(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Empty(t, sel[1].Slice())
require.Empty(t, sel[1].Field())
require.Equal(t, sel[1].Index(), -138)
})
t.Run("iterator", func(t *testing.T) {
sel, err := Parse(".[]")
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.False(t, sel[1].Optional())
require.True(t, sel[1].Iterator())
require.Empty(t, sel[1].Slice())
require.Empty(t, sel[1].Field())
require.Empty(t, sel[1].Index())
})
t.Run("optional field", func(t *testing.T) {
sel, err := Parse(".foo?")
require.NoError(t, err)
require.Equal(t, 1, len(sel))
require.False(t, sel[0].Identity())
require.True(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Equal(t, sel[0].Field(), "foo")
require.Empty(t, sel[0].Index())
})
t.Run("optional explicit field", func(t *testing.T) {
sel, err := Parse(`.["foo"]?`)
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.True(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Empty(t, sel[1].Slice())
require.Equal(t, sel[1].Field(), "foo")
require.Empty(t, sel[1].Index())
})
t.Run("optional index", func(t *testing.T) {
sel, err := Parse(".[138]?")
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.True(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Empty(t, sel[1].Slice())
require.Empty(t, sel[1].Field())
require.Equal(t, sel[1].Index(), 138)
})
t.Run("optional iterator", func(t *testing.T) {
sel, err := Parse(".[]?")
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.True(t, sel[1].Optional())
require.True(t, sel[1].Iterator())
require.Empty(t, sel[1].Slice())
require.Empty(t, sel[1].Field())
require.Empty(t, sel[1].Index())
})
t.Run("nesting", func(t *testing.T) {
str := `.foo.["bar"].[138]?.baz[1:]`
sel, err := Parse(str)
require.NoError(t, err)
printSegments(sel)
require.Equal(t, str, sel.String())
require.Equal(t, 7, len(sel))
require.False(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Equal(t, sel[0].Field(), "foo")
require.Empty(t, sel[0].Index())
require.True(t, sel[1].Identity())
require.False(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Empty(t, sel[1].Slice())
require.Empty(t, sel[1].Field())
require.Empty(t, sel[1].Index())
require.False(t, sel[2].Identity())
require.False(t, sel[2].Optional())
require.False(t, sel[2].Iterator())
require.Empty(t, sel[2].Slice())
require.Equal(t, sel[2].Field(), "bar")
require.Empty(t, sel[2].Index())
require.True(t, sel[3].Identity())
require.False(t, sel[3].Optional())
require.False(t, sel[3].Iterator())
require.Empty(t, sel[3].Slice())
require.Empty(t, sel[3].Field())
require.Empty(t, sel[3].Index())
require.False(t, sel[4].Identity())
require.True(t, sel[4].Optional())
require.False(t, sel[4].Iterator())
require.Empty(t, sel[4].Slice())
require.Empty(t, sel[4].Field())
require.Equal(t, sel[4].Index(), 138)
require.False(t, sel[5].Identity())
require.False(t, sel[5].Optional())
require.False(t, sel[5].Iterator())
require.Empty(t, sel[5].Slice())
require.Equal(t, sel[5].Field(), "baz")
require.Empty(t, sel[5].Index())
require.False(t, sel[6].Identity())
require.False(t, sel[6].Optional())
require.False(t, sel[6].Iterator())
require.Equal(t, sel[6].Slice(), []int{1})
require.Empty(t, sel[6].Field())
require.Empty(t, sel[6].Index())
})
t.Run("non dotted", func(t *testing.T) {
_, err := Parse("foo")
require.NotNil(t, err)
fmt.Println(err)
})
t.Run("non quoted", func(t *testing.T) {
_, err := Parse(".[foo]")
require.NotNil(t, err)
fmt.Println(err)
})
}
func printSegments(s Selector) {
for i, seg := range s {
fmt.Printf("%d: %s\n", i, seg.String())
}
}
func TestSelect(t *testing.T) {
type name struct {
First string
Middle *string
Last string
}
type interest struct {
Name string
Outdoor bool
Experience int
}
type user struct {
Name name
Age int
Nationalities []string
Interests []interest
}
ts, err := ipld.LoadSchemaBytes([]byte(`
type User struct {
name Name
age Int
nationalities [String]
interests [Interest]
}
type Name struct {
first String
middle optional String
last String
}
type Interest struct {
name String
outdoor Bool
experience Int
}
`))
require.NoError(t, err)
typ := ts.TypeByName("User")
am := "Joan"
alice := user{
Name: name{First: "Alice", Middle: &am, Last: "Wonderland"},
Age: 24,
Nationalities: []string{"British"},
Interests: []interest{
{Name: "Cycling", Outdoor: true, Experience: 4},
{Name: "Chess", Outdoor: false, Experience: 2},
},
}
bob := user{
Name: name{First: "Bob", Last: "Builder"},
Age: 35,
Nationalities: []string{"Canadian", "South African"},
Interests: []interest{
{Name: "Snowboarding", Outdoor: true, Experience: 8},
{Name: "Reading", Outdoor: false, Experience: 25},
},
}
anode := bindnode.Wrap(&alice, typ)
bnode := bindnode.Wrap(&bob, typ)
t.Run("identity", func(t *testing.T) {
sel, err := Parse(".")
require.NoError(t, err)
one, many, err := Select(sel, anode)
require.NoError(t, err)
require.NotEmpty(t, one)
require.Empty(t, many)
fmt.Println(printer.Sprint(one))
age := must.Int(must.Node(one.LookupByString("age")))
require.Equal(t, int64(alice.Age), age)
})
t.Run("nested property", func(t *testing.T) {
sel, err := Parse(".name.first")
require.NoError(t, err)
one, many, err := Select(sel, anode)
require.NoError(t, err)
require.NotEmpty(t, one)
require.Empty(t, many)
fmt.Println(printer.Sprint(one))
name := must.String(one)
require.Equal(t, alice.Name.First, name)
one, many, err = Select(sel, bnode)
require.NoError(t, err)
require.NotEmpty(t, one)
require.Empty(t, many)
fmt.Println(printer.Sprint(one))
name = must.String(one)
require.Equal(t, bob.Name.First, name)
})
t.Run("optional nested property", func(t *testing.T) {
sel, err := Parse(".name.middle?")
require.NoError(t, err)
one, many, err := Select(sel, anode)
require.NoError(t, err)
require.NotEmpty(t, one)
require.Empty(t, many)
fmt.Println(printer.Sprint(one))
name := must.String(one)
require.Equal(t, *alice.Name.Middle, name)
one, many, err = Select(sel, bnode)
require.NoError(t, err)
require.Empty(t, one)
require.Empty(t, many)
})
t.Run("not exists", func(t *testing.T) {
sel, err := Parse(".name.foo")
require.NoError(t, err)
one, many, err := Select(sel, anode)
require.Error(t, err)
require.Empty(t, one)
require.Empty(t, many)
fmt.Println(err)
require.ErrorAs(t, err, &resolutionerr{}, "error was not a resolution error")
})
t.Run("optional not exists", func(t *testing.T) {
sel, err := Parse(".name.foo?")
require.NoError(t, err)
one, many, err := Select(sel, anode)
require.NoError(t, err)
require.Empty(t, one)
require.Empty(t, many)
})
t.Run("iterator", func(t *testing.T) {
sel, err := Parse(".interests[]")
require.NoError(t, err)
one, many, err := Select(sel, anode)
require.NoError(t, err)
require.Empty(t, one)
require.NotEmpty(t, many)
for _, n := range many {
fmt.Println(printer.Sprint(n))
}
iname := must.String(must.Node(many[0].LookupByString("name")))
require.Equal(t, alice.Interests[0].Name, iname)
iname = must.String(must.Node(many[1].LookupByString("name")))
require.Equal(t, alice.Interests[1].Name, iname)
})
t.Run("map iterator", func(t *testing.T) {
sel, err := Parse(".interests[0][]")
require.NoError(t, err)
one, many, err := Select(sel, anode)
require.NoError(t, err)
require.Empty(t, one)
require.NotEmpty(t, many)
for _, n := range many {
fmt.Println(printer.Sprint(n))
}
require.Equal(t, alice.Interests[0].Name, must.String(many[0]))
require.Equal(t, alice.Interests[0].Experience, int(must.Int(many[2])))
})
}
func FuzzParse(f *testing.F) {
selectorCorpus := []string{
`.`, `.[]`, `.[]?`, `.[][]?`, `.x`, `.["x"]`, `.[0]`, `.[-1]`, `.[0]`,
`.[0]`, `.[0:2]`, `.[1:]`, `.[:2]`, `.[0:2]`, `.[1:]`, `.x?`, `.x?`,
`.x?`, `.["x"]?`, `.length?`, `.[4]?`, `.[]`, `.[][]`, `.x`, `.x`, `.x`,
`.length`, `.[4]`,
}
for _, selector := range selectorCorpus {
f.Add(selector)
}
f.Fuzz(func(t *testing.T, selector string) {
// only look for panic()
_, _ = Parse(selector)
})
}
func FuzzParseAndSelect(f *testing.F) {
selectorCorpus := []string{
`.`, `.[]`, `.[]?`, `.[][]?`, `.x`, `.["x"]`, `.[0]`, `.[-1]`, `.[0]`,
`.[0]`, `.[0:2]`, `.[1:]`, `.[:2]`, `.[0:2]`, `.[1:]`, `.x?`, `.x?`,
`.x?`, `.["x"]?`, `.length?`, `.[4]?`, `.[]`, `.[][]`, `.x`, `.x`, `.x`,
`.length`, `.[4]`,
}
subjectCorpus := []string{
`{"x":1}`, `[1, 2]`, `null`, `[[1], 2, [3]]`, `{"x": 1 }`, `{"x": 1}`,
`[1, 2]`, `[1, 2]`, `"Hi"`, `{"/":{"bytes":"AAE"}`, `[0, 1, 2]`,
`[0, 1, 2]`, `[0, 1, 2]`, `"hello"`, `{"/":{"bytes":"AAEC"}}`, `{}`,
`null`, `[]`, `{}`, `[1, 2]`, `[0, 1]`, `null`, `[[1], 2, [3]]`, `{}`,
`null`, `[]`, `[1, 2]`, `[0, 1]`,
}
for i := 0; ; i++ {
switch {
case i < len(selectorCorpus) && i < len(subjectCorpus):
f.Add(selectorCorpus[i], subjectCorpus[i])
continue
case i > len(selectorCorpus):
f.Add("", subjectCorpus[i])
continue
case i > len(subjectCorpus):
f.Add(selectorCorpus[i], "")
continue
}
break
}
f.Fuzz(func(t *testing.T, selector, subject string) {
sel, err := Parse(selector)
if err != nil {
t.Skip()
}
np := basicnode.Prototype.Any
nb := np.NewBuilder()
err = dagjson.Decode(nb, strings.NewReader(subject))
if err != nil {
t.Skip()
}
node := nb.Build()
if node == nil {
t.Skip()
}
// look for panic()
_, _, _ = Select(sel, node)
})
}

View File

@@ -1,33 +0,0 @@
package delegation
import (
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec/dagcbor"
"github.com/ipld/go-ipld-prime/codec/dagjson"
)
func (p *PayloadModel) EncodeDagCbor() ([]byte, error) {
return ipld.Marshal(dagcbor.Encode, p, PayloadType())
}
func (p *PayloadModel) EncodeDagJson() ([]byte, error) {
return ipld.Marshal(dagjson.Encode, p, PayloadType())
}
func DecodeDagCbor(data []byte) (*PayloadModel, error) {
var p PayloadModel
_, err := ipld.Unmarshal(data, dagcbor.Decode, &p, PayloadType())
if err != nil {
return nil, err
}
return &p, nil
}
func DecodeDagJson(data []byte) (*PayloadModel, error) {
var p PayloadModel
_, err := ipld.Unmarshal(data, dagjson.Decode, &p, PayloadType())
if err != nil {
return nil, err
}
return &p, nil
}

View File

@@ -1,69 +0,0 @@
package delegation
import (
_ "embed"
"fmt"
"sync"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/schema"
)
//go:embed delegation.ipldsch
var schemaBytes []byte
var (
once sync.Once
ts *schema.TypeSystem
err error
)
func mustLoadSchema() *schema.TypeSystem {
once.Do(func() {
ts, err = ipld.LoadSchemaBytes(schemaBytes)
})
if err != nil {
panic(fmt.Errorf("failed to load IPLD schema: %s", err))
}
return ts
}
func PayloadType() schema.Type {
return mustLoadSchema().TypeByName("Payload")
}
type PayloadModel struct {
// Issuer DID (sender)
Iss string
// Audience DID (receiver)
Aud string
// Principal that the chain is about (the Subject)
// optional: can be nil
Sub *string
// The Command to eventually invoke
Cmd string
// The delegation policy
Pol datamodel.Node
// A unique, random nonce
Nonce []byte
// Arbitrary Metadata
// optional: can be nil
Meta MetaModel
// "Not before" UTC Unix Timestamp in seconds (valid from), 53-bits integer
// optional: can be nil
Nbf *int64
// The timestamp at which the Invocation becomes invalid
// optional: can be nil
Exp *int64
}
type MetaModel struct {
Keys []string
Values map[string]datamodel.Node
}

View File

@@ -1,71 +0,0 @@
package delegation
import (
"fmt"
"testing"
"github.com/ipld/go-ipld-prime"
"github.com/stretchr/testify/require"
)
func TestSchemaRoundTrip(t *testing.T) {
const delegationJson = `
{
"aud":"did:key:def456",
"cmd":"/foo/bar",
"exp":123456,
"iss":"did:key:abc123",
"meta":{
"bar":"baaar",
"foo":"fooo"
},
"nbf":123456,
"nonce":{
"/":{
"bytes":"c3VwZXItcmFuZG9t"
}
},
"pol":[
["==", ".status", "draft"],
["all", ".reviewer", [
["like", ".email", "*@example.com"]]
],
["any", ".tags", [
["or", [
["==", ".", "news"],
["==", ".", "press"]]
]]
]
],
"sub":""
}
`
// format: dagJson --> PayloadModel --> dagCbor --> PayloadModel --> dagJson
// function: DecodeDagJson() EncodeDagCbor() DecodeDagCbor() EncodeDagJson()
p1, err := DecodeDagJson([]byte(delegationJson))
require.NoError(t, err)
cborBytes, err := p1.EncodeDagCbor()
require.NoError(t, err)
fmt.Println("cborBytes length", len(cborBytes))
fmt.Println("cbor", string(cborBytes))
p2, err := DecodeDagCbor(cborBytes)
require.NoError(t, err)
fmt.Println("read Cbor", p2)
readJson, err := p2.EncodeDagJson()
require.NoError(t, err)
fmt.Println("readJson length", len(readJson))
fmt.Println("json: ", string(readJson))
require.JSONEq(t, delegationJson, string(readJson))
}
func BenchmarkSchemaLoad(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = ipld.LoadSchemaBytes(schemaBytes)
}
}

View File

@@ -1,87 +0,0 @@
package delegation
import (
"fmt"
"time"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ucan-wg/go-ucan/v1/capability/command"
"github.com/ucan-wg/go-ucan/v1/capability/policy"
"github.com/ucan-wg/go-ucan/v1/did"
)
type View struct {
// Issuer DID (sender)
Issuer did.DID
// Audience DID (receiver)
Audience did.DID
// Principal that the chain is about (the Subject)
Subject did.DID
// The Command to eventually invoke
Command *command.Command
// The delegation policy
Policy policy.Policy
// A unique, random nonce
Nonce []byte
// Arbitrary Metadata
Meta map[string]datamodel.Node
// "Not before" UTC Unix Timestamp in seconds (valid from), 53-bits integer
NotBefore time.Time
// The timestamp at which the Invocation becomes invalid
Expiration time.Time
}
// ViewFromModel build a decoded view of the raw IPLD data.
// This function also serves as validation.
func ViewFromModel(m PayloadModel) (*View, error) {
var view View
var err error
view.Issuer, err = did.Parse(m.Iss)
if err != nil {
return nil, fmt.Errorf("parse iss: %w", err)
}
view.Audience, err = did.Parse(m.Aud)
if err != nil {
return nil, fmt.Errorf("parse audience: %w", err)
}
if m.Sub != nil {
view.Subject, err = did.Parse(*m.Sub)
if err != nil {
return nil, fmt.Errorf("parse subject: %w", err)
}
} else {
view.Subject = did.Undef
}
view.Command, err = command.Parse(m.Cmd)
if err != nil {
return nil, fmt.Errorf("parse command: %w", err)
}
view.Policy, err = policy.FromIPLD(m.Pol)
if err != nil {
return nil, fmt.Errorf("parse policy: %w", err)
}
if len(m.Nonce) == 0 {
return nil, fmt.Errorf("nonce is required")
}
view.Nonce = m.Nonce
// TODO: copy?
view.Meta = m.Meta.Values
if m.Nbf != nil {
view.NotBefore = time.Unix(*m.Nbf, 0)
}
if m.Exp != nil {
view.Expiration = time.Unix(*m.Exp, 0)
}
return &view, nil
}

View File

@@ -1,86 +0,0 @@
package did
import (
"fmt"
"strings"
mbase "github.com/multiformats/go-multibase"
varint "github.com/multiformats/go-varint"
)
const Prefix = "did:"
const KeyPrefix = "did:key:"
const DIDCore = 0x0d1d
const Ed25519 = 0xed
var MethodOffset = varint.UvarintSize(uint64(DIDCore))
type DID struct {
key bool
str string
}
// Undef can be used to represent a nil or undefined DID, using DID{}
// directly is also acceptable.
var Undef = DID{}
func (d DID) Defined() bool {
return d.str != ""
}
func (d DID) Bytes() []byte {
if !d.Defined() {
return nil
}
return []byte(d.str)
}
func (d DID) DID() DID {
return d
}
// String formats the decentralized identity document (DID) as a string.
func (d DID) String() string {
if d.key {
key, _ := mbase.Encode(mbase.Base58BTC, []byte(d.str))
return "did:key:" + key
}
return "did:" + d.str[MethodOffset:]
}
func Decode(bytes []byte) (DID, error) {
code, _, err := varint.FromUvarint(bytes)
if err != nil {
return Undef, err
}
if code == Ed25519 {
return DID{str: string(bytes), key: true}, nil
} else if code == DIDCore {
return DID{str: string(bytes)}, nil
}
return Undef, fmt.Errorf("unsupported DID encoding: 0x%x", code)
}
func Parse(str string) (DID, error) {
if !strings.HasPrefix(str, Prefix) {
return Undef, fmt.Errorf("must start with 'did:'")
}
if strings.HasPrefix(str, KeyPrefix) {
code, bytes, err := mbase.Decode(str[len(KeyPrefix):])
if err != nil {
return Undef, err
}
if code != mbase.Base58BTC {
return Undef, fmt.Errorf("not Base58BTC encoded")
}
return Decode(bytes)
}
buf := make([]byte, MethodOffset)
varint.PutUvarint(buf, DIDCore)
suffix, _ := strings.CutPrefix(str, Prefix)
buf = append(buf, suffix...)
return DID{str: string(buf)}, nil
}

View File

@@ -1,79 +0,0 @@
package did
import (
"testing"
)
func TestParseDIDKey(t *testing.T) {
str := "did:key:z6Mkod5Jr3yd5SC7UDueqK4dAAw5xYJYjksy722tA9Boxc4z"
d, err := Parse(str)
if err != nil {
t.Fatalf("%v", err)
}
if d.String() != str {
t.Fatalf("expected %v to equal %v", d.String(), str)
}
}
func TestDecodeDIDKey(t *testing.T) {
str := "did:key:z6Mkod5Jr3yd5SC7UDueqK4dAAw5xYJYjksy722tA9Boxc4z"
d0, err := Parse(str)
if err != nil {
t.Fatalf("%v", err)
}
d1, err := Decode(d0.Bytes())
if err != nil {
t.Fatalf("%v", err)
}
if d1.String() != str {
t.Fatalf("expected %v to equal %v", d1.String(), str)
}
}
func TestParseDIDWeb(t *testing.T) {
str := "did:web:up.web3.storage"
d, err := Parse(str)
if err != nil {
t.Fatalf("%v", err)
}
if d.String() != str {
t.Fatalf("expected %v to equal %v", d.String(), str)
}
}
func TestDecodeDIDWeb(t *testing.T) {
str := "did:web:up.web3.storage"
d0, err := Parse(str)
if err != nil {
t.Fatalf("%v", err)
}
d1, err := Decode(d0.Bytes())
if err != nil {
t.Fatalf("%v", err)
}
if d1.String() != str {
t.Fatalf("expected %v to equal %v", d1.String(), str)
}
}
func TestEquivalence(t *testing.T) {
u0 := DID{}
u1 := Undef
if u0 != u1 {
t.Fatalf("undef DID not equivalent")
}
d0, err := Parse("did:key:z6Mkod5Jr3yd5SC7UDueqK4dAAw5xYJYjksy722tA9Boxc4z")
if err != nil {
t.Fatalf("%v", err)
}
d1, err := Parse("did:key:z6Mkod5Jr3yd5SC7UDueqK4dAAw5xYJYjksy722tA9Boxc4z")
if err != nil {
t.Fatalf("%v", err)
}
if d0 != d1 {
t.Fatalf("two equivalent DID not equivalent")
}
}

37
go.mod
View File

@@ -1,31 +1,36 @@
module github.com/ucan-wg/go-ucan/v1 module github.com/ucan-wg/go-ucan
go 1.21 go 1.24.4
toolchain go1.22.1 toolchain go1.24.5
require ( require (
github.com/gobwas/glob v0.2.3 github.com/MetaMask/go-did-it v1.0.0-pre1
github.com/ipfs/go-cid v0.4.1 github.com/avast/retry-go/v4 v4.6.1
github.com/ipfs/go-cid v0.5.0
github.com/ipld/go-ipld-prime v0.21.0 github.com/ipld/go-ipld-prime v0.21.0
github.com/multiformats/go-multibase v0.0.3 github.com/multiformats/go-multibase v0.2.0
github.com/multiformats/go-varint v0.0.6 github.com/multiformats/go-multicodec v0.9.0
github.com/stretchr/testify v1.9.0 github.com/multiformats/go-multihash v0.2.3
github.com/multiformats/go-varint v0.0.7
github.com/stretchr/testify v1.10.0
github.com/ucan-wg/go-varsig v1.0.0
golang.org/x/crypto v0.40.0
) )
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/klauspost/cpuid/v2 v2.0.9 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/minio/sha256-simd v1.0.0 // indirect github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect github.com/mr-tron/base58 v1.2.0 // indirect
github.com/multiformats/go-base32 v0.0.3 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect
github.com/multiformats/go-base36 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect
github.com/multiformats/go-multihash v0.2.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/polydawn/refmt v0.89.0 // indirect github.com/polydawn/refmt v0.89.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect
golang.org/x/crypto v0.1.0 // indirect golang.org/x/sys v0.34.0 // indirect
golang.org/x/sys v0.1.0 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/blake3 v1.1.6 // indirect lukechampine.com/blake3 v1.3.0 // indirect
) )

61
go.sum
View File

@@ -1,46 +1,50 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/MetaMask/go-did-it v1.0.0-pre1 h1:NTGAC7z52TwFegEF7c+csUr/6Al1nAo6ValAAxOsjto=
github.com/MetaMask/go-did-it v1.0.0-pre1/go.mod h1:7m9syDnXFTg5GmUEcydpO4Rs3eYT4McFH7vCw5fp3A4=
github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk=
github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8=
github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg=
github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk=
github.com/ipld/go-ipld-prime v0.21.0 h1:n4JmcpOlPDIxBcY037SVfpd1G+Sj1nKZah0m6QH9C2E= github.com/ipld/go-ipld-prime v0.21.0 h1:n4JmcpOlPDIxBcY037SVfpd1G+Sj1nKZah0m6QH9C2E=
github.com/ipld/go-ipld-prime v0.21.0/go.mod h1:3RLqy//ERg/y5oShXXdx5YIp50cFGOanyMctpPjsvxQ= github.com/ipld/go-ipld-prime v0.21.0/go.mod h1:3RLqy//ERg/y5oShXXdx5YIp50cFGOanyMctpPjsvxQ=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
github.com/mr-tron/base58 v1.1.0/go.mod h1:xcD2VGqlgYjBdcBLw+TuYLr8afG+Hj8g2eTVqeSzSU8=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/multiformats/go-base32 v0.0.3 h1:tw5+NhuwaOjJCC5Pp82QuXbrmLzWg7uxlMFp8Nq/kkI= github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
github.com/multiformats/go-base32 v0.0.3/go.mod h1:pLiuGC8y0QR3Ue4Zug5UzK9LjgbkL8NSQj0zQ5Nz/AA= github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
github.com/multiformats/go-base36 v0.1.0 h1:JR6TyF7JjGd3m6FbLU2cOxhC0Li8z8dLNGQ89tUg4F4= github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
github.com/multiformats/go-base36 v0.1.0/go.mod h1:kFGE83c6s80PklsHO9sRn2NCoffoRdUUOENyW/Vv6sM= github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
github.com/multiformats/go-multibase v0.0.3 h1:l/B6bJDQjvQ5G52jw4QGSYeOTZoAwIO77RblWplfIqk= github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
github.com/multiformats/go-multibase v0.0.3/go.mod h1:5+1R4eQrT3PkYZ24C3W2Ue2tPwIdYQD509ZjSb5y9Oc= github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk=
github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg= github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg=
github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k= github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k=
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
github.com/multiformats/go-varint v0.0.6 h1:gk85QWKxh3TazbLxED/NlDVv8+q+ReFJk7Y2W/KhfNY= github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE= github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/polydawn/refmt v0.89.0 h1:ADJTApkvkeBZsN0tBTx8QjpD9JkmxbKp0cxfr9qszm4= github.com/polydawn/refmt v0.89.0 h1:ADJTApkvkeBZsN0tBTx8QjpD9JkmxbKp0cxfr9qszm4=
@@ -55,26 +59,29 @@ github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hg
github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/ucan-wg/go-varsig v1.0.0 h1:Hrc437Zg+B5Eoajg+qZQZI3Q3ocPyjlnp3/Bz9ZnlWw=
github.com/ucan-wg/go-varsig v1.0.0/go.mod h1:Sakln6IPooDPH+ClQ0VvR09TuwUhHcfLqcPiPkMZGh0=
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c= lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE=
lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA= lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=

187
pkg/args/args.go Normal file
View File

@@ -0,0 +1,187 @@
// Package args provides the type that represents the Arguments passed to
// a command within an invocation.Token as well as a convenient Add method
// to incrementally build the underlying map.
package args
import (
"errors"
"fmt"
"iter"
"sort"
"strings"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/fluent/qp"
"github.com/ipld/go-ipld-prime/node/basicnode"
"github.com/ipld/go-ipld-prime/printer"
"github.com/ucan-wg/go-ucan/pkg/policy/limits"
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
)
var ErrNotFound = errors.New("key not found in meta")
// Args are the Command's arguments when an invocation Token is processed by the executor.
// This also serves as a way to construct the underlying IPLD data with minimum allocations
// and transformations, while hiding the IPLD complexity from the caller.
type Args struct {
// This type must be compatible with the IPLD type represented by the IPLD
// schema { String : Any }.
Keys []string
Values map[string]ipld.Node
}
// New returns a pointer to an initialized Args value.
func New() *Args {
return &Args{
Values: map[string]ipld.Node{},
}
}
// GetNode retrieves a value as a raw IPLD node.
// Returns ErrNotFound if the given key is missing.
func (a *Args) GetNode(key string) (ipld.Node, error) {
v, ok := a.Values[key]
if !ok {
return nil, ErrNotFound
}
return v, nil
}
// Add inserts a key/value pair in the Args set.
//
// Accepted types for val are any CBOR compatible type, or directly IPLD values.
func (a *Args) Add(key string, val any) error {
if _, ok := a.Values[key]; ok {
return fmt.Errorf("duplicate key %q", key)
}
node, err := literal.Any(val)
if err != nil {
return err
}
if err := limits.ValidateIntegerBoundsIPLD(node); err != nil {
return fmt.Errorf("value for key %q: %w", key, err)
}
a.Values[key] = node
a.Keys = append(a.Keys, key)
return nil
}
type Iterator interface {
Iter() iter.Seq2[string, ipld.Node]
}
// Include merges the provided arguments into the existing arguments.
//
// If duplicate keys are encountered, the new value is silently dropped
// without causing an error.
func (a *Args) Include(other Iterator) {
for key, value := range other.Iter() {
if _, ok := a.Values[key]; ok {
// don't overwrite
continue
}
a.Values[key] = value
a.Keys = append(a.Keys, key)
}
}
// Len return the number of arguments.
func (a *Args) Len() int {
return len(a.Keys)
}
// Iter iterates over the args key/values
func (a *Args) Iter() iter.Seq2[string, ipld.Node] {
return func(yield func(string, ipld.Node) bool) {
for _, key := range a.Keys {
if !yield(key, a.Values[key]) {
return
}
}
}
}
// ToIPLD wraps an instance of an Args with an ipld.Node.
func (a *Args) ToIPLD() (ipld.Node, error) {
sort.Strings(a.Keys)
return qp.BuildMap(basicnode.Prototype.Any, int64(len(a.Keys)), func(ma datamodel.MapAssembler) {
for _, key := range a.Keys {
qp.MapEntry(ma, key, qp.Node(a.Values[key]))
}
})
}
// Equals tells if two Args hold the same values.
func (a *Args) Equals(other *Args) bool {
if len(a.Keys) != len(other.Keys) {
return false
}
if len(a.Values) != len(other.Values) {
return false
}
for _, key := range a.Keys {
if !ipld.DeepEqual(a.Values[key], other.Values[key]) {
return false
}
}
return true
}
func (a *Args) String() string {
sort.Strings(a.Keys)
buf := strings.Builder{}
buf.WriteString("{")
for _, key := range a.Keys {
buf.WriteString("\n\t")
buf.WriteString(key)
buf.WriteString(": ")
buf.WriteString(strings.ReplaceAll(printer.Sprint(a.Values[key]), "\n", "\n\t"))
buf.WriteString(",")
}
if len(a.Keys) > 0 {
buf.WriteString("\n")
}
buf.WriteString("}")
return buf.String()
}
// ReadOnly returns a read-only version of Args.
func (a *Args) ReadOnly() ReadOnly {
return ReadOnly{args: a}
}
// Clone makes a deep copy.
func (a *Args) Clone() *Args {
res := &Args{
Keys: make([]string, len(a.Keys)),
Values: make(map[string]ipld.Node, len(a.Values)),
}
copy(res.Keys, a.Keys)
for k, v := range a.Values {
res.Values[k] = v
}
return res
}
// Validate checks that all values in the Args are valid according to UCAN specs
func (a *Args) Validate() error {
for key, value := range a.Values {
if err := limits.ValidateIntegerBoundsIPLD(value); err != nil {
return fmt.Errorf("value for key %q: %w", key, err)
}
}
return nil
}

297
pkg/args/args_test.go Normal file
View File

@@ -0,0 +1,297 @@
package args_test
import (
"maps"
"sync"
"testing"
"github.com/ipfs/go-cid"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec/dagcbor"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/node/basicnode"
"github.com/ipld/go-ipld-prime/schema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/pkg/args"
"github.com/ucan-wg/go-ucan/pkg/policy/limits"
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
)
func TestArgs(t *testing.T) {
t.Parallel()
const (
intKey = "intKey"
mapKey = "mapKey"
nilKey = "nilKey"
boolKey = "boolKey"
linkKey = "linkKey"
listKey = "listKey"
nodeKey = "nodeKey"
uintKey = "uintKey"
bytesKey = "bytesKey"
floatKey = "floatKey"
stringKey = "stringKey"
)
const (
expIntVal = int64(-42)
expBoolVal = true
expUintVal = uint(42)
expStringVal = "stringVal"
)
var (
expMapVal = map[string]string{"keyOne": "valOne", "keyTwo": "valTwo"}
// expNilVal = (map[string]string)(nil)
expLinkVal = cid.MustParse("bafzbeigai3eoy2ccc7ybwjfz5r3rdxqrinwi4rwytly24tdbh6yk7zslrm")
expListVal = []string{"elem1", "elem2", "elem3"}
expNodeVal = literal.String("nodeVal")
expBytesVal = []byte{0xde, 0xad, 0xbe, 0xef}
expFloatVal = 42.0
)
argsIn := args.New()
for _, a := range []struct {
key string
val any
}{
{key: intKey, val: expIntVal},
{key: mapKey, val: expMapVal},
// {key: nilKey, val: expNilVal},
{key: boolKey, val: expBoolVal},
{key: linkKey, val: expLinkVal},
{key: listKey, val: expListVal},
{key: uintKey, val: expUintVal},
{key: nodeKey, val: expNodeVal},
{key: bytesKey, val: expBytesVal},
{key: floatKey, val: expFloatVal},
{key: stringKey, val: expStringVal},
} {
require.NoError(t, argsIn.Add(a.key, a.val))
}
// Round-trip to DAG-CBOR
argsOut := roundTripThroughDAGCBOR(t, argsIn)
assert.ElementsMatch(t, argsIn.Keys, argsOut.Keys)
assert.Equal(t, argsIn.Values, argsOut.Values)
actMapVal := map[string]string{}
mit := argsOut.Values[mapKey].MapIterator()
for !mit.Done() {
k, v, err := mit.Next()
require.NoError(t, err)
ks := must(k.AsString())
vs := must(v.AsString())
actMapVal[ks] = vs
}
actListVal := []string{}
lit := argsOut.Values[listKey].ListIterator()
for !lit.Done() {
_, v, err := lit.Next()
require.NoError(t, err)
vs := must(v.AsString())
actListVal = append(actListVal, vs)
}
assert.Equal(t, expIntVal, must(argsOut.Values[intKey].AsInt()))
assert.Equal(t, expMapVal, actMapVal) // TODO: special accessor
// TODO: the nil map comes back empty (but the right type)
// assert.Equal(t, expNilVal, actNilVal)
assert.Equal(t, expBoolVal, must(argsOut.Values[boolKey].AsBool()))
assert.Equal(t, expLinkVal.String(), must(argsOut.Values[linkKey].AsLink()).(datamodel.Link).String()) // TODO: special accessor
assert.Equal(t, expListVal, actListVal) // TODO: special accessor
assert.Equal(t, expNodeVal, argsOut.Values[nodeKey])
assert.Equal(t, expUintVal, uint(must(argsOut.Values[uintKey].AsInt())))
assert.Equal(t, expBytesVal, must(argsOut.Values[bytesKey].AsBytes()))
assert.Equal(t, expFloatVal, must(argsOut.Values[floatKey].AsFloat()))
assert.Equal(t, expStringVal, must(argsOut.Values[stringKey].AsString()))
}
func TestArgs_Include(t *testing.T) {
t.Parallel()
argsIn := args.New()
require.NoError(t, argsIn.Add("key1", "val1"))
require.NoError(t, argsIn.Add("key2", "val2"))
argsOther := args.New()
require.NoError(t, argsOther.Add("key2", "valOther")) // This should not overwrite key2 above
require.NoError(t, argsOther.Add("key3", "val3"))
require.NoError(t, argsOther.Add("key4", "val4"))
argsIn.Include(argsOther)
assert.Len(t, argsIn.Values, 4)
assert.Equal(t, "val1", must(argsIn.Values["key1"].AsString()))
assert.Equal(t, "val2", must(argsIn.Values["key2"].AsString()))
assert.Equal(t, "val3", must(argsIn.Values["key3"].AsString()))
assert.Equal(t, "val4", must(argsIn.Values["key4"].AsString()))
}
func TestIterCloneEquals(t *testing.T) {
a := args.New()
require.NoError(t, a.Add("foo", "bar"))
require.NoError(t, a.Add("baz", 1234))
expected := map[string]ipld.Node{
"foo": basicnode.NewString("bar"),
"baz": basicnode.NewInt(1234),
}
// args -> iter
require.Equal(t, expected, maps.Collect(a.Iter()))
// readonly -> iter
ro := a.ReadOnly()
require.Equal(t, expected, maps.Collect(ro.Iter()))
// args -> clone -> iter
clone := a.Clone()
require.Equal(t, expected, maps.Collect(clone.Iter()))
// readonly -> WriteableClone -> iter
wclone := ro.WriteableClone()
require.Equal(t, expected, maps.Collect(wclone.Iter()))
require.True(t, a.Equals(wclone))
require.True(t, ro.Equals(wclone.ReadOnly()))
}
func TestInclude(t *testing.T) {
a1 := args.New()
require.NoError(t, a1.Add("samekey", "bar"))
require.NoError(t, a1.Add("baz", 1234))
a2 := args.New()
require.NoError(t, a2.Add("samekey", "othervalue")) // check no overwrite
require.NoError(t, a2.Add("otherkey", 1234))
a1.Include(a2)
require.Equal(t, map[string]ipld.Node{
"samekey": basicnode.NewString("bar"),
"baz": basicnode.NewInt(1234),
"otherkey": basicnode.NewInt(1234),
}, maps.Collect(a1.Iter()))
}
func TestArgsIntegerBounds(t *testing.T) {
t.Parallel()
tests := []struct {
name string
key string
val int64
wantErr string
}{
{
name: "valid int",
key: "valid",
val: 42,
},
{
name: "max safe integer",
key: "max",
val: limits.MaxInt53,
},
{
name: "min safe integer",
key: "min",
val: limits.MinInt53,
},
{
name: "exceeds max safe integer",
key: "tooBig",
val: limits.MaxInt53 + 1,
wantErr: "exceeds safe integer bounds",
},
{
name: "below min safe integer",
key: "tooSmall",
val: limits.MinInt53 - 1,
wantErr: "exceeds safe integer bounds",
},
{
name: "duplicate key",
key: "duplicate",
val: 42,
wantErr: "duplicate key",
},
}
a := args.New()
require.NoError(t, a.Add("duplicate", 1)) // tests duplicate key
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := a.Add(tt.key, tt.val)
if tt.wantErr != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErr)
} else {
require.NoError(t, err)
val, err := a.GetNode(tt.key)
require.NoError(t, err)
i, err := val.AsInt()
require.NoError(t, err)
require.Equal(t, tt.val, i)
}
})
}
}
const (
argsSchema = "type Args { String : Any }"
argsName = "Args"
)
var (
once sync.Once
ts *schema.TypeSystem
errSchema error
)
func argsType() schema.Type {
once.Do(func() {
ts, errSchema = ipld.LoadSchemaBytes([]byte(argsSchema))
})
if errSchema != nil {
panic(errSchema)
}
return ts.TypeByName(argsName)
}
func roundTripThroughDAGCBOR(t *testing.T, argsIn *args.Args) *args.Args {
t.Helper()
node, err := argsIn.ToIPLD()
require.NoError(t, err)
data, err := ipld.Encode(node, dagcbor.Encode)
require.NoError(t, err)
var argsOut args.Args
_, err = ipld.Unmarshal(data, dagcbor.Decode, &argsOut, argsType())
require.NoError(t, err)
return &argsOut
}
func must[T any](t T, err error) T {
if err != nil {
panic(err)
}
return t
}

71
pkg/args/builder.go Normal file
View File

@@ -0,0 +1,71 @@
package args
import (
"errors"
"github.com/ipld/go-ipld-prime"
)
// Builder allows the fluid construction of an Args.
type Builder struct {
args *Args
errs error
}
// NewBuilder returns a Builder which will assemble the Args.
func NewBuilder() *Builder {
return &Builder{
args: New(),
}
}
// Add inserts a new key/val into the Args being assembled while collecting
// any errors caused by duplicate keys.
func (b *Builder) Add(key string, val any) *Builder {
b.errs = errors.Join(b.errs, b.args.Add(key, val))
return b
}
// Build returns the assembled Args or an error containing a list of
// errors encountered while trying to build the Args.
func (b *Builder) Build() (*Args, error) {
if b.errs != nil {
return nil, b.errs
}
return b.args, nil
}
// BuildIPLD is the same as Build except it takes the additional step of
// converting the Args to an ipld.Node.
func (b *Builder) BuildIPLD() (ipld.Node, error) {
args, err := b.Build()
if err != nil {
return nil, err
}
return args.ToIPLD()
}
// MustBuild is the same as Build except it panics if an error occurs.
func (b *Builder) MustBuild() *Args {
args, err := b.Build()
if err != nil {
panic(b.errs)
}
return args
}
// MustBuildIPLD is the same as BuildIPLD except it panics if an error
// occurs.
func (b *Builder) MustBuildIPLD() ipld.Node {
node, err := b.BuildIPLD()
if err != nil {
panic(err)
}
return node
}

82
pkg/args/builder_test.go Normal file
View File

@@ -0,0 +1,82 @@
package args_test
import (
"testing"
"github.com/ipld/go-ipld-prime"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/pkg/args"
)
func TestBuilder_XXX(t *testing.T) {
t.Parallel()
const (
keyOne = "key1"
valOne = "string"
keyTwo = "key2"
valTwo = 42
)
exp := args.New()
exp.Add(keyOne, valOne)
exp.Add(keyTwo, valTwo)
expNode, err := exp.ToIPLD()
require.NoError(t, err)
disjointKeys := args.NewBuilder().
Add(keyOne, valOne).
Add(keyTwo, valTwo)
duplicateKeys := args.NewBuilder().
Add(keyOne, valOne).
Add(keyTwo, valTwo).
Add(keyOne, "oh no!")
t.Run("MustBuild succeeds with disjoint keys", func(t *testing.T) {
t.Parallel()
var act *args.Args
require.NotPanics(t, func() {
act = disjointKeys.MustBuild()
})
assert.Equal(t, exp, act)
})
t.Run("MustBuild fails with duplicate keys", func(t *testing.T) {
t.Parallel()
var act *args.Args
require.Panics(t, func() {
act = duplicateKeys.MustBuild()
})
assert.Nil(t, act)
})
t.Run("MustBuildIPLD succeeds with disjoint keys", func(t *testing.T) {
t.Parallel()
var act ipld.Node
require.NotPanics(t, func() {
act = disjointKeys.MustBuildIPLD()
})
assert.Equal(t, expNode, act)
})
t.Run("MustBuildIPLD fails with duplicate keys", func(t *testing.T) {
t.Parallel()
var act ipld.Node
require.Panics(t, func() {
act = duplicateKeys.MustBuildIPLD()
})
assert.Nil(t, act)
})
}

39
pkg/args/readonly.go Normal file
View File

@@ -0,0 +1,39 @@
package args
import (
"iter"
"github.com/ipld/go-ipld-prime"
)
type ReadOnly struct {
args *Args
}
func (r ReadOnly) GetNode(key string) (ipld.Node, error) {
return r.args.GetNode(key)
}
func (r ReadOnly) Len() int {
return r.args.Len()
}
func (r ReadOnly) Iter() iter.Seq2[string, ipld.Node] {
return r.args.Iter()
}
func (r ReadOnly) ToIPLD() (ipld.Node, error) {
return r.args.ToIPLD()
}
func (r ReadOnly) Equals(other ReadOnly) bool {
return r.args.Equals(other.args)
}
func (r ReadOnly) String() string {
return r.args.String()
}
func (r ReadOnly) WriteableClone() *Args {
return r.args.Clone()
}

265
pkg/claims/claims.go Normal file
View File

@@ -0,0 +1,265 @@
package claims
import (
"errors"
"fmt"
"iter"
"sort"
"strings"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/printer"
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
"github.com/ucan-wg/go-ucan/pkg/secretbox"
)
var ErrNotFound = errors.New("key not found in claims")
var ErrNotEncryptable = errors.New("value of this type cannot be encrypted")
// Claims is a container for claims key-value pairs in an attestation token.
// This also serves as a way to construct the underlying IPLD data with minimum allocations
// and transformations, while hiding the IPLD complexity from the caller.
type Claims struct {
// This type must be compatible with the IPLD type represented by the IPLD
// schema { String : Any }.
Keys []string
Values map[string]ipld.Node
}
// NewClaims constructs a new Claims.
func NewClaims() *Claims {
return &Claims{Values: map[string]ipld.Node{}}
}
// GetBool retrieves a value as a bool.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
func (m *Claims) GetBool(key string) (bool, error) {
v, ok := m.Values[key]
if !ok {
return false, ErrNotFound
}
return v.AsBool()
}
// GetString retrieves a value as a string.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
func (m *Claims) GetString(key string) (string, error) {
v, ok := m.Values[key]
if !ok {
return "", ErrNotFound
}
return v.AsString()
}
// GetEncryptedString decorates GetString and decrypt its output with the given symmetric encryption key.
func (m *Claims) GetEncryptedString(key string, encryptionKey []byte) (string, error) {
v, err := m.GetBytes(key)
if err != nil {
return "", err
}
decrypted, err := secretbox.DecryptStringWithKey(v, encryptionKey)
if err != nil {
return "", err
}
return string(decrypted), nil
}
// GetInt64 retrieves a value as an int64.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
func (m *Claims) GetInt64(key string) (int64, error) {
v, ok := m.Values[key]
if !ok {
return 0, ErrNotFound
}
return v.AsInt()
}
// GetFloat64 retrieves a value as a float64.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
func (m *Claims) GetFloat64(key string) (float64, error) {
v, ok := m.Values[key]
if !ok {
return 0, ErrNotFound
}
return v.AsFloat()
}
// GetBytes retrieves a value as a []byte.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
func (m *Claims) GetBytes(key string) ([]byte, error) {
v, ok := m.Values[key]
if !ok {
return nil, ErrNotFound
}
return v.AsBytes()
}
// GetEncryptedBytes decorates GetBytes and decrypt its output with the given symmetric encryption key.
func (m *Claims) GetEncryptedBytes(key string, encryptionKey []byte) ([]byte, error) {
v, err := m.GetBytes(key)
if err != nil {
return nil, err
}
decrypted, err := secretbox.DecryptStringWithKey(v, encryptionKey)
if err != nil {
return nil, err
}
return decrypted, nil
}
// GetNode retrieves a value as a raw IPLD node.
// Returns ErrNotFound if the given key is missing.
func (m *Claims) GetNode(key string) (ipld.Node, error) {
v, ok := m.Values[key]
if !ok {
return nil, ErrNotFound
}
return v, nil
}
// Add adds a key/value pair in the claims set.
// Accepted types for val are any CBOR compatible type, or directly IPLD values.
func (m *Claims) Add(key string, val any) error {
if _, ok := m.Values[key]; ok {
return fmt.Errorf("duplicate key %q", key)
}
node, err := literal.Any(val)
if err != nil {
return err
}
m.Keys = append(m.Keys, key)
m.Values[key] = node
return nil
}
// AddEncrypted adds a key/value pair in the claims set.
// The value is encrypted with the given encryptionKey.
// Accepted types for the value are: string, []byte.
// The ciphertext will be 40 bytes larger than the plaintext due to encryption overhead.
func (m *Claims) AddEncrypted(key string, val any, encryptionKey []byte) error {
var encrypted []byte
var err error
switch val := val.(type) {
case string:
encrypted, err = secretbox.EncryptWithKey([]byte(val), encryptionKey)
if err != nil {
return err
}
case []byte:
encrypted, err = secretbox.EncryptWithKey(val, encryptionKey)
if err != nil {
return err
}
default:
return ErrNotEncryptable
}
return m.Add(key, encrypted)
}
type Iterator interface {
Iter() iter.Seq2[string, ipld.Node]
}
// Include merges the provided claims into the existing one.
//
// If duplicate keys are encountered, the new value is silently dropped
// without causing an error.
func (m *Claims) Include(other Iterator) {
for key, value := range other.Iter() {
if _, ok := m.Values[key]; ok {
// don't overwrite
continue
}
m.Values[key] = value
m.Keys = append(m.Keys, key)
}
}
// Len returns the number of key/values.
func (m *Claims) Len() int {
return len(m.Values)
}
// Iter iterates over the claims key/values
func (m *Claims) Iter() iter.Seq2[string, ipld.Node] {
return func(yield func(string, ipld.Node) bool) {
for _, key := range m.Keys {
if !yield(key, m.Values[key]) {
return
}
}
}
}
// Equals tells if two Claims hold the same key/values.
func (m *Claims) Equals(other *Claims) bool {
if len(m.Keys) != len(other.Keys) {
return false
}
if len(m.Values) != len(other.Values) {
return false
}
for _, key := range m.Keys {
if !ipld.DeepEqual(m.Values[key], other.Values[key]) {
return false
}
}
return true
}
func (m *Claims) String() string {
sort.Strings(m.Keys)
buf := strings.Builder{}
buf.WriteString("{")
for key, node := range m.Values {
buf.WriteString("\n\t")
buf.WriteString(key)
buf.WriteString(": ")
buf.WriteString(strings.ReplaceAll(printer.Sprint(node), "\n", "\n\t"))
buf.WriteString(",")
}
if len(m.Values) > 0 {
buf.WriteString("\n")
}
buf.WriteString("}")
return buf.String()
}
// ReadOnly returns a read-only version of Claims.
func (m *Claims) ReadOnly() ReadOnly {
return ReadOnly{claims: m}
}
// Clone makes a deep copy.
func (m *Claims) Clone() *Claims {
res := &Claims{
Keys: make([]string, len(m.Keys)),
Values: make(map[string]ipld.Node, len(m.Values)),
}
copy(res.Keys, m.Keys)
for k, v := range m.Values {
res.Values[k] = v
}
return res
}

130
pkg/claims/claims_test.go Normal file
View File

@@ -0,0 +1,130 @@
package claims_test
import (
"crypto/rand"
"maps"
"testing"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/node/basicnode"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/pkg/claims"
)
func TestClaims_Add(t *testing.T) {
t.Parallel()
type Unsupported struct{}
t.Run("error if not primitive or Node", func(t *testing.T) {
t.Parallel()
err := (&claims.Claims{}).Add("invalid", &Unsupported{})
require.Error(t, err)
})
t.Run("encrypted claims", func(t *testing.T) {
t.Parallel()
key := make([]byte, 32)
_, err := rand.Read(key)
require.NoError(t, err)
m := claims.NewClaims()
// string encryption
err = m.AddEncrypted("secret", "hello world", key)
require.NoError(t, err)
_, err = m.GetString("secret")
require.Error(t, err) // the ciphertext is saved as []byte instead of string
decrypted, err := m.GetEncryptedString("secret", key)
require.NoError(t, err)
require.Equal(t, "hello world", decrypted)
// bytes encryption
originalBytes := make([]byte, 128)
_, err = rand.Read(originalBytes)
require.NoError(t, err)
err = m.AddEncrypted("secret-bytes", originalBytes, key)
require.NoError(t, err)
encryptedBytes, err := m.GetBytes("secret-bytes")
require.NoError(t, err)
require.NotEqual(t, originalBytes, encryptedBytes)
decryptedBytes, err := m.GetEncryptedBytes("secret-bytes", key)
require.NoError(t, err)
require.Equal(t, originalBytes, decryptedBytes)
// error cases
t.Run("error on unsupported type", func(t *testing.T) {
err := m.AddEncrypted("invalid", 123, key)
require.ErrorIs(t, err, claims.ErrNotEncryptable)
})
t.Run("error on invalid key size", func(t *testing.T) {
err := m.AddEncrypted("invalid", "test", []byte("short-key"))
require.Error(t, err)
require.Contains(t, err.Error(), "invalid key size")
})
t.Run("error on nil key", func(t *testing.T) {
err := m.AddEncrypted("invalid", "test", nil)
require.Error(t, err)
require.Contains(t, err.Error(), "encryption key is required")
})
})
}
func TestIterCloneEquals(t *testing.T) {
m := claims.NewClaims()
require.NoError(t, m.Add("foo", "bar"))
require.NoError(t, m.Add("baz", 1234))
expected := map[string]ipld.Node{
"foo": basicnode.NewString("bar"),
"baz": basicnode.NewInt(1234),
}
// claims -> iter
require.Equal(t, expected, maps.Collect(m.Iter()))
// readonly -> iter
ro := m.ReadOnly()
require.Equal(t, expected, maps.Collect(ro.Iter()))
// claims -> clone -> iter
clone := m.Clone()
require.Equal(t, expected, maps.Collect(clone.Iter()))
// readonly -> WriteableClone -> iter
wclone := ro.WriteableClone()
require.Equal(t, expected, maps.Collect(wclone.Iter()))
require.True(t, m.Equals(wclone))
require.True(t, ro.Equals(wclone.ReadOnly()))
}
func TestInclude(t *testing.T) {
m1 := claims.NewClaims()
require.NoError(t, m1.Add("samekey", "bar"))
require.NoError(t, m1.Add("baz", 1234))
m2 := claims.NewClaims()
require.NoError(t, m2.Add("samekey", "othervalue")) // check no overwrite
require.NoError(t, m2.Add("otherkey", 1234))
m1.Include(m2)
require.Equal(t, map[string]ipld.Node{
"samekey": basicnode.NewString("bar"),
"baz": basicnode.NewInt(1234),
"otherkey": basicnode.NewInt(1234),
}, maps.Collect(m1.Iter()))
}

64
pkg/claims/readonly.go Normal file
View File

@@ -0,0 +1,64 @@
package claims
import (
"iter"
"github.com/ipld/go-ipld-prime"
)
// ReadOnly wraps a Claims into a read-only facade.
type ReadOnly struct {
claims *Claims
}
func (r ReadOnly) GetBool(key string) (bool, error) {
return r.claims.GetBool(key)
}
func (r ReadOnly) GetString(key string) (string, error) {
return r.claims.GetString(key)
}
func (r ReadOnly) GetEncryptedString(key string, encryptionKey []byte) (string, error) {
return r.claims.GetEncryptedString(key, encryptionKey)
}
func (r ReadOnly) GetInt64(key string) (int64, error) {
return r.claims.GetInt64(key)
}
func (r ReadOnly) GetFloat64(key string) (float64, error) {
return r.claims.GetFloat64(key)
}
func (r ReadOnly) GetBytes(key string) ([]byte, error) {
return r.claims.GetBytes(key)
}
func (r ReadOnly) GetEncryptedBytes(key string, encryptionKey []byte) ([]byte, error) {
return r.claims.GetEncryptedBytes(key, encryptionKey)
}
func (r ReadOnly) GetNode(key string) (ipld.Node, error) {
return r.claims.GetNode(key)
}
func (r ReadOnly) Len() int {
return r.claims.Len()
}
func (r ReadOnly) Iter() iter.Seq2[string, ipld.Node] {
return r.claims.Iter()
}
func (r ReadOnly) Equals(other ReadOnly) bool {
return r.claims.Equals(other.claims)
}
func (r ReadOnly) String() string {
return r.claims.String()
}
func (r ReadOnly) WriteableClone() *Claims {
return r.claims.Clone()
}

135
pkg/command/command.go Normal file
View File

@@ -0,0 +1,135 @@
package command
import (
"fmt"
"strings"
)
const separator = "/"
var _ fmt.Stringer = (*Command)(nil)
// Command is a concrete messages (a "verb") that MUST be unambiguously
// interpretable by the Subject of a UCAN.
//
// A [Command] is composed of a leading slash which is optionally followed
// by one or more slash-separated Segments of lowercase characters.
//
// [Command]: https://github.com/ucan-wg/spec#command
type Command string
// New creates a validated command from the provided list of segment strings.
// An error is returned if an invalid Command would be formed
func New(segments ...string) Command {
return Top().Join(segments...)
}
// Parse verifies that the provided string contains the required
// [segment structure] and, if valid, returns the resulting
// Command.
//
// [segment structure]: https://github.com/ucan-wg/spec#segment-structure
func Parse(s string) (Command, error) {
if !strings.HasPrefix(s, "/") {
return "", ErrRequiresLeadingSlash
}
if len(s) > 1 && strings.HasSuffix(s, "/") {
return "", ErrDisallowsTrailingSlash
}
if s != strings.ToLower(s) {
return "", ErrRequiresLowercase
}
// The leading slash will result in the first element from strings.Split
// being an empty string which is removed as strings.Join will ignore it.
return Command(s), nil
}
// MustParse is the same as Parse, but panic() if the parsing fail.
func MustParse(s string) Command {
c, err := Parse(s)
if err != nil {
panic(err)
}
return c
}
// Top is the most powerful capability.
//
// This function returns a Command that is a wildcard and therefore represents the
// most powerful ability. As such, it should be handled with care and used sparingly.
//
// [Top]: https://github.com/ucan-wg/spec#-aka-top
func Top() Command {
return Command(separator)
}
// IsValid returns true if the provided string is a valid UCAN command.
func IsValid(s string) bool {
_, err := Parse(s)
return err == nil
}
// Join appends segments to the end of this command using the required
// segment separator.
func (c Command) Join(segments ...string) Command {
size := 0
for _, s := range segments {
size += len(s)
}
if size == 0 {
return c
}
buf := make([]byte, 0, len(c)+size+len(segments))
buf = append(buf, []byte(c)...)
for _, s := range segments {
if s != "" {
if len(buf) > 1 {
buf = append(buf, separator...)
}
buf = append(buf, []byte(s)...)
}
}
return Command(buf)
}
// Segments returns the ordered segments that comprise the Command as a
// slice of strings.
func (c Command) Segments() []string {
if c == separator {
return nil
}
return strings.Split(string(c), separator)[1:]
}
// Covers returns true if the command is identical or a parent of the given other command.
func (c Command) Covers(other Command) bool {
// fast-path, equivalent to the code below (verified with fuzzing)
if !strings.HasPrefix(string(other), string(c)) {
return false
}
return c == separator || len(c) == len(other) || other[len(c)] == separator[0]
/* -------
otherSegments := other.Segments()
if len(otherSegments) < len(c.Segments()) {
return false
}
for i, s := range c.Segments() {
if otherSegments[i] != s {
return false
}
}
return true
*/
}
// String returns the composed representation the command. This is also
// the required wire representation (before IPLD encoding occurs.)
func (c Command) String() string {
return string(c)
}

View File

@@ -0,0 +1,21 @@
package command
import "fmt"
// ErrRequiresLeadingSlash is returned when a parsing a string that
// doesn't start with a [leading slash character].
//
// [leading slash character]: https://github.com/ucan-wg/spec#segment-structure
var ErrRequiresLeadingSlash = fmt.Errorf("a command requires a leading slash character")
// ErrDisallowsTrailingSlash is returned when parsing a string that [ends
// with a trailing slash character].
//
// [ends with a trailing slash character]: https://github.com/ucan-wg/spec#segment-structure
var ErrDisallowsTrailingSlash = fmt.Errorf("a command must not include a trailing slash")
// ErrRequiresLowercase is returned if a Command contains, or would contain,
// [uppercase unicode characters].
//
// [uppercase unicode characters]: https://github.com/ucan-wg/spec#segment-structure
var ErrRequiresLowercase = fmt.Errorf("UCAN path segments must must not contain upper-case characters")

View File

@@ -5,7 +5,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/v1/capability/command" "github.com/ucan-wg/go-ucan/pkg/command"
) )
func TestTop(t *testing.T) { func TestTop(t *testing.T) {
@@ -13,74 +13,81 @@ func TestTop(t *testing.T) {
} }
func TestIsValidCommand(t *testing.T) { func TestIsValidCommand(t *testing.T) {
t.Parallel()
t.Run("succeeds when", func(t *testing.T) { t.Run("succeeds when", func(t *testing.T) {
t.Parallel()
for _, testcase := range validTestcases(t) { for _, testcase := range validTestcases(t) {
testcase := testcase
t.Run(testcase.name, func(t *testing.T) { t.Run(testcase.name, func(t *testing.T) {
t.Parallel()
require.True(t, command.IsValid(testcase.inp)) require.True(t, command.IsValid(testcase.inp))
}) })
} }
}) })
t.Run("fails when", func(t *testing.T) { t.Run("fails when", func(t *testing.T) {
t.Parallel()
for _, testcase := range invalidTestcases(t) { for _, testcase := range invalidTestcases(t) {
testcase := testcase
t.Run(testcase.name, func(t *testing.T) { t.Run(testcase.name, func(t *testing.T) {
t.Parallel()
require.False(t, command.IsValid(testcase.inp)) require.False(t, command.IsValid(testcase.inp))
}) })
} }
}) })
} }
func TestNew(t *testing.T) {
require.Equal(t, command.Top(), command.New())
require.Equal(t, "/foo", command.New("foo").String())
require.Equal(t, "/foo/bar", command.New("foo", "bar").String())
require.Equal(t, "/foo/bar/baz", command.New("foo", "bar/baz").String())
}
func TestParseCommand(t *testing.T) { func TestParseCommand(t *testing.T) {
t.Parallel()
t.Run("succeeds when", func(t *testing.T) { t.Run("succeeds when", func(t *testing.T) {
t.Parallel()
for _, testcase := range validTestcases(t) { for _, testcase := range validTestcases(t) {
testcase := testcase
t.Run(testcase.name, func(t *testing.T) { t.Run(testcase.name, func(t *testing.T) {
t.Parallel()
cmd, err := command.Parse("/elem0/elem1/elem2") cmd, err := command.Parse("/elem0/elem1/elem2")
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, cmd) require.NotEmpty(t, cmd)
}) })
} }
}) })
t.Run("fails when", func(t *testing.T) { t.Run("fails when", func(t *testing.T) {
t.Parallel()
for _, testcase := range invalidTestcases(t) { for _, testcase := range invalidTestcases(t) {
testcase := testcase
t.Run(testcase.name, func(t *testing.T) { t.Run(testcase.name, func(t *testing.T) {
t.Parallel()
cmd, err := command.Parse(testcase.inp) cmd, err := command.Parse(testcase.inp)
require.ErrorIs(t, err, command.ErrParse)
require.ErrorIs(t, err, testcase.err) require.ErrorIs(t, err, testcase.err)
require.Nil(t, cmd) require.Zero(t, cmd)
}) })
} }
}) })
} }
func TestEquality(t *testing.T) {
require.True(t, command.MustParse("/foo/bar/baz") == command.MustParse("/foo/bar/baz"))
require.False(t, command.MustParse("/foo/bar/baz") == command.MustParse("/foo/bar/bazz"))
require.False(t, command.MustParse("/foo/bar") == command.MustParse("/foo/bar/baz"))
}
func TestJoin(t *testing.T) {
require.Equal(t, "/foo", command.Top().Join("foo").String())
require.Equal(t, "/foo/bar", command.Top().Join("foo/bar").String())
require.Equal(t, "/foo/bar", command.Top().Join("foo", "bar").String())
require.Equal(t, "/faz/boz/foo/bar", command.MustParse("/faz/boz").Join("foo/bar").String())
require.Equal(t, "/faz/boz/foo/bar", command.MustParse("/faz/boz").Join("foo", "bar").String())
}
func TestSegments(t *testing.T) {
require.Empty(t, command.Top().Segments())
require.Equal(t, []string{"foo", "bar", "baz"}, command.MustParse("/foo/bar/baz").Segments())
}
func TestCovers(t *testing.T) {
require.True(t, command.MustParse("/foo/bar/baz").Covers(command.MustParse("/foo/bar/baz")))
require.True(t, command.MustParse("/foo/bar").Covers(command.MustParse("/foo/bar/baz")))
require.False(t, command.MustParse("/foo/bar/baz").Covers(command.MustParse("/foo/bar")))
require.True(t, command.MustParse("/").Covers(command.MustParse("/foo")))
require.True(t, command.MustParse("/").Covers(command.MustParse("/foo/bar/baz")))
require.False(t, command.MustParse("/foo").Covers(command.MustParse("/foo00")))
require.False(t, command.MustParse("/foo/bar").Covers(command.MustParse("/foo/bar00")))
}
type testcase struct { type testcase struct {
name string name string
inp string inp string
@@ -134,20 +141,6 @@ func invalidTestcases(t *testing.T) []errorTestcase {
}, },
err: command.ErrDisallowsTrailingSlash, err: command.ErrDisallowsTrailingSlash,
}, },
{
testcase: testcase{
name: "only reserved ucan namespace",
inp: "/ucan",
},
err: command.ErrUCANNamespaceReserved,
},
{
testcase: testcase{
name: "reserved ucan namespace prefix",
inp: "/ucan/elem0/elem1/elem2",
},
err: command.ErrUCANNamespaceReserved,
},
{ {
testcase: testcase{ testcase: testcase{
name: "uppercase character are present", name: "uppercase character are present",

88
pkg/container/Readme.md Normal file
View File

@@ -0,0 +1,88 @@
# Token container
The specification has been promoted to https://github.com/ucan-wg/container.
## Why do I need that?
Some common situation asks to package multiple tokens together:
- calling a service requires sending an invocation, alongside the matching delegations
- sending a series of revocations
- \<insert your application specific scenario here>
The UCAN specification defines how a single token is serialized (envelope with signature, IPLD encoded as Dag-cbor), but it's entirely left open how to package multiple tokens together. To be clear, this is a correct thing to do for a specification, as different ways equally valid to solve that problem exists and can coexist. Any wire format holding a list of bytes would do (cbor, json, csv ...).
**go-ucan** however, provide an opinionated implementation, which may or may not work in your situation.
Some experiment has been done over which format is appropriate, and two have been selected:
- **DAG-CBOR** of a list of bytes, as a low overhead option
- **CAR** file, as a somewhat common ways to cary arbitrary blocks of data
Notably, **compression is not included**, even though it does work reasonably well. This is because your transport medium might already do it, or should.
## Wire format consideration
Several possible formats have been explored:
- CAR files (binary or base64)
- DAG-CBOR (binary or base64)
Additionally, gzip and deflate compression has been experimented with.
Below are the results in terms of storage used, as percentage and byte overhead over the raw tokens:
| Token count | car | carBase64 | carGzip | carGzipBase64 | cbor | cborBase64 | cborGzip | cborGzipBase64 | cborFlate | cborFlateBase64 |
|-------------|-----|-----------|---------|---------------|------|------------|----------|----------------|-----------|-----------------|
| 1 | 15 | 54 | 7 | 42 | 0 | 35 | \-8 | 22 | \-12 | 16 |
| 2 | 12 | 49 | \-12 | 15 | 0 | 34 | \-25 | 0 | \-28 | \-3 |
| 3 | 11 | 48 | \-21 | 4 | 0 | 34 | \-32 | \-10 | \-34 | \-11 |
| 4 | 10 | 47 | \-26 | \-1 | 0 | 34 | \-36 | \-15 | \-37 | \-17 |
| 5 | 10 | 47 | \-28 | \-4 | 0 | 34 | \-38 | \-18 | \-40 | \-20 |
| 6 | 10 | 47 | \-30 | \-7 | 0 | 34 | \-40 | \-20 | \-40 | \-20 |
| 7 | 10 | 46 | \-31 | \-8 | 0 | 34 | \-41 | \-21 | \-42 | \-22 |
| 8 | 9 | 46 | \-32 | \-10 | 0 | 34 | \-42 | \-22 | \-42 | \-23 |
| 9 | 9 | 46 | \-33 | \-11 | 0 | 34 | \-43 | \-23 | \-43 | \-24 |
| 10 | 9 | 46 | \-34 | \-12 | 0 | 34 | \-43 | \-25 | \-44 | \-25 |
![Overhead %](img/overhead_percent.png)
| Token count | car | carBase64 | carGzip | carGzipBase64 | cbor | cborBase64 | cborGzip | cborGzipBase64 | cborFlate | cborFlateBase64 |
|-------------|-----|-----------|---------|---------------|------|------------|----------|----------------|-----------|-----------------|
| 1 | 64 | 226 | 29 | 178 | 4 | 146 | \-35 | 94 | \-52 | 70 |
| 2 | 102 | 412 | \-107 | 128 | 7 | 288 | \-211 | 0 | \-234 | \-32 |
| 3 | 140 | 602 | \-270 | 58 | 10 | 430 | \-405 | \-126 | \-429 | \-146 |
| 4 | 178 | 792 | \-432 | \-28 | 13 | 572 | \-602 | \-252 | \-617 | \-288 |
| 5 | 216 | 978 | \-582 | \-94 | 16 | 714 | \-805 | \-386 | \-839 | \-418 |
| 6 | 254 | 1168 | \-759 | \-176 | 19 | 856 | \-1001 | \-508 | \-1018 | \-520 |
| 7 | 292 | 1358 | \-908 | \-246 | 22 | 998 | \-1204 | \-634 | \-1229 | \-650 |
| 8 | 330 | 1544 | \-1085 | \-332 | 25 | 1140 | \-1398 | \-756 | \-1423 | \-792 |
| 9 | 368 | 1734 | \-1257 | \-414 | 28 | 1282 | \-1614 | \-894 | \-1625 | \-930 |
| 10 | 406 | 1924 | \-1408 | \-508 | 31 | 1424 | \-1804 | \-1040 | \-1826 | \-1060 |
![img.png](img/overhead_bytes.png)
Following is the performance aspect, with CPU usage and memory allocation:
| | Write ns/op | Read ns/op | Write B/op | Read B/op | Write allocs/op | Read allocs/op |
|-----------------|-------------|------------|------------|-----------|-----------------|----------------|
| car | 8451 | 1474630 | 17928 | 149437 | 59 | 2631 |
| carBase64 | 16750 | 1437678 | 24232 | 151502 | 61 | 2633 |
| carGzip | 320253 | 1581412 | 823887 | 192272 | 76 | 2665 |
| carGzipBase64 | 343305 | 1486269 | 828782 | 198543 | 77 | 2669 |
| cbor | 6419 | 1301554 | 16368 | 138891 | 25 | 2534 |
| cborBase64 | 12860 | 1386728 | 20720 | 140962 | 26 | 2536 |
| cborGzip | 310106 | 1379146 | 822742 | 182003 | 42 | 2585 |
| cborGzipBase64 | 317001 | 1462548 | 827640 | 189283 | 43 | 2594 |
| cborFlate | 327112 | 1555007 | 822473 | 181537 | 40 | 2591 |
| cborFlateBase64 | 311276 | 1456562 | 826042 | 188665 | 41 | 2596 |
(BEWARE: logarithmic scale)
![img.png](img/cpu.png)
![img_1.png](img/alloc_byte.png)
![img_2.png](img/alloc_count.png)
Conclusion:
- CAR files are heavy for this usage, notably because they carry the CIDs of the tokens
- compression works quite well and warrants its usage even with a single token
- DAG-CBOR outperform CAR files everywhere, and comes with a tiny ~3 bytes per token overhead.
**Formats beside DAG-CBOR and CAR, with or without base64, have been removed. They are in the git history though.**

263
pkg/container/car.go Normal file
View File

@@ -0,0 +1,263 @@
package container
import (
"bufio"
"bytes"
"encoding/binary"
"fmt"
"io"
"iter"
"github.com/ipfs/go-cid"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec/dagcbor"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/fluent/qp"
cidlink "github.com/ipld/go-ipld-prime/linking/cid"
"github.com/ipld/go-ipld-prime/node/basicnode"
)
/*
Note: below is essentially a re-implementation of the CAR file v1 read and write.
This exists here for two reasons:
- go-car's API forces to go through an IPLD getter or through a blockstore API
- generally, go-car is a very complex and large dependency
*/
// EmptyCid is a "zero" Cid: zero-length "identity" multihash with "raw" codec
// It can be used to have at least one root in a CARv1 file (making it legal), yet
// denote that it can be ignored.
var EmptyCid = cid.MustParse([]byte{01, 55, 00, 00})
type carBlock struct {
c cid.Cid
data []byte
}
// writeCar writes a CARv1 file containing the blocks from the iterator.
// If no roots are provided, a single EmptyCid is used as root to make the file
// spec compliant.
func writeCar(w io.Writer, roots []cid.Cid, blocks iter.Seq2[carBlock, error]) error {
if len(roots) == 0 {
roots = []cid.Cid{EmptyCid}
}
h := carHeader{
Roots: roots,
Version: 1,
}
hb, err := h.Write()
if err != nil {
return err
}
err = ldWrite(w, hb)
if err != nil {
return err
}
for block, err := range blocks {
if err != nil {
return err
}
err = ldWrite(w, block.c.Bytes(), block.data)
if err != nil {
return err
}
}
return nil
}
// readCar reads a CARv1 file from the reader, and return a block iterator.
// Roots are ignored.
func readCar(r io.Reader) (roots []cid.Cid, blocks iter.Seq2[carBlock, error], err error) {
br := bufio.NewReader(r)
hb, err := ldRead(br)
if err != nil {
return nil, nil, err
}
h, err := readHeader(hb)
if err != nil {
return nil, nil, err
}
if h.Version != 1 {
return nil, nil, fmt.Errorf("invalid car version: %d", h.Version)
}
return h.Roots, func(yield func(block carBlock, err error) bool) {
for {
block, err := readBlock(br)
if err == io.EOF {
return
}
if err != nil {
if !yield(carBlock{}, err) {
return
}
}
if !yield(block, nil) {
return
}
}
}, nil
}
// readBlock reads a section from the reader and decode a (cid+data) block.
func readBlock(r *bufio.Reader) (carBlock, error) {
raw, err := ldRead(r)
if err != nil {
return carBlock{}, err
}
n, c, err := cid.CidFromReader(bytes.NewReader(raw))
if err != nil {
return carBlock{}, err
}
data := raw[n:]
// integrity check
hashed, err := c.Prefix().Sum(data)
if err != nil {
return carBlock{}, err
}
if !hashed.Equals(c) {
return carBlock{}, fmt.Errorf("mismatch in content integrity, name: %s, data: %s", c, hashed)
}
return carBlock{c: c, data: data}, nil
}
// maxAllowedSectionSize dictates the maximum number of bytes that a CARv1 header
// or section is allowed to occupy without causing a decode to error.
// This cannot be supplied as an option, only adjusted as a global. You should
// use v2#NewReader instead since it allows for options to be passed in.
var maxAllowedSectionSize uint = 32 << 20 // 32MiB
// ldRead performs a length-delimited read of a section from the reader.
// A section is composed of an uint length followed by the data.
func ldRead(r *bufio.Reader) ([]byte, error) {
if _, err := r.Peek(1); err != nil { // no more blocks, likely clean io.EOF
return nil, err
}
l, err := binary.ReadUvarint(r)
if err != nil {
if err == io.EOF {
return nil, io.ErrUnexpectedEOF // don't silently pretend this is a clean EOF
}
return nil, err
}
if l == 0 {
return nil, fmt.Errorf("invalid zero size section")
}
if l > uint64(maxAllowedSectionSize) { // Don't OOM
return nil, fmt.Errorf("malformed car; header is bigger than MaxAllowedSectionSize")
}
buf := make([]byte, l)
if _, err := io.ReadFull(r, buf); err != nil {
if err == io.EOF {
// we should be able to read the promised bytes, this is not normal
return nil, io.ErrUnexpectedEOF
}
return nil, err
}
return buf, nil
}
// ldWrite performs a length-delimited write of a section on the writer.
// A section is composed of an uint length followed by the data.
func ldWrite(w io.Writer, d ...[]byte) error {
var sum uint64
for _, s := range d {
sum += uint64(len(s))
}
buf := make([]byte, 8)
n := binary.PutUvarint(buf, sum)
_, err := w.Write(buf[:n])
if err != nil {
return err
}
for _, s := range d {
_, err = w.Write(s)
if err != nil {
return err
}
}
return nil
}
type carHeader struct {
Roots []cid.Cid
Version uint64
}
const rootsKey = "roots"
const versionKey = "version"
func readHeader(data []byte) (*carHeader, error) {
var header carHeader
nd, err := ipld.Decode(data, dagcbor.Decode)
if err != nil {
return nil, err
}
if nd.Length() != 2 {
return nil, fmt.Errorf("malformed car header")
}
rootsNd, err := nd.LookupByString(rootsKey)
if err != nil {
return nil, fmt.Errorf("malformed car header")
}
it := rootsNd.ListIterator()
if it == nil {
return nil, fmt.Errorf("malformed car header")
}
header.Roots = make([]cid.Cid, 0, rootsNd.Length())
for !it.Done() {
_, nd, err := it.Next()
if err != nil {
return nil, err
}
lk, err := nd.AsLink()
if err != nil {
return nil, err
}
switch lk := lk.(type) {
case cidlink.Link:
header.Roots = append(header.Roots, lk.Cid)
default:
return nil, fmt.Errorf("malformed car header")
}
}
versionNd, err := nd.LookupByString(versionKey)
if err != nil {
return nil, fmt.Errorf("malformed car header")
}
version, err := versionNd.AsInt()
if err != nil {
return nil, fmt.Errorf("malformed car header")
}
header.Version = uint64(version)
return &header, nil
}
func (ch *carHeader) Write() ([]byte, error) {
nd, err := qp.BuildMap(basicnode.Prototype.Any, 2, func(ma datamodel.MapAssembler) {
qp.MapEntry(ma, rootsKey, qp.List(int64(len(ch.Roots)), func(la datamodel.ListAssembler) {
for _, root := range ch.Roots {
qp.ListEntry(la, qp.Link(cidlink.Link{Cid: root}))
}
}))
qp.MapEntry(ma, versionKey, qp.Int(1))
})
if err != nil {
return nil, err
}
return ipld.Encode(nd, dagcbor.Encode)
}

85
pkg/container/car_test.go Normal file
View File

@@ -0,0 +1,85 @@
package container
import (
"bytes"
"os"
"testing"
"github.com/stretchr/testify/require"
)
func TestCarRoundTrip(t *testing.T) {
// this car file is a complex and legal CARv1 file
original, err := os.ReadFile("testdata/sample-v1.car")
require.NoError(t, err)
roots, it, err := readCar(bytes.NewReader(original))
require.NoError(t, err)
var blks []carBlock
for blk, err := range it {
require.NoError(t, err)
blks = append(blks, blk)
}
require.Len(t, blks, 1049)
buf := bytes.NewBuffer(nil)
err = writeCar(buf, roots, func(yield func(carBlock, error) bool) {
for _, blk := range blks {
if !yield(blk, nil) {
return
}
}
})
require.NoError(t, err)
// Bytes equal after the round-trip
require.Equal(t, original, buf.Bytes())
}
func FuzzCarRoundTrip(f *testing.F) {
// Note: this fuzzing is somewhat broken.
// After some time, the fuzzer discover that a varint can be serialized in different
// ways that lead to the same integer value. This means that the CAR format can have
// multiple legal binary representation for the exact same data, which is what we are
// trying to detect here. Ideally, the format would be stricter, but that's how things
// are.
example, err := os.ReadFile("testdata/sample-v1.car")
require.NoError(f, err)
f.Add(example)
f.Fuzz(func(t *testing.T, data []byte) {
roots, blocksIter, err := readCar(bytes.NewReader(data))
if err != nil {
// skip invalid binary
t.Skip()
}
// reading all the blocks, which force reading and verifying the full file
var blocks []carBlock
for block, err := range blocksIter {
if err != nil {
// error reading, invalid data
t.Skip()
}
blocks = append(blocks, block)
}
var buf bytes.Buffer
err = writeCar(&buf, roots, func(yield func(carBlock, error) bool) {
for _, blk := range blocks {
if !yield(blk, nil) {
return
}
}
})
require.NoError(t, err)
// test if the round-trip produce a byte-equal CAR
require.Equal(t, data, buf.Bytes())
})
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
OH4sIAAAAAAAA/5zXa5MU1R3HcXYjIOAVMQZQUQElCGx3n9M3wLhzn8xl3ZnpuSLB0+ecnp6enft9VBJWjSAkxEVXkBUJAgbULTUq4gULlWhQiauilKiFgiaFoIhoCVFIpXY3VXm2s2/g++RTv+7/+bOGi+nZZfauaNOGzkgrmfzUPZ9cOm50aPP2jdPmvbb81I3vbtiTWHDmwAcZZt+W78+6vCU3Irb6u23LjnR37vp07d5Iz7nHX9+96OnXlsw8e9Oaj87agHQnbDradLTpolyhhFG6hXTEW9k5zBxmdh7PYf+CUYlUJZIgc5O0NrcueJOJrDlsLkKXwBnEYlciBgsAWyqFgdnpy6oxPWU3SCpQE0L+KI9xiugtWibToqI8ptXsJL2rZRtOFAr/3yxqAQfJqG6iS6561FqQsk7ZkecUt9cac9WSpOSGJb9d9njNsIqzmY4lt2HU0YHnLFh4G/qVNqeMOkq0GRdK6vCzJEWL6CFdALzMsQIyEAZUYjlW0nRJJAwPNN5QBY3lAAC8oKuYUsLKqqFRAau8RgGl6UwaU891vofXTUn5Lu58ZDzpRzqlf63e713VHbzpqg8X3iqFfzniqfGtdlx7DrzQfPbDT2RXHLnnX9e/fLJp/eFR27974d9/3HHJyusXt7+Zcoyd9uIPSzePGAZSho/biynJrhC3JrEIFeSUkOc9rmSbwWqlMKl5Wc2d5kRUz1iGjOTLxLwatBZVv9tdkWFIoylbDvjsjjbOXSt7JFhkXVQgBS5ZagSpgewAEkcYqFHAGSojIEJEyCNdFjmMoUoNBjEyIBJhNR0JjEQIkQ1WhiJCSMBkAMl427RzzztvjD8G9jP9SONm9dxw6dFNH78+uqfpgtRV3ZEfJ3jfbfvkw0MLr/J2r205cGjrvOVvdNaa4tPO7GpfMWXU12+8N4ELOK/tu2DJzrc7rztnGEglu0VM521lPl0LmqOuhBRMZ/KC1ahYRXdSUax5bM9nqlRtJ5nKUJFyLjtvMoMyF0yaCvlE0mGLI1MkAKGlFOAEEiaKOx0qUls4YKo3gNRIdgBJpIwIJcgaUCaaJmkaILosaAQLEm8IkgiIpAmY0xECDFUhNhCUiCxzGGgDSIuv+WnPyHPyh1f3XRDuR/p2VXRu29o7XKfEVTN6F/kfv2XLyyfI7vWT+27+NvDZoZk3L7jm+Jm3Os9qPbjd0/fNHdmtTcKnnfKMpnVLf977PTjw2djhfO7CAQLNKFVS89GU325j0/ZcO0ybc+4QnyhhUfTasSoVK1aHxxgqklZIB+yYOjhUZWvRrNNVjqlK1iFUTTZTwFOLpMopW6ZYyeZ03t8AUiPZwSVJjIoAgwxNBSzlOVVWdaRJVOAwMgQKMKQSFXmdcARSEUkGFVUWS4IApAGkhyb+bPHD1yg3jHt1m9KP1D7Z81H2oxu/WEaZY5NOPtrT8fQGafFXc2/7HNw+4q5/2vnn9yJ2zIQFm8907niQ+eKbg9Gde73li/RJnv3dpmV/rY8cBpKRC1lNpmBBL/uSFVd7RkplEuFSOJRzJ4rZTCwlxnRLu9RWZKNCdqhImbBIY6Ag2CxqWrbFcrYsiXj8SibSHpDEWtRjCmeor8x6VL8oNYDUSHZwSVDGEuVYQxR5ATAaAZqu8ghSGQGDhRLAgOVkrGPK8xLPA0OgqkqoIKqDS1o/ZdHt7ldffNPyt3un9yPN+MU73vKTuw8F1z17+fe+51Yefn+s5+rz1t0Fn36m9akHz7viifvOXV1r//KVWRvbn22euGD9LPjK3d0FX2zTe4vgyMuOjRsGkibmcKCuhMPhesxQirLVAkvAEtUDvmilYI5EjIALAIGIfCwcHfKSUgVH0JZJKW2yFpFs2GKEzdlstaLkk0otmw6l7YYt68rURDGXa2RJDWQHkJj/fus4jTeoTCiUACMLOsuyLIepZlABUchCIEm6jCmLeYE3VKgyPOE1QR1Amsif2hd9xD//84nTf92PVDgynzy//f4Pbthd2fXV6dZXxl7bd1oYX7LN61o7f/TyS0bfd2Tes4ww76Y9S5ofK+y7+bONZ59/ZzI/6qLN+49dvX7mlObhHA5Wtz9ZTHJVZ8hU8lS5rN+XUarhUpuiQFeUr+QqcrBsWKyBSMQ0VKR4oEYdqbqYj8slrlyzSL6YyezWqZoTSDgcr5hqCleL1jTBldUbQGokO4gkEkGDCBuiJsssAJKGdEEVMRUhMVRKOVZlBRHoIoZAZBneIAyrchpiODyA9OBLv5tv77LPvqK+98p+pMyOH1b2jFrW+vfukx1XjlkxsnjnNPWdW3t63dHO5nMegCO37Np74uSkNZ7I0i1be0949RWt5y/v/P29k17auiZ0r/PCYS0pqoZ8ArZhD1EVn+IuakF/LOV3hi1WSYmgSDEfzJjSrhCXrg75BE9mcEGwu4uGWrRWg1az3aaLDo56QnWTJeJ3pKqRUnsorNkKXgQaQGokO4DEU5lqiFCDZ6jGMzIiSBcow/EaRw3IExYQwkFGxwKHJIGTDBkwGtIAwoMn+CfL7+77urfL/UxtwS39SH+6sGvR/M3b7j489vTU1OwxHxxnp7rWHVx98fpL9ph/M/fEq1ue+e1N/xDGNz9xCzw8q3vyZT2jp46ePuP9mZvqo/bv+6llOP+kuMfscECbC5eddZJTfHlT2M5bk4GEYvWZS3mxElTCKdkUS8XjcMjXHUtLCaermiY4S2g8zpoiRqIcssplwuUlECoqeiXutikRACONXHcNZAeXpKqCqMmcgURW1lgsAUkHkGUxo6kGhYSIEksg0mXACEjloAGhCmRVZoXBJS2LGo9+PG7/LrnvXXc/0iOu5JnKZS0/9n65Za8n+d3jdfGtx5ZEVtktu9uPT1+9Z8J2T8fGrulTS5mltvn3H+xyHljjfODJHWt3LkycGfOYDYwaBlLF7ra4goKzXg2zyJXysnwlTKS8I2su5R12iiqkXKon4gb0ytyQ30nBONtWymUrZU/cUi9DLppTvJZglavadJxyF5N2wSSBkDNiVWONvJMayP5vSURSkcAYLBYBx0CEVF0SoarxSDQIzyFGRZJMdY2DHBEk2ZA5RmU4BjBwAOlopu2+lb0heeXJP9zxnwAAAP//V8QWnXsQAAA=

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
PH4sIAAAAAAAA_5zW65MU1f3HcS7-hB8igpfIgqUxCqLC0tfTfUADMzs7M87ObubaOzMEsM-lp6eb6Zmee49GvEKwVBQRgygIUVHLCwoqoJAAikCQaJRICCqFGPCCcXVFoaBMJbukKs929h94P3nV-ZzvSg2XrEkV9u7kwFW3J6bv7bj7tflfrjm4e8_grc7Dke4jPx6c_Mm87olH_O9Of35_59-X7z2xe056dKx7156F074aOXzCcemCRe-Y4T3n3v7SNV-fdd4qVfcLA48NPDbwArtYxqo1mcxJT2ebmWZmUgE3s09jtUxqMsmQKSZ1ptRBu5lF0WK75ZbawpVO25NW2rLtfgG5XFZKtlwBr1GPVfyKU9YjZS_BOEv0yVouNxmpBUxr-TH6osmv4Uyx-L9NM5PUWqLtmqTohbQnkWp1i5JlxiOWLxAFAdaJeU13Z0gwWsN2AOdzc267A6tz5uDmGTPvUH-pNVfUOWU6CBfLqP9ZkqUl9fc6w3KqRmTVUDWGB1BiiKoDCBhJptRABAuCxrJY0zHPQBZjbLCY8hKCVBOplbMwDXbdsO9O4ZT5m20fTPywBymh3HfTBjDl5MzlT65xpm15kC4-f_2hlTtKyQUbrj78wBPfjn1e3T9i7OmuEfmt25d4z_q2K_0H36lRiw_9NbK0Szy0d3A_kPKuFk6qOYqVTSCj2uGNknxdT0BN0Etlqklxr8QaSqzF468oQl-R0oo7rztKQaK5crqzmMq7vOV4uh4jFZ2zPIrfFL0hS_ZYOFikDSA1ku1FAqJEZIRUA2gMy0OeY4EOKGVlHmCDIgZKKhYhp2NKGQR5YvBYZAWVp4j0Io1b4N905xU_e_SJj1-4pAfpbefa207fnMit_9XP39xxw7D3Fk0bH7vs09W_3uwcG3Vk5Nxv14yYP2Zj7lLPQ01Nnw0bsmXTssuZprL6-dX3zB3U9NH9q8_tB1KhgwUG8gA54HH5OcMyW1vqjmLnO11GNB0Ph6ut8WI1zAbdSMv2FclKhWK2riCS9trFvDvmd3tC0bpo8SAeC5QExZfiO3LeIOuKePkGkBrJnkEiWJYlnjEg1nhERSKouowIUgEvGUTGRBN5Ims6QiyVZVUwNIp4GUNOxr1I199x4OH_y6103Q-3DOhBuhYw1x1Y9eHYjejREfahp2Z9SNaqo83Xb0yff-mFr3_srFg7at3FV2aEy28atv2p8zbc-313YOQ3y65gm1Z9MHzdipHm0H4gFYmaLrQGWjpFLp4ICmagamWKHI560tlAsoa1uNcotoQKWj7fGuvz3Ck1v5hJgvZQJGYIKTlcFrypWocVLHPuEC_oWa2e0M3OVsen-BuZuwayZ-YOckQARDJ4TLAsChqUdVHVOBkA1pA5GVKGkaiqA04CDISiIcoUMhRjCHuRmjJraxO7f_ji-PiTE3pf0g83F4Zc-hF4-7uTV4XPG7xw986TC__2_qlnjrf9OLDrrKfeXioPGvtN0-NHRv8OHh7_5fzP1V3Lt40Zar-z-c3Vtx5O54b0AykTCrGsVS1iKmkdapQ1q6lqKWkV2gD1J3MIY09SlHw5O6yHgn1FqnoTLU66YobsbASoKq46rmoka2c8LomNkHBFYH2VVldNa-GTQgNIjWR7kTiVAIYRsYEFTcMCgpDqogw4TFXGwKqmYaACRtMJ4ClmEDAQFaHAYAbSXqRXxt9z8fIVxXOXjpjI9SDtXIkK6MJ3T6xbv-WuN7azi9K8UGl5a1t056x9G6fgxJJ5g03lSBk_O2juXV0Lnh2kVHfIwcqrKw-9dboId5X-eU5__iRPLhyxMsF03C9ZnWYKtRfZajIbUDjb68k7XFG2UL5adKesKOwrku42qkgrRaK2lI1jYJaMelnpaLf9RhW0sfmUrjk-FymaAcHBDSA1ku1FYjVeBBpGBqUCpjIUKdI5TuAwwqqhiQAAyvAQ6BoDBCgiwSCizMsSQhzfi_SP-2K_-PGE9saxTzMze5Dy-z0P7Di679olRys3fzdIXbZgbpvy0Krm5oXnnP_1w9yzj0yF8_64bCjzW2H22feEv48NLk6_8UD32fWPNvlf_CS18OUB_UDSE1WcaNUKSsqx_SjA-zNeLYI4T0cybfM4pbMg4AknnbbOetjqK1K-blcKUt4DWcNrVBzBIiJ2fGE7W8gCKZbI4WhBy4KkP9MWhA0gNZL9LxJLVMQhA_KEkTAjEkbnBZFILMQGQRLPi5gSpAOqEShqqoFFWZA4jCHoRRLeO9XcMqtwy7p35cd6kIY99snirRBtnvqcHN9benz4hCuG3LJt9bafZki7thTP_mHpi5PGXfbY6N1Nm68Z992tLD51S-q5DR98dsnhAbNSNzwZvOr_-4FUTdpy0XIjlg0nZSHkqaneUBVUCylgZEtmwQ3LhZrhR3WtkrX7PHegSpISVKtKuCYFyu11s5pK6FHbR112BNtsIZUOt1fSHVIuLzYydw1kz8ydoGqUF4nBizIHZYFyog5ZLHCU0QwEOZGROVEiusr824blDCgBSSIMgGdO8ClPP7KYPJnsQIeKG3uQrnz5teFvvD98hrrOc3nrmK3rn9s_-09PjBzaddOwxDl3Hz1xfEx810uPhvYcbL3sL9dNWjnuxamjXnCFEheAP29i5nXf3tGf6y7r6qwrJZfHlLyGLCvuWCf05Qp6JGDYBaNWqnvS5bKN_VRBVaWvSDnJBG4lHBEAl05yIccVIHZabmmLBttdobSjaKY3FRY9alvY42oAqZFsL5KKCAaEQkMlokgpyzKijhlRpaIoGZClCLCU1ahOIGQhFWSDQwLLqkhlzrwkvOH-ScEtSw68Omr87B6kpYWRa185WbvuC8u8aMC0i1pXbHeO72P9982fsHhh2xbf8WGe0y5Zq-wAk3YenZ05evG93MzVAx78aXJlxr1r2hce7A9Srqh6jIIVjNU7INENUg7Tqmz7tFBb2pfy-oq6zxuOOO0kWo27-3yC58tCwi900DxOtpk-k2txFCfuCGKllnOscqVk-W0N1dmoFcw3coI3kO1FElVWYhiiGhzgCS-xPES6zFKVAZxgyIAXJZWjAOsygppAEG_8Z-wIQZzai7Ri5eyp3V8981nL9fET_woAAP__svLMp3sQAAA

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,21 @@
package containertest
import _ "embed"
//go:embed Base64StdPadding
var Base64StdPadding string
//go:embed Base64StdPaddingGzipped
var Base64StdPaddingGzipped string
//go:embed Base64URL
var Base64URL string
//go:embed Base64URLGzipped
var Base64URLGzipped string
//go:embed Bytes
var Bytes []byte
//go:embed BytesGzipped
var BytesGzipped []byte

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
pkg/container/img/cpu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

118
pkg/container/packaging.go Normal file
View File

@@ -0,0 +1,118 @@
package container
import (
"compress/gzip"
"encoding/base64"
"errors"
"fmt"
"io"
)
const containerVersionTag = "ctn-v1"
type header byte
const (
headerRawBytes = header(0x40)
headerBase64StdPadding = header(0x42)
headerBase64URL = header(0x43)
headerRawBytesGzip = header(0x4D)
headerBase64StdPaddingGzip = header(0x4F)
headerBase64URLGzip = header(0x50)
)
func (h header) encoder(w io.Writer) *payloadWriter {
res := &payloadWriter{rawWriter: w, writer: w, header: h}
switch h {
case headerBase64StdPadding, headerBase64StdPaddingGzip:
b64Writer := base64.NewEncoder(base64.StdEncoding, res.writer)
res.writer = b64Writer
res.closers = append([]io.Closer{b64Writer}, res.closers...)
case headerBase64URL, headerBase64URLGzip:
b64Writer := base64.NewEncoder(base64.RawURLEncoding, res.writer)
res.writer = b64Writer
res.closers = append([]io.Closer{b64Writer}, res.closers...)
}
switch h {
case headerRawBytesGzip, headerBase64StdPaddingGzip, headerBase64URLGzip:
gzipWriter := gzip.NewWriter(res.writer)
res.writer = gzipWriter
res.closers = append([]io.Closer{gzipWriter}, res.closers...)
}
return res
}
func payloadDecoder(r io.Reader) (io.Reader, error) {
headerBuf := make([]byte, 1)
_, err := r.Read(headerBuf)
if err != nil {
return nil, err
}
h := header(headerBuf[0])
switch h {
case headerRawBytes,
headerBase64StdPadding,
headerBase64URL,
headerRawBytesGzip,
headerBase64StdPaddingGzip,
headerBase64URLGzip:
default:
return nil, fmt.Errorf("unknown container header")
}
switch h {
case headerBase64StdPadding, headerBase64StdPaddingGzip:
r = base64.NewDecoder(base64.StdEncoding, r)
case headerBase64URL, headerBase64URLGzip:
r = base64.NewDecoder(base64.RawURLEncoding, r)
}
switch h {
case headerRawBytesGzip, headerBase64StdPaddingGzip, headerBase64URLGzip:
gzipReader, err := gzip.NewReader(r)
if err != nil {
return nil, err
}
r = gzipReader
}
return r, nil
}
var _ io.WriteCloser = &payloadWriter{}
// payloadWriter is tasked with two things:
// - prepend the header byte
// - call Close() on all the underlying io.Writer
type payloadWriter struct {
rawWriter io.Writer
writer io.Writer
header header
headerWrote bool
closers []io.Closer
}
func (w *payloadWriter) Write(p []byte) (n int, err error) {
if !w.headerWrote {
_, err := w.rawWriter.Write([]byte{byte(w.header)})
if err != nil {
return 0, err
}
w.headerWrote = true
}
return w.writer.Write(p)
}
func (w *payloadWriter) Close() error {
var errs error
for _, closer := range w.closers {
if err := closer.Close(); err != nil {
errs = errors.Join(errs, err)
}
}
return errs
}

235
pkg/container/reader.go Normal file
View File

@@ -0,0 +1,235 @@
package container
import (
"bytes"
"fmt"
"io"
"iter"
"strings"
"github.com/ipfs/go-cid"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec/cbor"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ucan-wg/go-ucan/token"
"github.com/ucan-wg/go-ucan/token/delegation"
"github.com/ucan-wg/go-ucan/token/invocation"
)
var ErrNotFound = fmt.Errorf("not found")
var ErrMultipleInvocations = fmt.Errorf("multiple invocations")
// Reader is a token container reader. It exposes the tokens conveniently decoded.
type Reader map[cid.Cid]bundle
type bundle struct {
sealed []byte
token token.Token
}
// FromBytes decodes a container from a []byte
func FromBytes(data []byte) (Reader, error) {
return FromReader(bytes.NewReader(data))
}
// FromString decodes a container from a string
func FromString(s string) (Reader, error) {
return FromReader(strings.NewReader(s))
}
// FromReader decodes a container from an io.Reader.
func FromReader(r io.Reader) (Reader, error) {
payload, err := payloadDecoder(r)
if err != nil {
return nil, err
}
n, err := ipld.DecodeStreaming(payload, cbor.Decode)
if err != nil {
return nil, err
}
if n.Kind() != datamodel.Kind_Map {
return nil, fmt.Errorf("invalid container format: expected map")
}
if n.Length() != 1 {
return nil, fmt.Errorf("invalid container format: expected single version key")
}
// get the first (and only) key-value pair
it := n.MapIterator()
key, tokensNode, err := it.Next()
if err != nil {
return nil, err
}
version, err := key.AsString()
if err != nil {
return nil, fmt.Errorf("invalid container format: version must be string")
}
if version != containerVersionTag {
return nil, fmt.Errorf("unsupported container version: %s", version)
}
if tokensNode.Kind() != datamodel.Kind_List {
return nil, fmt.Errorf("invalid container format: tokens must be a list")
}
ctn := make(Reader, tokensNode.Length())
it2 := tokensNode.ListIterator()
for !it2.Done() {
_, val, err := it2.Next()
if err != nil {
return nil, err
}
data, err := val.AsBytes()
if err != nil {
return nil, err
}
err = ctn.addToken(data)
if err != nil {
return nil, err
}
}
return ctn, nil
}
// GetToken returns an arbitrary decoded token, from its CID.
// If not found, ErrNotFound is returned.
func (ctn Reader) GetToken(cid cid.Cid) (token.Token, error) {
bndl, ok := ctn[cid]
if !ok {
return nil, ErrNotFound
}
return bndl.token, nil
}
// GetSealed returns an arbitrary sealed token, from its CID.
// If not found, ErrNotFound is returned.
func (ctn Reader) GetSealed(cid cid.Cid) ([]byte, error) {
bndl, ok := ctn[cid]
if !ok {
return nil, ErrNotFound
}
return bndl.sealed, nil
}
// GetAllTokens return all the tokens in the container.
func (ctn Reader) GetAllTokens() iter.Seq[token.Bundle] {
return func(yield func(token.Bundle) bool) {
for c, bndl := range ctn {
if !yield(token.Bundle{
Cid: c,
Decoded: bndl.token,
Sealed: bndl.sealed,
}) {
return
}
}
}
}
// GetDelegation is the same as GetToken but only return a delegation.Token, with the right type.
// If not found, delegation.ErrDelegationNotFound is returned.
func (ctn Reader) GetDelegation(cid cid.Cid) (*delegation.Token, error) {
tkn, err := ctn.GetToken(cid)
if err != nil { // only ErrNotFound expected
return nil, delegation.ErrDelegationNotFound
}
if tkn, ok := tkn.(*delegation.Token); ok {
return tkn, nil
}
return nil, delegation.ErrDelegationNotFound
}
// GetDelegationBundle is the same as GetToken but only return a delegation.Bundle, with the right type.
// If not found, delegation.ErrDelegationNotFound is returned.
func (ctn Reader) GetDelegationBundle(cid cid.Cid) (*delegation.Bundle, error) {
bndl, ok := ctn[cid]
if !ok {
return nil, delegation.ErrDelegationNotFound
}
if tkn, ok := bndl.token.(*delegation.Token); ok {
return &delegation.Bundle{
Cid: cid,
Decoded: tkn,
Sealed: bndl.sealed,
}, nil
}
return nil, delegation.ErrDelegationNotFound
}
// GetAllDelegations returns all the delegation.Token in the container.
func (ctn Reader) GetAllDelegations() iter.Seq[*delegation.Bundle] {
return func(yield func(*delegation.Bundle) bool) {
for c, bndl := range ctn {
if t, ok := bndl.token.(*delegation.Token); ok {
if !yield(&delegation.Bundle{
Cid: c,
Decoded: t,
Sealed: bndl.sealed,
}) {
return
}
}
}
}
}
// GetInvocation returns a single invocation.Token.
// If none are found, ErrNotFound is returned.
// If more than one invocation exists, ErrMultipleInvocations is returned.
func (ctn Reader) GetInvocation() (*invocation.Token, error) {
var res *invocation.Token
for _, bndl := range ctn {
if inv, ok := bndl.token.(*invocation.Token); ok {
if res != nil {
return nil, ErrMultipleInvocations
}
res = inv
}
}
if res == nil {
return nil, ErrNotFound
}
return res, nil
}
// GetAllInvocations returns all the invocation.Token in the container.
func (ctn Reader) GetAllInvocations() iter.Seq[invocation.Bundle] {
return func(yield func(invocation.Bundle) bool) {
for c, bndl := range ctn {
if t, ok := bndl.token.(*invocation.Token); ok {
if !yield(invocation.Bundle{
Cid: c,
Decoded: t,
Sealed: bndl.sealed,
}) {
return
}
}
}
}
}
func (ctn Reader) addToken(data []byte) error {
tkn, c, err := token.FromSealed(data)
if err != nil {
return err
}
ctn[c] = bundle{
sealed: data,
token: tkn,
}
return nil
}
// ToWriter convert a container Reader into a Writer.
// Most likely, you only want to use this in tests for convenience.
func (ctn Reader) ToWriter() Writer {
writer := NewWriter()
for _, bndl := range ctn {
writer.AddSealed(bndl.sealed)
}
return writer
}

View File

@@ -0,0 +1,243 @@
package container
import (
"bytes"
"crypto/rand"
"fmt"
"io"
"strings"
"testing"
"time"
"github.com/MetaMask/go-did-it"
"github.com/MetaMask/go-did-it/controller/did-key"
"github.com/MetaMask/go-did-it/crypto/ed25519"
"github.com/ipfs/go-cid"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/pkg/policy"
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
"github.com/ucan-wg/go-ucan/token/delegation"
)
func TestContainerRoundTrip(t *testing.T) {
for _, tc := range []struct {
name string
expectedHeader header
writer any
}{
{"Bytes", headerRawBytes, Writer.ToBytes},
{"BytesWriter", headerRawBytes, Writer.ToBytesWriter},
{"BytesGzipped", headerRawBytesGzip, Writer.ToBytesGzipped},
{"BytesGzippedWriter", headerRawBytesGzip, Writer.ToBytesGzippedWriter},
{"Base64StdPadding", headerBase64StdPadding, Writer.ToBase64StdPadding},
{"Base64StdPaddingWriter", headerBase64StdPadding, Writer.ToBase64StdPaddingWriter},
{"Base64StdPaddingGzipped", headerBase64StdPaddingGzip, Writer.ToBase64StdPaddingGzipped},
{"Base64StdPaddingGzippedWriter", headerBase64StdPaddingGzip, Writer.ToBase64StdPaddingGzippedWriter},
{"Base64URL", headerBase64URL, Writer.ToBase64URL},
{"Base64URLWriter", headerBase64URL, Writer.ToBase64URLWriter},
{"Base64URLGzipped", headerBase64URLGzip, Writer.ToBase64URLGzipped},
{"Base64URLGzipWriter", headerBase64URLGzip, Writer.ToBase64URLGzipWriter},
} {
t.Run(tc.name, func(t *testing.T) {
tokens := make(map[cid.Cid]*delegation.Token)
var dataSize int
writer := NewWriter()
for i := 0; i < 10; i++ {
dlg, c, data := randToken()
writer.AddSealed(data)
tokens[c] = dlg
dataSize += len(data)
}
var reader Reader
var serialLen int
switch fn := tc.writer.(type) {
case func(ctn Writer, w io.Writer) error:
buf := bytes.NewBuffer(nil)
err := fn(writer, buf)
require.NoError(t, err)
serialLen = buf.Len()
h, err := buf.ReadByte()
require.NoError(t, err)
require.Equal(t, byte(tc.expectedHeader), h)
err = buf.UnreadByte()
require.NoError(t, err)
reader, err = FromReader(bytes.NewReader(buf.Bytes()))
require.NoError(t, err)
case func(ctn Writer) ([]byte, error):
b, err := fn(writer)
require.NoError(t, err)
serialLen = len(b)
require.Equal(t, byte(tc.expectedHeader), b[0])
reader, err = FromBytes(b)
require.NoError(t, err)
case func(ctn Writer) (string, error):
s, err := fn(writer)
require.NoError(t, err)
serialLen = len(s)
require.Equal(t, byte(tc.expectedHeader), s[0])
reader, err = FromString(s)
require.NoError(t, err)
}
t.Logf("data size %d, container size %d, overhead: %d%%, %d bytes",
dataSize, serialLen, int(float32(serialLen-dataSize)/float32(dataSize)*100.0), serialLen-dataSize)
for c, dlg := range tokens {
tknRead, err := reader.GetToken(c)
require.NoError(t, err)
// require.Equal fails as time.Time holds a wall time that is going to be
// different, even if it represents the same event.
// We need to do the following instead.
dlgRead := tknRead.(*delegation.Token)
require.Equal(t, dlg.Issuer(), dlgRead.Issuer())
require.Equal(t, dlg.Audience(), dlgRead.Audience())
require.Equal(t, dlg.Subject(), dlgRead.Subject())
require.Equal(t, dlg.Command(), dlgRead.Command())
require.Equal(t, dlg.Policy(), dlgRead.Policy())
require.Equal(t, dlg.Nonce(), dlgRead.Nonce())
require.True(t, dlg.Meta().Equals(dlgRead.Meta()))
if dlg.NotBefore() != nil {
// within 1s as the original value gets truncated to seconds when serialized
require.WithinDuration(t, *dlg.NotBefore(), *dlgRead.NotBefore(), time.Second)
}
if dlg.Expiration() != nil {
// within 1s as the original value gets truncated to seconds when serialized
require.WithinDuration(t, *dlg.Expiration(), *dlgRead.Expiration(), time.Second)
}
}
})
}
}
func BenchmarkContainerSerialisation(b *testing.B) {
var duration strings.Builder
var allocByte strings.Builder
var allocCount strings.Builder
for _, builder := range []strings.Builder{duration, allocByte, allocCount} {
builder.WriteString("car\tcarBase64\tcarGzip\tcarGzipBase64\tcbor\tcborBase64\tcborGzip\tcborGzipBase64\tcborFlate\tcborFlateBase64\n")
}
for _, tc := range []struct {
name string
writer func(ctn Writer, w io.Writer) error
reader func(io.Reader) (Reader, error)
}{
{"Bytes", Writer.ToBytesWriter, FromReader},
{"BytesGzipped", Writer.ToBytesGzippedWriter, FromReader},
{"Base64StdPadding", Writer.ToBase64StdPaddingWriter, FromReader},
{"Base64StdPaddingGzipped", Writer.ToBase64StdPaddingGzippedWriter, FromReader},
{"Base64URL", Writer.ToBase64URLWriter, FromReader},
{"Base64URLGzip", Writer.ToBase64URLGzipWriter, FromReader},
} {
writer := NewWriter()
for i := 0; i < 10; i++ {
_, _, data := randToken()
writer.AddSealed(data)
}
buf := bytes.NewBuffer(nil)
_ = tc.writer(writer, buf)
b.Run(tc.name+"_write", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
buf := bytes.NewBuffer(nil)
_ = tc.writer(writer, buf)
}
})
b.Run(tc.name+"_read", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, _ = tc.reader(bytes.NewReader(buf.Bytes()))
}
})
}
}
func randDID() (ed25519.PrivateKey, did.DID) {
_, privKey, err := ed25519.GenerateKeyPair()
if err != nil {
panic(err)
}
d := didkeyctl.FromPrivateKey(privKey)
return privKey, d
}
func randomString(length int) string {
b := make([]byte, length/2+1)
_, _ = rand.Read(b)
return fmt.Sprintf("%x", b)[0:length]
}
func randToken() (*delegation.Token, cid.Cid, []byte) {
priv, iss := randDID()
_, aud := randDID()
cmd := command.New("foo", "bar")
pol := policy.MustConstruct(
policy.All(".[]",
policy.GreaterThan(".value", literal.Int(2)),
),
)
opts := []delegation.Option{
delegation.WithExpiration(time.Now().Add(time.Hour)),
}
for i := 0; i < 3; i++ {
opts = append(opts, delegation.WithMeta(randomString(8), randomString(10)))
}
t, err := delegation.Root(iss, aud, cmd, pol, opts...)
if err != nil {
panic(err)
}
b, c, err := t.ToSealed(priv)
if err != nil {
panic(err)
}
return t, c, b
}
func FuzzContainerRead(f *testing.F) {
// Generate a corpus
for tokenCount := 0; tokenCount < 10; tokenCount++ {
writer := NewWriter()
for i := 0; i < tokenCount; i++ {
_, _, data := randToken()
writer.AddSealed(data)
}
data, err := writer.ToBytes()
require.NoError(f, err)
f.Add(data)
}
f.Fuzz(func(t *testing.T, data []byte) {
start := time.Now()
// search for panics
_, _ = FromBytes(data)
if time.Since(start) > 100*time.Millisecond {
panic("too long")
}
})
}

BIN
pkg/container/testdata/sample-v1.car vendored Normal file

Binary file not shown.

145
pkg/container/writer.go Normal file
View File

@@ -0,0 +1,145 @@
package container
import (
"bytes"
"io"
"slices"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec/cbor"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/fluent/qp"
"github.com/ipld/go-ipld-prime/node/basicnode"
)
// Writer is a token container writer. It provides a convenient way to aggregate and serialize tokens together.
type Writer map[string]struct{}
func NewWriter() Writer {
return make(Writer)
}
// AddSealed includes a "sealed" token (serialized with a ToSealed* function) in the container.
func (ctn Writer) AddSealed(data []byte) {
ctn[string(data)] = struct{}{}
}
// ToBytes encode the container into raw bytes.
func (ctn Writer) ToBytes() ([]byte, error) {
return ctn.toBytes(headerRawBytes)
}
// ToBytesWriter is the same as ToBytes, but with an io.Writer.
func (ctn Writer) ToBytesWriter(w io.Writer) error {
return ctn.toWriter(headerRawBytes, w)
}
// ToBytesGzipped encode the container into gzipped bytes.
func (ctn Writer) ToBytesGzipped() ([]byte, error) {
return ctn.toBytes(headerRawBytesGzip)
}
// ToBytesGzippedWriter is the same as ToBytesGzipped, but with an io.Writer.
func (ctn Writer) ToBytesGzippedWriter(w io.Writer) error {
return ctn.toWriter(headerRawBytesGzip, w)
}
// ToBase64StdPadding encode the container into a base64 string, with standard encoding and padding.
func (ctn Writer) ToBase64StdPadding() (string, error) {
return ctn.toString(headerBase64StdPadding)
}
// ToBase64StdPaddingWriter is the same as ToBase64StdPadding, but with an io.Writer.
func (ctn Writer) ToBase64StdPaddingWriter(w io.Writer) error {
return ctn.toWriter(headerBase64StdPadding, w)
}
// ToBase64StdPaddingGzipped encode the container into a pre-gzipped base64 string, with standard encoding and padding.
func (ctn Writer) ToBase64StdPaddingGzipped() (string, error) {
return ctn.toString(headerBase64StdPaddingGzip)
}
// ToBase64StdPaddingGzippedWriter is the same as ToBase64StdPaddingGzipped, but with an io.Writer.
func (ctn Writer) ToBase64StdPaddingGzippedWriter(w io.Writer) error {
return ctn.toWriter(headerBase64StdPaddingGzip, w)
}
// ToBase64URL encode the container into base64 string, with URL-safe encoding and no padding.
func (ctn Writer) ToBase64URL() (string, error) {
return ctn.toString(headerBase64URL)
}
// ToBase64URLWriter is the same as ToBase64URL, but with an io.Writer.
func (ctn Writer) ToBase64URLWriter(w io.Writer) error {
return ctn.toWriter(headerBase64URL, w)
}
// ToBase64URLGzipped encode the container into pre-gzipped base64 string, with URL-safe encoding and no padding.
func (ctn Writer) ToBase64URLGzipped() (string, error) {
return ctn.toString(headerBase64URLGzip)
}
// ToBase64URLGzipWriter is the same as ToBase64URL, but with an io.Writer.
func (ctn Writer) ToBase64URLGzipWriter(w io.Writer) error {
return ctn.toWriter(headerBase64URLGzip, w)
}
func (ctn Writer) toBytes(header header) ([]byte, error) {
var buf bytes.Buffer
err := ctn.toWriter(header, &buf)
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func (ctn Writer) toString(header header) (string, error) {
var buf bytes.Buffer
err := ctn.toWriter(header, &buf)
if err != nil {
return "", err
}
return buf.String(), nil
}
func (ctn Writer) toWriter(header header, w io.Writer) (err error) {
encoder := header.encoder(w)
defer func() {
err = encoder.Close()
}()
node, err := qp.BuildMap(basicnode.Prototype.Any, 1, func(ma datamodel.MapAssembler) {
qp.MapEntry(ma, containerVersionTag, qp.List(int64(len(ctn)), func(la datamodel.ListAssembler) {
tokens := make([][]byte, 0, len(ctn))
for data := range ctn {
tokens = append(tokens, []byte(data))
}
slices.SortFunc(tokens, bytes.Compare)
for _, data := range tokens {
qp.ListEntry(la, qp.Bytes(data))
}
}))
})
if err != nil {
return err
}
return ipld.EncodeStreaming(encoder, node, cbor.Encode)
}
// ToReader convert a container Writer into a Reader.
// Most likely, you only want to use this in tests for convenience.
// This is not optimized and can panic.
func (ctn Writer) ToReader() Reader {
data, err := ctn.ToBytes()
if err != nil {
panic(err)
}
reader, err := FromBytes(data)
if err != nil {
panic(err)
}
return reader
}

View File

@@ -0,0 +1,18 @@
package container
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestWriterDedup(t *testing.T) {
ctn := NewWriter()
_, _, sealed := randToken()
ctn.AddSealed(sealed)
require.Len(t, ctn, 1)
ctn.AddSealed(sealed)
require.Len(t, ctn, 1)
}

265
pkg/meta/meta.go Normal file
View File

@@ -0,0 +1,265 @@
package meta
import (
"errors"
"fmt"
"iter"
"sort"
"strings"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/printer"
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
"github.com/ucan-wg/go-ucan/pkg/secretbox"
)
var ErrNotFound = errors.New("key not found in meta")
var ErrNotEncryptable = errors.New("value of this type cannot be encrypted")
// Meta is a container for meta key-value pairs in a UCAN token.
// This also serves as a way to construct the underlying IPLD data with minimum allocations
// and transformations, while hiding the IPLD complexity from the caller.
type Meta struct {
// This type must be compatible with the IPLD type represented by the IPLD
// schema { String : Any }.
Keys []string
Values map[string]ipld.Node
}
// NewMeta constructs a new Meta.
func NewMeta() *Meta {
return &Meta{Values: map[string]ipld.Node{}}
}
// GetBool retrieves a value as a bool.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
func (m *Meta) GetBool(key string) (bool, error) {
v, ok := m.Values[key]
if !ok {
return false, ErrNotFound
}
return v.AsBool()
}
// GetString retrieves a value as a string.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
func (m *Meta) GetString(key string) (string, error) {
v, ok := m.Values[key]
if !ok {
return "", ErrNotFound
}
return v.AsString()
}
// GetEncryptedString decorates GetString and decrypt its output with the given symmetric encryption key.
func (m *Meta) GetEncryptedString(key string, encryptionKey []byte) (string, error) {
v, err := m.GetBytes(key)
if err != nil {
return "", err
}
decrypted, err := secretbox.DecryptStringWithKey(v, encryptionKey)
if err != nil {
return "", err
}
return string(decrypted), nil
}
// GetInt64 retrieves a value as an int64.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
func (m *Meta) GetInt64(key string) (int64, error) {
v, ok := m.Values[key]
if !ok {
return 0, ErrNotFound
}
return v.AsInt()
}
// GetFloat64 retrieves a value as a float64.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
func (m *Meta) GetFloat64(key string) (float64, error) {
v, ok := m.Values[key]
if !ok {
return 0, ErrNotFound
}
return v.AsFloat()
}
// GetBytes retrieves a value as a []byte.
// Returns ErrNotFound if the given key is missing.
// Returns datamodel.ErrWrongKind if the value has the wrong type.
func (m *Meta) GetBytes(key string) ([]byte, error) {
v, ok := m.Values[key]
if !ok {
return nil, ErrNotFound
}
return v.AsBytes()
}
// GetEncryptedBytes decorates GetBytes and decrypt its output with the given symmetric encryption key.
func (m *Meta) GetEncryptedBytes(key string, encryptionKey []byte) ([]byte, error) {
v, err := m.GetBytes(key)
if err != nil {
return nil, err
}
decrypted, err := secretbox.DecryptStringWithKey(v, encryptionKey)
if err != nil {
return nil, err
}
return decrypted, nil
}
// GetNode retrieves a value as a raw IPLD node.
// Returns ErrNotFound if the given key is missing.
func (m *Meta) GetNode(key string) (ipld.Node, error) {
v, ok := m.Values[key]
if !ok {
return nil, ErrNotFound
}
return v, nil
}
// Add adds a key/value pair in the meta set.
// Accepted types for val are any CBOR compatible type, or directly IPLD values.
func (m *Meta) Add(key string, val any) error {
if _, ok := m.Values[key]; ok {
return fmt.Errorf("duplicate key %q", key)
}
node, err := literal.Any(val)
if err != nil {
return err
}
m.Keys = append(m.Keys, key)
m.Values[key] = node
return nil
}
// AddEncrypted adds a key/value pair in the meta set.
// The value is encrypted with the given encryptionKey.
// Accepted types for the value are: string, []byte.
// The ciphertext will be 40 bytes larger than the plaintext due to encryption overhead.
func (m *Meta) AddEncrypted(key string, val any, encryptionKey []byte) error {
var encrypted []byte
var err error
switch val := val.(type) {
case string:
encrypted, err = secretbox.EncryptWithKey([]byte(val), encryptionKey)
if err != nil {
return err
}
case []byte:
encrypted, err = secretbox.EncryptWithKey(val, encryptionKey)
if err != nil {
return err
}
default:
return ErrNotEncryptable
}
return m.Add(key, encrypted)
}
type Iterator interface {
Iter() iter.Seq2[string, ipld.Node]
}
// Include merges the provided meta into the existing one.
//
// If duplicate keys are encountered, the new value is silently dropped
// without causing an error.
func (m *Meta) Include(other Iterator) {
for key, value := range other.Iter() {
if _, ok := m.Values[key]; ok {
// don't overwrite
continue
}
m.Values[key] = value
m.Keys = append(m.Keys, key)
}
}
// Len returns the number of key/values.
func (m *Meta) Len() int {
return len(m.Values)
}
// Iter iterates over the meta key/values
func (m *Meta) Iter() iter.Seq2[string, ipld.Node] {
return func(yield func(string, ipld.Node) bool) {
for _, key := range m.Keys {
if !yield(key, m.Values[key]) {
return
}
}
}
}
// Equals tells if two Meta hold the same key/values.
func (m *Meta) Equals(other *Meta) bool {
if len(m.Keys) != len(other.Keys) {
return false
}
if len(m.Values) != len(other.Values) {
return false
}
for _, key := range m.Keys {
if !ipld.DeepEqual(m.Values[key], other.Values[key]) {
return false
}
}
return true
}
func (m *Meta) String() string {
sort.Strings(m.Keys)
buf := strings.Builder{}
buf.WriteString("{")
for key, node := range m.Values {
buf.WriteString("\n\t")
buf.WriteString(key)
buf.WriteString(": ")
buf.WriteString(strings.ReplaceAll(printer.Sprint(node), "\n", "\n\t"))
buf.WriteString(",")
}
if len(m.Values) > 0 {
buf.WriteString("\n")
}
buf.WriteString("}")
return buf.String()
}
// ReadOnly returns a read-only version of Meta.
func (m *Meta) ReadOnly() ReadOnly {
return ReadOnly{meta: m}
}
// Clone makes a deep copy.
func (m *Meta) Clone() *Meta {
res := &Meta{
Keys: make([]string, len(m.Keys)),
Values: make(map[string]ipld.Node, len(m.Values)),
}
copy(res.Keys, m.Keys)
for k, v := range m.Values {
res.Values[k] = v
}
return res
}

130
pkg/meta/meta_test.go Normal file
View File

@@ -0,0 +1,130 @@
package meta_test
import (
"crypto/rand"
"maps"
"testing"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/node/basicnode"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/pkg/meta"
)
func TestMeta_Add(t *testing.T) {
t.Parallel()
type Unsupported struct{}
t.Run("error if not primitive or Node", func(t *testing.T) {
t.Parallel()
err := (&meta.Meta{}).Add("invalid", &Unsupported{})
require.Error(t, err)
})
t.Run("encrypted meta", func(t *testing.T) {
t.Parallel()
key := make([]byte, 32)
_, err := rand.Read(key)
require.NoError(t, err)
m := meta.NewMeta()
// string encryption
err = m.AddEncrypted("secret", "hello world", key)
require.NoError(t, err)
_, err = m.GetString("secret")
require.Error(t, err) // the ciphertext is saved as []byte instead of string
decrypted, err := m.GetEncryptedString("secret", key)
require.NoError(t, err)
require.Equal(t, "hello world", decrypted)
// bytes encryption
originalBytes := make([]byte, 128)
_, err = rand.Read(originalBytes)
require.NoError(t, err)
err = m.AddEncrypted("secret-bytes", originalBytes, key)
require.NoError(t, err)
encryptedBytes, err := m.GetBytes("secret-bytes")
require.NoError(t, err)
require.NotEqual(t, originalBytes, encryptedBytes)
decryptedBytes, err := m.GetEncryptedBytes("secret-bytes", key)
require.NoError(t, err)
require.Equal(t, originalBytes, decryptedBytes)
// error cases
t.Run("error on unsupported type", func(t *testing.T) {
err := m.AddEncrypted("invalid", 123, key)
require.ErrorIs(t, err, meta.ErrNotEncryptable)
})
t.Run("error on invalid key size", func(t *testing.T) {
err := m.AddEncrypted("invalid", "test", []byte("short-key"))
require.Error(t, err)
require.Contains(t, err.Error(), "invalid key size")
})
t.Run("error on nil key", func(t *testing.T) {
err := m.AddEncrypted("invalid", "test", nil)
require.Error(t, err)
require.Contains(t, err.Error(), "encryption key is required")
})
})
}
func TestIterCloneEquals(t *testing.T) {
m := meta.NewMeta()
require.NoError(t, m.Add("foo", "bar"))
require.NoError(t, m.Add("baz", 1234))
expected := map[string]ipld.Node{
"foo": basicnode.NewString("bar"),
"baz": basicnode.NewInt(1234),
}
// meta -> iter
require.Equal(t, expected, maps.Collect(m.Iter()))
// readonly -> iter
ro := m.ReadOnly()
require.Equal(t, expected, maps.Collect(ro.Iter()))
// meta -> clone -> iter
clone := m.Clone()
require.Equal(t, expected, maps.Collect(clone.Iter()))
// readonly -> WriteableClone -> iter
wclone := ro.WriteableClone()
require.Equal(t, expected, maps.Collect(wclone.Iter()))
require.True(t, m.Equals(wclone))
require.True(t, ro.Equals(wclone.ReadOnly()))
}
func TestInclude(t *testing.T) {
m1 := meta.NewMeta()
require.NoError(t, m1.Add("samekey", "bar"))
require.NoError(t, m1.Add("baz", 1234))
m2 := meta.NewMeta()
require.NoError(t, m2.Add("samekey", "othervalue")) // check no overwrite
require.NoError(t, m2.Add("otherkey", 1234))
m1.Include(m2)
require.Equal(t, map[string]ipld.Node{
"samekey": basicnode.NewString("bar"),
"baz": basicnode.NewInt(1234),
"otherkey": basicnode.NewInt(1234),
}, maps.Collect(m1.Iter()))
}

64
pkg/meta/readonly.go Normal file
View File

@@ -0,0 +1,64 @@
package meta
import (
"iter"
"github.com/ipld/go-ipld-prime"
)
// ReadOnly wraps a Meta into a read-only facade.
type ReadOnly struct {
meta *Meta
}
func (r ReadOnly) GetBool(key string) (bool, error) {
return r.meta.GetBool(key)
}
func (r ReadOnly) GetString(key string) (string, error) {
return r.meta.GetString(key)
}
func (r ReadOnly) GetEncryptedString(key string, encryptionKey []byte) (string, error) {
return r.meta.GetEncryptedString(key, encryptionKey)
}
func (r ReadOnly) GetInt64(key string) (int64, error) {
return r.meta.GetInt64(key)
}
func (r ReadOnly) GetFloat64(key string) (float64, error) {
return r.meta.GetFloat64(key)
}
func (r ReadOnly) GetBytes(key string) ([]byte, error) {
return r.meta.GetBytes(key)
}
func (r ReadOnly) GetEncryptedBytes(key string, encryptionKey []byte) ([]byte, error) {
return r.meta.GetEncryptedBytes(key, encryptionKey)
}
func (r ReadOnly) GetNode(key string) (ipld.Node, error) {
return r.meta.GetNode(key)
}
func (r ReadOnly) Len() int {
return r.meta.Len()
}
func (r ReadOnly) Iter() iter.Seq2[string, ipld.Node] {
return r.meta.Iter()
}
func (r ReadOnly) Equals(other ReadOnly) bool {
return r.meta.Equals(other.meta)
}
func (r ReadOnly) String() string {
return r.meta.String()
}
func (r ReadOnly) WriteableClone() *Meta {
return r.meta.Clone()
}

79
pkg/policy/glob.go Normal file
View File

@@ -0,0 +1,79 @@
package policy
import "fmt"
type glob string
// parseGlob ensures that the pattern conforms to the spec: only '*' and escaped '\*' are allowed.
func parseGlob(pattern string) (glob, error) {
for i := 0; i < len(pattern); i++ {
if pattern[i] == '*' {
continue
}
if pattern[i] == '\\' && i+1 < len(pattern) && pattern[i+1] == '*' {
i++ // skip the escaped '*'
continue
}
if pattern[i] == '\\' && i+1 < len(pattern) {
i++ // skip the escaped character
continue
}
if pattern[i] == '\\' {
return "", fmt.Errorf("invalid escape sequence")
}
}
return glob(pattern), nil
}
func mustParseGlob(pattern string) glob {
g, err := parseGlob(pattern)
if err != nil {
panic(err)
}
return g
}
// Match matches a string against the glob pattern with * wildcards, handling escaped '\*' literals.
func (pattern glob) Match(str string) bool {
// i is the index for the pattern
// j is the index for the string
var i, j int
// starIdx keeps track of the position of the last * in the pattern.
// matchIdx keeps track of the position in the string where the last * matched.
var starIdx, matchIdx int = -1, -1
for j < len(str) {
if i < len(pattern) && (pattern[i] == str[j] || pattern[i] == '\\' && i+1 < len(pattern) && pattern[i+1] == str[j]) {
// characters match or if there's an escaped character that matches
if pattern[i] == '\\' {
// skip the escape character
i++
}
i++
j++
} else if i < len(pattern) && pattern[i] == '*' {
// there's a * wildcard in the pattern
starIdx = i
matchIdx = j
i++
} else if starIdx != -1 {
// there's a previous * wildcard, backtrack
i = starIdx + 1
matchIdx++
j = matchIdx
} else {
// no match found
return false
}
}
// check for remaining characters in the pattern
for i < len(pattern) && pattern[i] == '*' {
i++
}
// the entire pattern is processed, it's a match
return i == len(pattern)
}

73
pkg/policy/glob_test.go Normal file
View File

@@ -0,0 +1,73 @@
package policy
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestSimpleGlobMatch(t *testing.T) {
tests := []struct {
pattern string
str string
matches bool
}{
// Basic matching
{"*", "anything", true},
{"a*", "abc", true},
{"*c", "abc", true},
{"a*c", "abc", true},
{"a*c", "abxc", true},
{"a*c", "ac", true},
{"a*c", "a", false},
{"a*c", "ab", false},
// Escaped characters
{"a\\*c", "a*c", true},
{"a\\*c", "abc", false},
// Mixed wildcards and literals
{"a*b*c", "abc", true},
{"a*b*c", "aXbYc", true},
{"a*b*c", "aXbY", false},
{"a*b*c", "abYc", true},
{"a*b*c", "aXbc", true},
{"a*b*c", "aXbYcZ", false},
// Edge cases
{"", "", true},
{"", "a", false},
{"*", "", true},
{"*", "a", true},
{"\\*", "*", true},
{"\\*", "a", false},
// Specified test cases
{"Alice\\*, Bob*, Carol.", "Alice*, Bob, Carol.", true},
{"Alice\\*, Bob*, Carol.", "Alice*, Bob, Dan, Erin, Carol.", true},
{"Alice\\*, Bob*, Carol.", "Alice*, Bob , Carol.", true},
{"Alice\\*, Bob*, Carol.", "Alice*, Bob*, Carol.", true},
{"Alice\\*, Bob*, Carol.", "Alice*, Bob, Carol", false},
{"Alice\\*, Bob*, Carol.", "Alice*, Bob*, Carol!", false},
{"Alice\\*, Bob*, Carol.", "Alice, Bob, Carol.", false},
{"Alice\\*, Bob*, Carol.", "Alice Cooper, Bob, Carol.", false},
{"Alice\\*, Bob*, Carol.", " Alice*, Bob, Carol. ", false},
}
for _, tt := range tests {
t.Run(tt.pattern+"_"+tt.str, func(t *testing.T) {
g, err := parseGlob(tt.pattern)
require.NoError(t, err)
require.Equal(t, tt.matches, g.Match(tt.str))
})
}
}
func BenchmarkGlob(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
g := mustParseGlob("Alice\\*, Bob*, Carol.")
g.Match("Alice*, Bob*, Carol!")
}
}

View File

@@ -9,10 +9,15 @@ import (
"github.com/ipld/go-ipld-prime/must" "github.com/ipld/go-ipld-prime/must"
"github.com/ipld/go-ipld-prime/node/basicnode" "github.com/ipld/go-ipld-prime/node/basicnode"
"github.com/ucan-wg/go-ucan/v1/capability/policy/selector" "github.com/ucan-wg/go-ucan/pkg/policy/limits"
"github.com/ucan-wg/go-ucan/pkg/policy/selector"
) )
func FromIPLD(node datamodel.Node) (Policy, error) { func FromIPLD(node datamodel.Node) (Policy, error) {
if err := limits.ValidateIntegerBoundsIPLD(node); err != nil {
return nil, fmt.Errorf("policy contains integer values outside safe bounds: %w", err)
}
return statementsFromIPLD("/", node) return statementsFromIPLD("/", node)
} }
@@ -40,14 +45,14 @@ func statementFromIPLD(path string, node datamodel.Node) (Statement, error) {
} }
op := must.String(opNode) op := must.String(opNode)
arg2AsSelector := func() (selector.Selector, error) { arg2AsSelector := func(op string) (selector.Selector, error) {
nd, _ := node.LookupByIndex(1) nd, _ := node.LookupByIndex(1)
if nd.Kind() != datamodel.Kind_String { if nd.Kind() != datamodel.Kind_String {
return nil, ErrNotAString(path + "1/") return nil, ErrNotAString(combinePath(path, op, 1))
} }
sel, err := selector.Parse(must.String(nd)) sel, err := selector.Parse(must.String(nd))
if err != nil { if err != nil {
return nil, ErrInvalidSelector(path+"1/", err) return nil, ErrInvalidSelector(combinePath(path, op, 1), err)
} }
return sel, nil return sel, nil
} }
@@ -57,15 +62,15 @@ func statementFromIPLD(path string, node datamodel.Node) (Statement, error) {
switch op { switch op {
case KindNot: case KindNot:
arg2, _ := node.LookupByIndex(1) arg2, _ := node.LookupByIndex(1)
statement, err := statementFromIPLD(path+"1/", arg2) statement, err := statementFromIPLD(combinePath(path, op, 1), arg2)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return Not(statement), nil return negation{statement: statement}, nil
case KindAnd, KindOr: case KindAnd, KindOr:
arg2, _ := node.LookupByIndex(1) arg2, _ := node.LookupByIndex(1)
statement, err := statementsFromIPLD(path+"1/", arg2) statement, err := statementsFromIPLD(combinePath(path, op, 1), arg2)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -76,8 +81,8 @@ func statementFromIPLD(path string, node datamodel.Node) (Statement, error) {
} }
case 3: case 3:
switch op { switch op {
case KindEqual, KindLessThan, KindLessThanOrEqual, KindGreaterThan, KindGreaterThanOrEqual: case KindEqual, KindNotEqual, KindLessThan, KindLessThanOrEqual, KindGreaterThan, KindGreaterThanOrEqual:
sel, err := arg2AsSelector() sel, err := arg2AsSelector(op)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -85,28 +90,31 @@ func statementFromIPLD(path string, node datamodel.Node) (Statement, error) {
return equality{kind: op, selector: sel, value: arg3}, nil return equality{kind: op, selector: sel, value: arg3}, nil
case KindLike: case KindLike:
sel, err := arg2AsSelector() sel, err := arg2AsSelector(op)
if err != nil { if err != nil {
return nil, err return nil, err
} }
pattern, _ := node.LookupByIndex(2) pattern, _ := node.LookupByIndex(2)
if pattern.Kind() != datamodel.Kind_String { if pattern.Kind() != datamodel.Kind_String {
return nil, ErrNotAString(path + "2/") return nil, ErrNotAString(combinePath(path, op, 2))
} }
res, err := Like(sel, must.String(pattern)) g, err := parseGlob(must.String(pattern))
if err != nil { if err != nil {
return nil, ErrInvalidPattern(path+"2/", err) return nil, ErrInvalidPattern(combinePath(path, op, 2), err)
} }
return res, nil return wildcard{selector: sel, pattern: g}, nil
case KindAll, KindAny: case KindAll, KindAny:
sel, err := arg2AsSelector() sel, err := arg2AsSelector(op)
if err != nil { if err != nil {
return nil, err return nil, err
} }
statementsNodes, _ := node.LookupByIndex(2) statementsNode, _ := node.LookupByIndex(2)
statements, err := statementsFromIPLD(path+"1/", statementsNodes) statement, err := statementFromIPLD(combinePath(path, op, 1), statementsNode)
return quantifier{kind: op, selector: sel, statements: statements}, nil if err != nil {
return nil, err
}
return quantifier{kind: op, selector: sel, statement: statement}, nil
default: default:
return nil, ErrUnrecognizedOperator(path, op) return nil, ErrUnrecognizedOperator(path, op)
@@ -123,7 +131,7 @@ func statementsFromIPLD(path string, node datamodel.Node) ([]Statement, error) {
return nil, ErrNotATuple(path) return nil, ErrNotATuple(path)
} }
if node.Length() == 0 { if node.Length() == 0 {
return nil, ErrEmptyList(path) return nil, nil
} }
res := make([]Statement, node.Length()) res := make([]Statement, node.Length())
@@ -229,7 +237,7 @@ func statementToIPLD(statement Statement) (datamodel.Node, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = listBuilder.AssembleValue().AssignString(statement.pattern) err = listBuilder.AssembleValue().AssignString(string(statement.pattern))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -243,7 +251,7 @@ func statementToIPLD(statement Statement) (datamodel.Node, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
args, err := statementsToIPLD(statement.statements) args, err := statementToIPLD(statement.statement)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -260,3 +268,7 @@ func statementToIPLD(statement Statement) (datamodel.Node, error) {
return list.Build(), nil return list.Build(), nil
} }
func combinePath(prev string, operator string, index int) string {
return fmt.Sprintf("%s%d-%s/", prev, index, operator)
}

View File

@@ -35,10 +35,6 @@ func ErrNotATuple(path string) error {
return errWithPath{path: path, msg: "not a tuple"} return errWithPath{path: path, msg: "not a tuple"}
} }
func ErrEmptyList(path string) error {
return errWithPath{path: path, msg: "empty list"}
}
func safeStr(str string) string { func safeStr(str string) string {
if len(str) > 10 { if len(str) > 10 {
return str[:10] return str[:10]

View File

@@ -12,21 +12,40 @@ func TestIpldRoundTrip(t *testing.T) {
const illustrativeExample = ` const illustrativeExample = `
[ [
["==", ".status", "draft"], ["==", ".status", "draft"],
["all", ".reviewer", [ ["all", ".reviewer", ["like", ".email", "*@example.com"]],
["like", ".email", "*@example.com"]] ["any", ".tags",
],
["any", ".tags", [
["or", [ ["or", [
["==", ".", "news"], ["==", ".", "news"],
["==", ".", "press"]] ["==", ".", "press"]
]] ]]
] ]
]` ]`
// must contain all the operators
const allOps = `
[
["and", [
["==", ".foo1", ".bar1"],
["!=", ".foo2", ".bar2"]
]],
["or", [
[">", ".foo5", 5.2],
[">=", ".foo6", 6.2]
]],
["not", ["like", ".foo7", "*@example.com"]],
["all", ".foo8",
["<", ".foo3", 3]
],
["any", ".foo9",
["<=", ".foo4", 4]
]
]`
for _, tc := range []struct { for _, tc := range []struct {
name, dagJsonStr string name, dagJsonStr string
}{ }{
{"illustrativeExample", illustrativeExample}, {"illustrativeExample", illustrativeExample},
{"allOps", allOps},
} { } {
nodes, err := ipld.Decode([]byte(tc.dagJsonStr), dagjson.Decode) nodes, err := ipld.Decode([]byte(tc.dagJsonStr), dagjson.Decode)
require.NoError(t, err) require.NoError(t, err)

49
pkg/policy/limits/int.go Normal file
View File

@@ -0,0 +1,49 @@
package limits
import (
"fmt"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/must"
)
const (
// MaxInt53 represents the maximum safe integer in JavaScript (2^53 - 1)
MaxInt53 int64 = 9007199254740991
// MinInt53 represents the minimum safe integer in JavaScript (-2^53 + 1)
MinInt53 int64 = -9007199254740991
)
func ValidateIntegerBoundsIPLD(node ipld.Node) error {
switch node.Kind() {
case ipld.Kind_Int:
val := must.Int(node)
if val > MaxInt53 || val < MinInt53 {
return fmt.Errorf("integer value %d exceeds safe bounds", val)
}
case ipld.Kind_List:
it := node.ListIterator()
for !it.Done() {
_, v, err := it.Next()
if err != nil {
return err
}
if err := ValidateIntegerBoundsIPLD(v); err != nil {
return err
}
}
case ipld.Kind_Map:
it := node.MapIterator()
for !it.Done() {
_, v, err := it.Next()
if err != nil {
return err
}
if err := ValidateIntegerBoundsIPLD(v); err != nil {
return err
}
}
}
return nil
}

View File

@@ -0,0 +1,82 @@
package limits
import (
"testing"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/fluent/qp"
"github.com/ipld/go-ipld-prime/node/basicnode"
"github.com/stretchr/testify/require"
)
func TestValidateIntegerBoundsIPLD(t *testing.T) {
buildMap := func() datamodel.Node {
nb := basicnode.Prototype.Any.NewBuilder()
qp.Map(1, func(ma datamodel.MapAssembler) {
qp.MapEntry(ma, "foo", qp.Int(MaxInt53+1))
})(nb)
return nb.Build()
}
buildList := func() datamodel.Node {
nb := basicnode.Prototype.Any.NewBuilder()
qp.List(1, func(la datamodel.ListAssembler) {
qp.ListEntry(la, qp.Int(MinInt53-1))
})(nb)
return nb.Build()
}
tests := []struct {
name string
input datamodel.Node
wantErr bool
}{
{
name: "valid int",
input: basicnode.NewInt(42),
wantErr: false,
},
{
name: "max safe int",
input: basicnode.NewInt(MaxInt53),
wantErr: false,
},
{
name: "min safe int",
input: basicnode.NewInt(MinInt53),
wantErr: false,
},
{
name: "above MaxInt53",
input: basicnode.NewInt(MaxInt53 + 1),
wantErr: true,
},
{
name: "below MinInt53",
input: basicnode.NewInt(MinInt53 - 1),
wantErr: true,
},
{
name: "nested map with invalid int",
input: buildMap(),
wantErr: true,
},
{
name: "nested list with invalid int",
input: buildList(),
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateIntegerBoundsIPLD(tt.input)
if tt.wantErr {
require.Error(t, err)
require.Contains(t, err.Error(), "exceeds safe bounds")
} else {
require.NoError(t, err)
}
})
}
}

View File

@@ -0,0 +1,205 @@
// Package literal holds a collection of functions to create IPLD types to use in policies, selector and args.
package literal
import (
"fmt"
"reflect"
"sort"
"github.com/ipfs/go-cid"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/fluent/qp"
cidlink "github.com/ipld/go-ipld-prime/linking/cid"
"github.com/ipld/go-ipld-prime/node/basicnode"
"github.com/ucan-wg/go-ucan/pkg/policy/limits"
)
var Bool = basicnode.NewBool
var Int = basicnode.NewInt
var Float = basicnode.NewFloat
var String = basicnode.NewString
var Bytes = basicnode.NewBytes
var Link = basicnode.NewLink
func LinkCid(cid cid.Cid) ipld.Node {
return Link(cidlink.Link{Cid: cid})
}
func Null() ipld.Node {
nb := basicnode.Prototype.Any.NewBuilder()
nb.AssignNull()
return nb.Build()
}
// Map creates an IPLD node from a map[string]any
func Map[T any](m map[string]T) (ipld.Node, error) {
return qp.BuildMap(basicnode.Prototype.Any, int64(len(m)), func(ma datamodel.MapAssembler) {
// deterministic iteration
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
qp.MapEntry(ma, key, anyAssemble(m[key]))
}
})
}
// List creates an IPLD node from a []any
func List[T any](l []T) (ipld.Node, error) {
return qp.BuildList(basicnode.Prototype.Any, int64(len(l)), func(la datamodel.ListAssembler) {
for _, val := range l {
qp.ListEntry(la, anyAssemble(val))
}
})
}
// Any creates an IPLD node from any value
// If possible, use another dedicated function for your type for performance.
func Any(v any) (res ipld.Node, err error) {
// some fast path
switch val := v.(type) {
case bool:
return basicnode.NewBool(val), nil
case string:
return basicnode.NewString(val), nil
case int:
i := int64(val)
if i > limits.MaxInt53 || i < limits.MinInt53 {
return nil, fmt.Errorf("integer value %d exceeds safe integer bounds", i)
}
return basicnode.NewInt(i), nil
case int8:
return basicnode.NewInt(int64(val)), nil
case int16:
return basicnode.NewInt(int64(val)), nil
case int32:
return basicnode.NewInt(int64(val)), nil
case int64:
if val > limits.MaxInt53 || val < limits.MinInt53 {
return nil, fmt.Errorf("integer value %d exceeds safe integer bounds", val)
}
return basicnode.NewInt(val), nil
case uint:
return basicnode.NewInt(int64(val)), nil
case uint8:
return basicnode.NewInt(int64(val)), nil
case uint16:
return basicnode.NewInt(int64(val)), nil
case uint32:
return basicnode.NewInt(int64(val)), nil
case uint64:
if val > uint64(limits.MaxInt53) {
return nil, fmt.Errorf("unsigned integer value %d exceeds safe integer bounds", val)
}
return basicnode.NewInt(int64(val)), nil
case float32:
return basicnode.NewFloat(float64(val)), nil
case float64:
return basicnode.NewFloat(val), nil
case []byte:
return basicnode.NewBytes(val), nil
case datamodel.Node:
return val, nil
case cid.Cid:
return LinkCid(val), nil
default:
}
builder := basicnode.Prototype__Any{}.NewBuilder()
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("%v", r)
res = nil
}
}()
anyAssemble(v)(builder)
return builder.Build(), nil
}
func anyAssemble(val any) qp.Assemble {
var rt reflect.Type
var rv reflect.Value
// support for recursive calls, staying in reflection land
if cast, ok := val.(reflect.Value); ok {
rt = cast.Type()
rv = cast
} else {
rt = reflect.TypeOf(val)
rv = reflect.ValueOf(val)
}
// we need to dereference in some cases, to get the real value type
if rt.Kind() == reflect.Ptr || rt.Kind() == reflect.Interface {
rv = rv.Elem()
rt = rv.Type()
}
switch rt.Kind() {
case reflect.Array:
if rt.Elem().Kind() == reflect.Uint8 {
panic("bytes array are not supported yet")
}
return qp.List(int64(rv.Len()), func(la datamodel.ListAssembler) {
for i := range rv.Len() {
qp.ListEntry(la, anyAssemble(rv.Index(i)))
}
})
case reflect.Slice:
if rt.Elem().Kind() == reflect.Uint8 {
return qp.Bytes(val.([]byte))
}
return qp.List(int64(rv.Len()), func(la datamodel.ListAssembler) {
for i := range rv.Len() {
qp.ListEntry(la, anyAssemble(rv.Index(i)))
}
})
case reflect.Map:
if rt.Key().Kind() != reflect.String {
break
}
// deterministic iteration
keys := rv.MapKeys()
sort.Slice(keys, func(i, j int) bool {
return keys[i].String() < keys[j].String()
})
return qp.Map(int64(rv.Len()), func(ma datamodel.MapAssembler) {
for _, key := range keys {
qp.MapEntry(ma, key.String(), anyAssemble(rv.MapIndex(key)))
}
})
case reflect.Bool:
return qp.Bool(rv.Bool())
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
i := rv.Int()
if i > limits.MaxInt53 || i < limits.MinInt53 {
panic(fmt.Sprintf("integer %d exceeds safe bounds", i))
}
return qp.Int(i)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
u := rv.Uint()
if u > uint64(limits.MaxInt53) {
panic(fmt.Sprintf("unsigned integer %d exceeds safe bounds", u))
}
return qp.Int(int64(u))
case reflect.Float32, reflect.Float64:
return qp.Float(rv.Float())
case reflect.String:
return qp.String(rv.String())
case reflect.Struct:
if rt == reflect.TypeOf(cid.Cid{}) {
c := rv.Interface().(cid.Cid)
return qp.Link(cidlink.Link{Cid: c})
}
default:
}
panic(fmt.Sprintf("unsupported type %T", val))
}

View File

@@ -0,0 +1,314 @@
package literal
import (
"testing"
"github.com/ipfs/go-cid"
"github.com/ipld/go-ipld-prime/datamodel"
cidlink "github.com/ipld/go-ipld-prime/linking/cid"
"github.com/ipld/go-ipld-prime/printer"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/pkg/policy/limits"
)
func TestList(t *testing.T) {
n, err := List([]int{1, 2, 3})
require.NoError(t, err)
require.Equal(t, datamodel.Kind_List, n.Kind())
require.Equal(t, int64(3), n.Length())
require.Equal(t, `list{
0: int{1}
1: int{2}
2: int{3}
}`, printer.Sprint(n))
n, err = List([][]int{{1, 2, 3}, {4, 5, 6}})
require.NoError(t, err)
require.Equal(t, datamodel.Kind_List, n.Kind())
require.Equal(t, int64(2), n.Length())
require.Equal(t, `list{
0: list{
0: int{1}
1: int{2}
2: int{3}
}
1: list{
0: int{4}
1: int{5}
2: int{6}
}
}`, printer.Sprint(n))
}
func TestMap(t *testing.T) {
n, err := Map(map[string]any{
"bool": true,
"string": "foobar",
"bytes": []byte{1, 2, 3, 4},
"int": 1234,
"uint": uint(12345),
"float": 1.45,
"slice": []int{1, 2, 3},
"array": [2]int{1, 2},
"map": map[string]any{
"foo": "bar",
"foofoo": map[string]string{
"barbar": "foo",
},
},
"link": cid.MustParse("bafzbeigai3eoy2ccc7ybwjfz5r3rdxqrinwi4rwytly24tdbh6yk7zslrm"),
})
require.NoError(t, err)
v, err := n.LookupByString("bool")
require.NoError(t, err)
require.Equal(t, datamodel.Kind_Bool, v.Kind())
require.Equal(t, true, must(v.AsBool()))
v, err = n.LookupByString("string")
require.NoError(t, err)
require.Equal(t, datamodel.Kind_String, v.Kind())
require.Equal(t, "foobar", must(v.AsString()))
v, err = n.LookupByString("bytes")
require.NoError(t, err)
require.Equal(t, datamodel.Kind_Bytes, v.Kind())
require.Equal(t, []byte{1, 2, 3, 4}, must(v.AsBytes()))
v, err = n.LookupByString("int")
require.NoError(t, err)
require.Equal(t, datamodel.Kind_Int, v.Kind())
require.Equal(t, int64(1234), must(v.AsInt()))
v, err = n.LookupByString("uint")
require.NoError(t, err)
require.Equal(t, datamodel.Kind_Int, v.Kind())
require.Equal(t, int64(12345), must(v.AsInt()))
v, err = n.LookupByString("float")
require.NoError(t, err)
require.Equal(t, datamodel.Kind_Float, v.Kind())
require.Equal(t, 1.45, must(v.AsFloat()))
v, err = n.LookupByString("slice")
require.NoError(t, err)
require.Equal(t, datamodel.Kind_List, v.Kind())
require.Equal(t, int64(3), v.Length())
require.Equal(t, `list{
0: int{1}
1: int{2}
2: int{3}
}`, printer.Sprint(v))
v, err = n.LookupByString("array")
require.NoError(t, err)
require.Equal(t, datamodel.Kind_List, v.Kind())
require.Equal(t, int64(2), v.Length())
require.Equal(t, `list{
0: int{1}
1: int{2}
}`, printer.Sprint(v))
v, err = n.LookupByString("map")
require.NoError(t, err)
require.Equal(t, datamodel.Kind_Map, v.Kind())
require.Equal(t, int64(2), v.Length())
require.Equal(t, `map{
string{"foo"}: string{"bar"}
string{"foofoo"}: map{
string{"barbar"}: string{"foo"}
}
}`, printer.Sprint(v))
v, err = n.LookupByString("link")
require.NoError(t, err)
require.Equal(t, datamodel.Kind_Link, v.Kind())
asLink, err := v.AsLink()
require.NoError(t, err)
require.True(t, asLink.(cidlink.Link).Equals(cid.MustParse("bafzbeigai3eoy2ccc7ybwjfz5r3rdxqrinwi4rwytly24tdbh6yk7zslrm")))
}
func TestAny(t *testing.T) {
data := map[string]any{
"bool": true,
"string": "foobar",
"bytes": []byte{1, 2, 3, 4},
"int": 1234,
"uint": uint(12345),
"float": 1.45,
"slice": []int{1, 2, 3},
"array": [2]int{1, 2},
"map": map[string]any{
"foo": "bar",
"foofoo": map[string]string{
"barbar": "foo",
},
},
"link": cid.MustParse("bafzbeigai3eoy2ccc7ybwjfz5r3rdxqrinwi4rwytly24tdbh6yk7zslrm"),
"func": func() {},
}
v, err := Any(data["bool"])
require.NoError(t, err)
require.Equal(t, datamodel.Kind_Bool, v.Kind())
require.Equal(t, true, must(v.AsBool()))
v, err = Any(data["string"])
require.NoError(t, err)
require.Equal(t, datamodel.Kind_String, v.Kind())
require.Equal(t, "foobar", must(v.AsString()))
v, err = Any(data["bytes"])
require.NoError(t, err)
require.Equal(t, datamodel.Kind_Bytes, v.Kind())
require.Equal(t, []byte{1, 2, 3, 4}, must(v.AsBytes()))
v, err = Any(data["int"])
require.NoError(t, err)
require.Equal(t, datamodel.Kind_Int, v.Kind())
require.Equal(t, int64(1234), must(v.AsInt()))
v, err = Any(data["uint"])
require.NoError(t, err)
require.Equal(t, datamodel.Kind_Int, v.Kind())
require.Equal(t, int64(12345), must(v.AsInt()))
v, err = Any(data["float"])
require.NoError(t, err)
require.Equal(t, datamodel.Kind_Float, v.Kind())
require.Equal(t, 1.45, must(v.AsFloat()))
v, err = Any(data["slice"])
require.NoError(t, err)
require.Equal(t, datamodel.Kind_List, v.Kind())
require.Equal(t, int64(3), v.Length())
require.Equal(t, `list{
0: int{1}
1: int{2}
2: int{3}
}`, printer.Sprint(v))
v, err = Any(data["array"])
require.NoError(t, err)
require.Equal(t, datamodel.Kind_List, v.Kind())
require.Equal(t, int64(2), v.Length())
require.Equal(t, `list{
0: int{1}
1: int{2}
}`, printer.Sprint(v))
v, err = Any(data["map"])
require.NoError(t, err)
require.Equal(t, datamodel.Kind_Map, v.Kind())
require.Equal(t, int64(2), v.Length())
require.Equal(t, `map{
string{"foo"}: string{"bar"}
string{"foofoo"}: map{
string{"barbar"}: string{"foo"}
}
}`, printer.Sprint(v))
v, err = Any(data["link"])
require.NoError(t, err)
require.Equal(t, datamodel.Kind_Link, v.Kind())
asLink, err := v.AsLink()
require.NoError(t, err)
require.True(t, asLink.(cidlink.Link).Equals(cid.MustParse("bafzbeigai3eoy2ccc7ybwjfz5r3rdxqrinwi4rwytly24tdbh6yk7zslrm")))
_, err = Any(data["func"])
require.Error(t, err)
}
func BenchmarkAny(b *testing.B) {
b.Run("bool", func(b *testing.B) {
b.ReportAllocs()
for n := 0; n < b.N; n++ {
_, _ = Any(true)
}
})
b.Run("string", func(b *testing.B) {
b.ReportAllocs()
for n := 0; n < b.N; n++ {
_, _ = Any("foobar")
}
})
b.Run("bytes", func(b *testing.B) {
b.ReportAllocs()
for n := 0; n < b.N; n++ {
_, _ = Any([]byte{1, 2, 3, 4})
}
})
b.Run("map", func(b *testing.B) {
b.ReportAllocs()
for n := 0; n < b.N; n++ {
_, _ = Any(map[string]any{
"foo": "bar",
"foofoo": map[string]string{
"barbar": "foo",
},
})
}
})
}
func TestAnyAssembleIntegerOverflow(t *testing.T) {
tests := []struct {
name string
input interface{}
shouldErr bool
}{
{
name: "valid int",
input: 42,
shouldErr: false,
},
{
name: "max safe int",
input: limits.MaxInt53,
shouldErr: false,
},
{
name: "min safe int",
input: limits.MinInt53,
shouldErr: false,
},
{
name: "overflow int",
input: int64(limits.MaxInt53 + 1),
shouldErr: true,
},
{
name: "underflow int",
input: int64(limits.MinInt53 - 1),
shouldErr: true,
},
{
name: "overflow uint",
input: uint64(limits.MaxInt53 + 1),
shouldErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Any(tt.input)
if tt.shouldErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
func must[T any](t T, err error) T {
if err != nil {
panic(err)
}
return t
}

306
pkg/policy/match.go Normal file
View File

@@ -0,0 +1,306 @@
package policy
import (
"cmp"
"fmt"
"math"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/must"
)
// Match determines if the IPLD node satisfies the policy.
// The first Statement failing to match is returned as well.
func (p Policy) Match(node datamodel.Node) (bool, Statement) {
for _, stmt := range p {
res, leaf := matchStatement(stmt, node)
switch res {
case matchResultNoData, matchResultFalse:
return false, leaf
case matchResultOptionalNoData, matchResultTrue:
// continue
}
}
return true, nil
}
// PartialMatch returns false IIF one non-optional Statement has the corresponding data and doesn't match.
// If the data is missing or the non-optional Statement is matching, true is returned.
//
// This allows performing the policy checking in multiple steps, and find immediately if a Statement already failed.
// A final call to Match is necessary to make sure that the policy is fully matched, with no missing data
// (apart from optional values).
//
// The first Statement failing to match is returned as well.
func (p Policy) PartialMatch(node datamodel.Node) (bool, Statement) {
for _, stmt := range p {
res, leaf := matchStatement(stmt, node)
switch res {
case matchResultFalse:
return false, leaf
case matchResultNoData, matchResultOptionalNoData, matchResultTrue:
// continue
}
}
return true, nil
}
type matchResult int8
const (
matchResultTrue matchResult = iota // statement has data and resolve to true
matchResultFalse // statement has data and resolve to false
matchResultNoData // statement has no data
matchResultOptionalNoData // statement has no data and is optional
)
// matchStatement evaluate the policy against the given ipld.Node and returns:
// - matchResultTrue: if the selector matched and the statement evaluated to true.
// - matchResultFalse: if the selector matched and the statement evaluated to false.
// - matchResultNoData: if the selector didn't match the expected data.
// For matchResultTrue and matchResultNoData, the leaf-most (innermost) statement failing to be true is returned,
// as well as the corresponding root-most encompassing statement.
func matchStatement(cur Statement, node ipld.Node) (_ matchResult, leafMost Statement) {
var boolToRes = func(v bool) (matchResult, Statement) {
if v {
return matchResultTrue, nil
} else {
return matchResultFalse, cur
}
}
switch cur.Kind() {
case KindEqual:
if s, ok := cur.(equality); ok {
res, err := s.selector.Select(node)
if err != nil {
return matchResultNoData, cur
}
if res == nil { // optional selector didn't match
return matchResultOptionalNoData, nil
}
return boolToRes(datamodel.DeepEqual(s.value, res))
}
case KindNotEqual:
if s, ok := cur.(equality); ok {
res, err := s.selector.Select(node)
if err != nil {
return matchResultNoData, cur
}
if res == nil { // optional selector didn't match
return matchResultOptionalNoData, nil
}
return boolToRes(!datamodel.DeepEqual(s.value, res))
}
case KindGreaterThan:
if s, ok := cur.(equality); ok {
res, err := s.selector.Select(node)
if err != nil {
return matchResultNoData, cur
}
if res == nil { // optional selector didn't match
return matchResultOptionalNoData, nil
}
return boolToRes(isOrdered(s.value, res, gt))
}
case KindGreaterThanOrEqual:
if s, ok := cur.(equality); ok {
res, err := s.selector.Select(node)
if err != nil {
return matchResultNoData, cur
}
if res == nil { // optional selector didn't match
return matchResultOptionalNoData, nil
}
return boolToRes(isOrdered(s.value, res, gte))
}
case KindLessThan:
if s, ok := cur.(equality); ok {
res, err := s.selector.Select(node)
if err != nil {
return matchResultNoData, cur
}
if res == nil { // optional selector didn't match
return matchResultOptionalNoData, nil
}
return boolToRes(isOrdered(s.value, res, lt))
}
case KindLessThanOrEqual:
if s, ok := cur.(equality); ok {
res, err := s.selector.Select(node)
if err != nil {
return matchResultNoData, cur
}
if res == nil { // optional selector didn't match
return matchResultOptionalNoData, nil
}
return boolToRes(isOrdered(s.value, res, lte))
}
case KindNot:
if s, ok := cur.(negation); ok {
res, leaf := matchStatement(s.statement, node)
switch res {
case matchResultNoData, matchResultOptionalNoData:
return res, leaf
case matchResultTrue:
return matchResultFalse, cur
case matchResultFalse:
return matchResultTrue, nil
}
}
case KindAnd:
if s, ok := cur.(connective); ok {
for _, cs := range s.statements {
res, leaf := matchStatement(cs, node)
switch res {
case matchResultNoData, matchResultOptionalNoData:
return res, leaf
case matchResultTrue:
// continue
case matchResultFalse:
return matchResultFalse, leaf
}
}
return matchResultTrue, nil
}
case KindOr:
if s, ok := cur.(connective); ok {
if len(s.statements) == 0 {
return matchResultTrue, nil
}
for _, cs := range s.statements {
res, leaf := matchStatement(cs, node)
switch res {
case matchResultNoData, matchResultOptionalNoData:
return res, leaf
case matchResultTrue:
return matchResultTrue, leaf
case matchResultFalse:
// continue
}
}
return matchResultFalse, cur
}
case KindLike:
if s, ok := cur.(wildcard); ok {
res, err := s.selector.Select(node)
if err != nil {
return matchResultNoData, cur
}
if res == nil { // optional selector didn't match
return matchResultOptionalNoData, nil
}
v, err := res.AsString()
if err != nil {
return matchResultFalse, cur // not a string
}
return boolToRes(s.pattern.Match(v))
}
case KindAll:
if s, ok := cur.(quantifier); ok {
res, err := s.selector.Select(node)
if err != nil {
return matchResultNoData, cur
}
if res == nil {
return matchResultOptionalNoData, nil
}
it := res.ListIterator()
if it == nil {
return matchResultFalse, cur // not a list
}
for !it.Done() {
_, v, err := it.Next()
if err != nil {
panic("should never happen")
}
matchRes, leaf := matchStatement(s.statement, v)
switch matchRes {
case matchResultNoData, matchResultOptionalNoData:
return matchRes, leaf
case matchResultTrue:
// continue
case matchResultFalse:
return matchResultFalse, leaf
}
}
return matchResultTrue, nil
}
case KindAny:
if s, ok := cur.(quantifier); ok {
res, err := s.selector.Select(node)
if err != nil {
return matchResultNoData, cur
}
if res == nil {
return matchResultOptionalNoData, nil
}
it := res.ListIterator()
if it == nil {
return matchResultFalse, cur // not a list
}
for !it.Done() {
_, v, err := it.Next()
if err != nil {
panic("should never happen")
}
matchRes, leaf := matchStatement(s.statement, v)
switch matchRes {
case matchResultNoData, matchResultOptionalNoData:
return matchRes, leaf
case matchResultTrue:
return matchResultTrue, nil
case matchResultFalse:
// continue
}
}
return matchResultFalse, cur
}
}
panic(fmt.Errorf("unimplemented statement kind: %s", cur.Kind()))
}
// isOrdered compares two IPLD nodes and returns true if they satisfy the given ordering function.
// It supports comparison of integers and floats, returning false for:
// - Nodes of different or unsupported kinds
// - Integer values outside JavaScript's safe integer bounds (±2^53-1)
// - Non-finite floating point values (NaN or ±Inf)
//
// The satisfies parameter is a function that interprets the comparison result:
// - For ">" it returns true when order is 1
// - For ">=" it returns true when order is 0 or 1
// - For "<" it returns true when order is -1
// - For "<=" it returns true when order is -1 or 0
func isOrdered(expected ipld.Node, actual ipld.Node, satisfies func(order int) bool) bool {
if expected.Kind() == ipld.Kind_Int && actual.Kind() == ipld.Kind_Int {
a := must.Int(actual)
b := must.Int(expected)
return satisfies(cmp.Compare(a, b))
}
if expected.Kind() == ipld.Kind_Float && actual.Kind() == ipld.Kind_Float {
a, err := actual.AsFloat()
if err != nil {
panic(fmt.Errorf("extracting node float: %w", err))
}
b, err := expected.AsFloat()
if err != nil {
panic(fmt.Errorf("extracting selector float: %w", err))
}
if math.IsInf(a, 0) || math.IsNaN(a) || math.IsInf(b, 0) || math.IsNaN(b) {
return false
}
return satisfies(cmp.Compare(a, b))
}
return false
}
func gt(order int) bool { return order == 1 }
func gte(order int) bool { return order == 0 || order == 1 }
func lt(order int) bool { return order == -1 }
func lte(order int) bool { return order == 0 || order == -1 }

1071
pkg/policy/match_test.go Normal file

File diff suppressed because it is too large Load Diff

254
pkg/policy/policy.go Normal file
View File

@@ -0,0 +1,254 @@
package policy
// https://github.com/ucan-wg/delegation/blob/4094d5878b58f5d35055a3b93fccda0b8329ebae/README.md#policy
import (
"fmt"
"strings"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec/dagjson"
selpkg "github.com/ucan-wg/go-ucan/pkg/policy/selector"
)
const (
KindEqual = "==" // implemented by equality
KindNotEqual = "!=" // implemented by equality
KindGreaterThan = ">" // implemented by equality
KindGreaterThanOrEqual = ">=" // implemented by equality
KindLessThan = "<" // implemented by equality
KindLessThanOrEqual = "<=" // implemented by equality
KindNot = "not" // implemented by negation
KindAnd = "and" // implemented by connective
KindOr = "or" // implemented by connective
KindLike = "like" // implemented by wildcard
KindAll = "all" // implemented by quantifier
KindAny = "any" // implemented by quantifier
)
type Policy []Statement
type Constructor func() (Statement, error)
func Construct(cstors ...Constructor) (Policy, error) {
stmts, err := assemble(cstors)
if err != nil {
return nil, err
}
return stmts, nil
}
func MustConstruct(cstors ...Constructor) Policy {
pol, err := Construct(cstors...)
if err != nil {
panic(err)
}
return pol
}
func (p Policy) String() string {
if len(p) == 0 {
return "[]"
}
childs := make([]string, len(p))
for i, statement := range p {
childs[i] = strings.ReplaceAll(statement.String(), "\n", "\n ")
}
return fmt.Sprintf("[\n %s\n]", strings.Join(childs, ",\n "))
}
type Statement interface {
Kind() string
String() string
}
type equality struct {
kind string
selector selpkg.Selector
value ipld.Node
}
func (e equality) Kind() string {
return e.kind
}
func (e equality) String() string {
child, err := ipld.Encode(e.value, dagjson.Encode)
if err != nil {
return "ERROR: INVALID VALUE"
}
return fmt.Sprintf(`["%s", "%s", %s]`, e.kind, e.selector, strings.ReplaceAll(string(child), "\n", "\n "))
}
func Equal(selector string, value ipld.Node) Constructor {
return func() (Statement, error) {
sel, err := selpkg.Parse(selector)
return equality{kind: KindEqual, selector: sel, value: value}, err
}
}
func NotEqual(selector string, value ipld.Node) Constructor {
return func() (Statement, error) {
sel, err := selpkg.Parse(selector)
return equality{kind: KindNotEqual, selector: sel, value: value}, err
}
}
func GreaterThan(selector string, value ipld.Node) Constructor {
return func() (Statement, error) {
sel, err := selpkg.Parse(selector)
return equality{kind: KindGreaterThan, selector: sel, value: value}, err
}
}
func GreaterThanOrEqual(selector string, value ipld.Node) Constructor {
return func() (Statement, error) {
sel, err := selpkg.Parse(selector)
return equality{kind: KindGreaterThanOrEqual, selector: sel, value: value}, err
}
}
func LessThan(selector string, value ipld.Node) Constructor {
return func() (Statement, error) {
sel, err := selpkg.Parse(selector)
return equality{kind: KindLessThan, selector: sel, value: value}, err
}
}
func LessThanOrEqual(selector string, value ipld.Node) Constructor {
return func() (Statement, error) {
sel, err := selpkg.Parse(selector)
return equality{kind: KindLessThanOrEqual, selector: sel, value: value}, err
}
}
type negation struct {
statement Statement
}
func (n negation) Kind() string {
return KindNot
}
func (n negation) String() string {
child := n.statement.String()
return fmt.Sprintf(`["%s", %s]`, n.Kind(), strings.ReplaceAll(child, "\n", "\n "))
}
func Not(cstor Constructor) Constructor {
return func() (Statement, error) {
stmt, err := cstor()
return negation{statement: stmt}, err
}
}
type connective struct {
kind string
statements []Statement
}
func (c connective) Kind() string {
return c.kind
}
func (c connective) String() string {
childs := make([]string, len(c.statements))
for i, statement := range c.statements {
childs[i] = strings.ReplaceAll(statement.String(), "\n", "\n ")
}
return fmt.Sprintf("[\"%s\", [\n %s\n]]", c.kind, strings.Join(childs, ",\n "))
}
func And(cstors ...Constructor) Constructor {
return func() (Statement, error) {
stmts, err := assemble(cstors)
if err != nil {
return nil, err
}
return connective{kind: KindAnd, statements: stmts}, nil
}
}
func Or(cstors ...Constructor) Constructor {
return func() (Statement, error) {
stmts, err := assemble(cstors)
if err != nil {
return nil, err
}
return connective{kind: KindOr, statements: stmts}, nil
}
}
type wildcard struct {
selector selpkg.Selector
pattern glob
}
func (n wildcard) Kind() string {
return KindLike
}
func (n wildcard) String() string {
return fmt.Sprintf(`["%s", "%s", "%s"]`, n.Kind(), n.selector, n.pattern)
}
func Like(selector string, pattern string) Constructor {
return func() (Statement, error) {
g, err := parseGlob(pattern)
if err != nil {
return nil, err
}
sel, err := selpkg.Parse(selector)
return wildcard{selector: sel, pattern: g}, err
}
}
type quantifier struct {
kind string
selector selpkg.Selector
statement Statement
}
func (n quantifier) Kind() string {
return n.kind
}
func (n quantifier) String() string {
child := n.statement.String()
return fmt.Sprintf("[\"%s\", \"%s\",\n %s\n]", n.Kind(), n.selector, strings.ReplaceAll(child, "\n", "\n "))
}
func All(selector string, cstor Constructor) Constructor {
return func() (Statement, error) {
stmt, err := cstor()
if err != nil {
return nil, err
}
sel, err := selpkg.Parse(selector)
return quantifier{kind: KindAll, selector: sel, statement: stmt}, err
}
}
func Any(selector string, cstor Constructor) Constructor {
return func() (Statement, error) {
stmt, err := cstor()
if err != nil {
return nil, err
}
sel, err := selpkg.Parse(selector)
return quantifier{kind: KindAny, selector: sel, statement: stmt}, err
}
}
func assemble(cstors []Constructor) ([]Statement, error) {
stmts := make([]Statement, 0, len(cstors))
for _, cstor := range cstors {
stmt, err := cstor()
if err != nil {
return nil, err
}
stmts = append(stmts, stmt)
}
return stmts, nil
}

95
pkg/policy/policy_test.go Normal file
View File

@@ -0,0 +1,95 @@
package policy_test
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/pkg/policy"
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
)
func ExamplePolicy() {
pol := policy.MustConstruct(
policy.Equal(".status", literal.String("draft")),
policy.All(".reviewer",
policy.Like(".email", "*@example.com"),
),
policy.Any(".tags", policy.Or(
policy.Equal(".", literal.String("news")),
policy.Equal(".", literal.String("press")),
)),
)
fmt.Println(pol)
// Output:
// [
// ["==", ".status", "draft"],
// ["all", ".reviewer",
// ["like", ".email", "*@example.com"]
// ],
// ["any", ".tags",
// ["or", [
// ["==", ".", "news"],
// ["==", ".", "press"]
// ]]
// ]
// ]
}
func ExamplePolicy_accumulate() {
var statements []policy.Constructor
statements = append(statements, policy.Equal(".status", literal.String("draft")))
statements = append(statements, policy.All(".reviewer",
policy.Like(".email", "*@example.com"),
))
statements = append(statements, policy.Any(".tags", policy.Or(
policy.Equal(".", literal.String("news")),
policy.Equal(".", literal.String("press")),
)))
pol := policy.MustConstruct(statements...)
fmt.Println(pol)
// Output:
// [
// ["==", ".status", "draft"],
// ["all", ".reviewer",
// ["like", ".email", "*@example.com"]
// ],
// ["any", ".tags",
// ["or", [
// ["==", ".", "news"],
// ["==", ".", "press"]
// ]]
// ]
// ]
}
func TestConstruct(t *testing.T) {
pol, err := policy.Construct(
policy.Equal(".status", literal.String("draft")),
policy.All(".reviewer",
policy.Like(".email", "*@example.com"),
),
)
require.NoError(t, err)
require.NotNil(t, pol)
// check if errors cascade correctly
pol, err = policy.Construct(
policy.Equal(".status", literal.String("draft")),
policy.All(".reviewer", policy.Or(
policy.Like(".email", "*@example.com"),
policy.Like(".", "\\"), // invalid pattern
)),
)
require.Error(t, err)
require.Nil(t, pol)
}

View File

@@ -0,0 +1,67 @@
package policytest
import (
"github.com/ipld/go-ipld-prime"
"github.com/ucan-wg/go-ucan/pkg/args"
"github.com/ucan-wg/go-ucan/pkg/policy"
"github.com/ucan-wg/go-ucan/pkg/policy/literal"
)
// EmptyPolicy provides a Policy with no statements.
var EmptyPolicy = policy.Policy{}
// SpecPolicy provides a valid Policy containing the statements that are included
// in the second code block of the [Validation] section of the delegation specification.
//
// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation
var SpecPolicy = policy.MustConstruct(
policy.Equal(".from", literal.String("alice@example.com")),
policy.Any(".to", policy.Like(".", "*@example.com")),
)
// TODO: Replace the URL for [Validation] above when the delegation
// specification has been finished/merged.
// SpecValidArguments provides valid, instantiated Arguments containing
// the key/value pairs that are included in portion of the second code block
// of the [Validation] section of the delegation specification.
//
// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation
var SpecValidArguments = args.NewBuilder().
Add("from", "alice@example.com").
Add("to", []string{
"bob@example.com",
"carol@not.example.com",
}).
Add("title", "Coffee").
Add("body", "Still on for coffee").
MustBuild()
var specValidArgumentsIPLD = mustIPLD(SpecValidArguments)
// SpecInvalidArguments provides invalid, instantiated Arguments containing
// the key/value pairs that are included in portion of the second code block
// of the [Validation] section of the delegation specification.
//
// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation
var SpecInvalidArguments = args.NewBuilder().
Add("from", "alice@example.com").
Add("to", []string{
"bob@null.com",
"carol@elsewhere.example.com",
}).
Add("title", "Coffee").
Add("body", "Still on for coffee").
MustBuild()
var specInvalidArgumentsIPLD = mustIPLD(SpecInvalidArguments)
func mustIPLD(args *args.Args) ipld.Node {
node, err := args.ToIPLD()
if err != nil {
panic(err)
}
return node
}

View File

@@ -0,0 +1,32 @@
package policytest
import (
"testing"
"github.com/stretchr/testify/assert"
)
// TestInvocationValidation applies the example policy to the second
// example arguments as defined in the [Validation] section of the
// invocation specification.
//
// [Validation]: https://github.com/ucan-wg/delegation/tree/v1_ipld#validation
func TestInvocationValidationSpecExamples(t *testing.T) {
t.Parallel()
t.Run("with passing args", func(t *testing.T) {
t.Parallel()
exec, stmt := SpecPolicy.Match(specValidArgumentsIPLD)
assert.True(t, exec)
assert.Nil(t, stmt)
})
t.Run("fails on recipients (second statement)", func(t *testing.T) {
t.Parallel()
exec, stmt := SpecPolicy.Match(specInvalidArgumentsIPLD)
assert.False(t, exec)
assert.NotNil(t, stmt)
})
}

View File

@@ -0,0 +1,209 @@
package selector
import (
"fmt"
"math"
"regexp"
"strconv"
"strings"
"github.com/ucan-wg/go-ucan/pkg/policy/limits"
)
var (
indexRegex = regexp.MustCompile(`^-?\d+$`)
sliceRegex = regexp.MustCompile(`^((\-?\d+:\-?\d*)|(\-?\d*:\-?\d+))$`)
// Field name requirements:
// - Must start with ASCII letter, Unicode letter, or underscore
// - Can contain:
// - ASCII letters (a-z, A-Z)
// - ASCII digits (0-9)
// - Unicode letters (\p{L})
// - Dollar sign ($)
// - Underscore (_)
// - Hyphen (-)
fieldRegex = regexp.MustCompile(`^\.[a-zA-Z_\p{L}][a-zA-Z0-9$_\p{L}\-]*$`)
)
func Parse(str string) (Selector, error) {
if len(str) == 0 {
return nil, newParseError("empty selector", str, 0, "")
}
if string(str[0]) != "." {
return nil, newParseError("selector must start with identity segment '.'", str, 0, string(str[0]))
}
if str == "." {
return Selector{segment{str: ".", identity: true}}, nil
}
if str == ".?" {
return Selector{segment{str: ".?", identity: true, optional: true}}, nil
}
col := 0
var sel Selector
for _, tok := range tokenize(str) {
seg := tok
opt := strings.HasSuffix(tok, "?")
if opt {
seg = strings.TrimRight(tok, "?")
}
switch {
case seg == ".":
if len(sel) > 0 && sel[len(sel)-1].Identity() {
return nil, newParseError("selector contains unsupported recursive descent segment: '..'", str, col, tok)
}
sel = append(sel, segment{str: ".", identity: true})
case seg == "[]":
sel = append(sel, segment{str: tok, optional: opt, iterator: true})
case strings.HasPrefix(seg, "[") && strings.HasSuffix(seg, "]"):
lookup := seg[1 : len(seg)-1]
switch {
// index, [123]
case indexRegex.MatchString(lookup):
idx, err := strconv.Atoi(lookup)
if err != nil {
return nil, newParseError("invalid index", str, col, tok)
}
if int64(idx) > limits.MaxInt53 || int64(idx) < limits.MinInt53 {
return nil, newParseError(fmt.Sprintf("index %d exceeds safe integer bounds", idx), str, col, tok)
}
sel = append(sel, segment{str: tok, optional: opt, index: idx})
// explicit field, ["abcd"]
case strings.HasPrefix(lookup, "\"") && strings.HasSuffix(lookup, "\""):
fieldName := lookup[1 : len(lookup)-1]
if strings.Contains(fieldName, ":") {
return nil, newParseError(fmt.Sprintf("invalid segment: %s", seg), str, col, tok)
}
sel = append(sel, segment{str: tok, optional: opt, field: fieldName})
// slice [3:5] or [:5] or [3:], also negative numbers
case sliceRegex.MatchString(lookup):
var rng [2]int64
splt := strings.Split(lookup, ":")
if splt[0] == "" {
rng[0] = math.MinInt
} else {
i, err := strconv.ParseInt(splt[0], 10, 0)
if err != nil {
return nil, newParseError("invalid slice index", str, col, tok)
}
if i > limits.MaxInt53 || i < limits.MinInt53 {
return nil, newParseError(fmt.Sprintf("slice index %d exceeds safe integer bounds", i), str, col, tok)
}
rng[0] = i
}
if splt[1] == "" {
rng[1] = math.MaxInt
} else {
i, err := strconv.ParseInt(splt[1], 10, 0)
if err != nil {
return nil, newParseError("invalid slice index", str, col, tok)
}
if i > limits.MaxInt53 || i < limits.MinInt53 {
return nil, newParseError(fmt.Sprintf("slice index %d exceeds safe integer bounds", i), str, col, tok)
}
rng[1] = i
}
sel = append(sel, segment{str: tok, optional: opt, slice: rng[:]})
default:
return nil, newParseError(fmt.Sprintf("invalid segment: %s", seg), str, col, tok)
}
case fieldRegex.MatchString(seg):
sel = append(sel, segment{str: tok, optional: opt, field: seg[1:]})
default:
return nil, newParseError(fmt.Sprintf("invalid segment: %s", seg), str, col, tok)
}
col += len(tok)
}
return sel, nil
}
func MustParse(sel string) Selector {
s, err := Parse(sel)
if err != nil {
panic(err)
}
return s
}
func tokenize(str string) []string {
var toks []string
col := 0
ofs := 0
ctx := ""
for col < len(str) {
char := string(str[col])
if char == "\"" && string(str[col-1]) != "\\" {
col++
if ctx == "\"" {
ctx = ""
} else {
ctx = "\""
}
continue
}
if ctx == "\"" {
col++
continue
}
if char == "." || char == "[" {
if ofs < col {
toks = append(toks, str[ofs:col])
}
ofs = col
}
col++
}
if ofs < col && ctx != "\"" {
toks = append(toks, str[ofs:col])
}
return toks
}
type parseErr struct {
msg string
src string
col int
tok string
}
func (p parseErr) Name() string {
return "ParseError"
}
func (p parseErr) Message() string {
return p.msg
}
func (p parseErr) Column() int {
return p.col
}
func (p parseErr) Error() string {
return p.msg
}
func (p parseErr) Source() string {
return p.src
}
func (p parseErr) Token() string {
return p.tok
}
func newParseError(message string, source string, column int, token string) error {
return parseErr{message, source, column, token}
}

View File

@@ -0,0 +1,641 @@
package selector
import (
"fmt"
"math"
"testing"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/pkg/policy/limits"
)
func TestParse(t *testing.T) {
t.Run("identity", func(t *testing.T) {
sel, err := Parse(".")
require.NoError(t, err)
require.Equal(t, 1, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
})
t.Run("dotted field name", func(t *testing.T) {
sel, err := Parse(".foo")
require.NoError(t, err)
require.Equal(t, 1, len(sel))
require.False(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Equal(t, sel[0].Field(), "foo")
require.Empty(t, sel[0].Index())
sel, err = Parse(".foo_bar")
require.NoError(t, err)
require.Equal(t, 1, len(sel))
require.False(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Equal(t, sel[0].Field(), "foo_bar")
require.Empty(t, sel[0].Index())
sel, err = Parse(".foo-bar")
require.NoError(t, err)
require.Equal(t, 1, len(sel))
require.False(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Equal(t, sel[0].Field(), "foo-bar")
require.Empty(t, sel[0].Index())
sel, err = Parse(".foo*bar")
require.ErrorContains(t, err, "invalid segment")
})
t.Run("explicit field", func(t *testing.T) {
sel, err := Parse(`.["foo"]`)
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.False(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Empty(t, sel[1].Slice())
require.Equal(t, sel[1].Field(), "foo")
require.Empty(t, sel[1].Index())
})
t.Run("iterator, collection value", func(t *testing.T) {
sel, err := Parse(".[]")
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.False(t, sel[1].Optional())
require.True(t, sel[1].Iterator())
require.Empty(t, sel[1].Slice())
require.Empty(t, sel[1].Field())
require.Empty(t, sel[1].Index())
})
t.Run("index", func(t *testing.T) {
sel, err := Parse(".[138]")
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.False(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Empty(t, sel[1].Slice())
require.Empty(t, sel[1].Field())
require.Equal(t, sel[1].Index(), 138)
})
t.Run("negative index", func(t *testing.T) {
sel, err := Parse(".[-138]")
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.False(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Empty(t, sel[1].Slice())
require.Empty(t, sel[1].Field())
require.Equal(t, sel[1].Index(), -138)
})
t.Run("List slice", func(t *testing.T) {
sel, err := Parse(".[7:11]")
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.False(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Equal(t, sel[1].Slice(), []int64{7, 11})
require.Empty(t, sel[1].Field())
require.Empty(t, sel[1].Index())
sel, err = Parse(".[2:]")
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.False(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Equal(t, sel[1].Slice(), []int64{2, math.MaxInt})
require.Empty(t, sel[1].Field())
require.Empty(t, sel[1].Index())
sel, err = Parse(".[:42]")
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.False(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Equal(t, sel[1].Slice(), []int64{math.MinInt, 42})
require.Empty(t, sel[1].Field())
require.Empty(t, sel[1].Index())
sel, err = Parse(".[0:-2]")
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.False(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Equal(t, sel[1].Slice(), []int64{0, -2})
require.Empty(t, sel[1].Field())
require.Empty(t, sel[1].Index())
})
t.Run("optional identity", func(t *testing.T) {
sel, err := Parse(".?")
require.NoError(t, err)
require.Equal(t, 1, len(sel))
require.True(t, sel[0].Identity())
require.True(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
})
t.Run("optional dotted field name", func(t *testing.T) {
sel, err := Parse(".foo?")
require.NoError(t, err)
require.Equal(t, 1, len(sel))
require.False(t, sel[0].Identity())
require.True(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Equal(t, sel[0].Field(), "foo")
require.Empty(t, sel[0].Index())
})
t.Run("optional explicit field", func(t *testing.T) {
sel, err := Parse(`.["foo"]?`)
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.True(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Empty(t, sel[1].Slice())
require.Equal(t, sel[1].Field(), "foo")
require.Empty(t, sel[1].Index())
})
t.Run("optional iterator", func(t *testing.T) {
sel, err := Parse(".[]?")
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.True(t, sel[1].Optional())
require.True(t, sel[1].Iterator())
require.Empty(t, sel[1].Slice())
require.Empty(t, sel[1].Field())
require.Empty(t, sel[1].Index())
})
t.Run("optional index", func(t *testing.T) {
sel, err := Parse(".[138]?")
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.True(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Empty(t, sel[1].Slice())
require.Empty(t, sel[1].Field())
require.Equal(t, sel[1].Index(), 138)
})
t.Run("optional negative index", func(t *testing.T) {
sel, err := Parse(".[-138]?")
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.True(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Empty(t, sel[1].Slice())
require.Empty(t, sel[1].Field())
require.Equal(t, sel[1].Index(), -138)
})
t.Run("optional list slice", func(t *testing.T) {
sel, err := Parse(".[7:11]?")
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.True(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Equal(t, sel[1].Slice(), []int64{7, 11})
require.Empty(t, sel[1].Field())
require.Empty(t, sel[1].Index())
sel, err = Parse(".[2:]?")
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.True(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Equal(t, sel[1].Slice(), []int64{2, math.MaxInt})
require.Empty(t, sel[1].Field())
require.Empty(t, sel[1].Index())
sel, err = Parse(".[:42]?")
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.True(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Equal(t, sel[1].Slice(), []int64{math.MinInt, 42})
require.Empty(t, sel[1].Field())
require.Empty(t, sel[1].Index())
sel, err = Parse(".[0:-2]?")
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.True(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Equal(t, sel[1].Slice(), []int64{0, -2})
require.Empty(t, sel[1].Field())
require.Empty(t, sel[1].Index())
})
t.Run("idempotent optional", func(t *testing.T) {
sel, err := Parse(".foo???")
require.NoError(t, err)
require.Equal(t, 1, len(sel))
require.False(t, sel[0].Identity())
require.True(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Equal(t, sel[0].Field(), "foo")
require.Empty(t, sel[0].Index())
})
t.Run("deny multi dot", func(t *testing.T) {
_, err := Parse("..")
require.Error(t, err)
})
t.Run("nesting", func(t *testing.T) {
str := `.foo.["bar"].[138]?.baz[1:]`
sel, err := Parse(str)
require.NoError(t, err)
require.Equal(t, str, sel.String())
require.Equal(t, 7, len(sel))
require.False(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Equal(t, sel[0].Field(), "foo")
require.Empty(t, sel[0].Index())
require.True(t, sel[1].Identity())
require.False(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Empty(t, sel[1].Slice())
require.Empty(t, sel[1].Field())
require.Empty(t, sel[1].Index())
require.False(t, sel[2].Identity())
require.False(t, sel[2].Optional())
require.False(t, sel[2].Iterator())
require.Empty(t, sel[2].Slice())
require.Equal(t, sel[2].Field(), "bar")
require.Empty(t, sel[2].Index())
require.True(t, sel[3].Identity())
require.False(t, sel[3].Optional())
require.False(t, sel[3].Iterator())
require.Empty(t, sel[3].Slice())
require.Empty(t, sel[3].Field())
require.Empty(t, sel[3].Index())
require.False(t, sel[4].Identity())
require.True(t, sel[4].Optional())
require.False(t, sel[4].Iterator())
require.Empty(t, sel[4].Slice())
require.Empty(t, sel[4].Field())
require.Equal(t, sel[4].Index(), 138)
require.False(t, sel[5].Identity())
require.False(t, sel[5].Optional())
require.False(t, sel[5].Iterator())
require.Empty(t, sel[5].Slice())
require.Equal(t, sel[5].Field(), "baz")
require.Empty(t, sel[5].Index())
require.False(t, sel[6].Identity())
require.False(t, sel[6].Optional())
require.False(t, sel[6].Iterator())
require.Equal(t, sel[6].Slice(), []int64{1, math.MaxInt})
require.Empty(t, sel[6].Field())
require.Empty(t, sel[6].Index())
})
t.Run("non dotted", func(t *testing.T) {
_, err := Parse("foo")
require.NotNil(t, err)
})
t.Run("non quoted", func(t *testing.T) {
_, err := Parse(".[foo]")
require.NotNil(t, err)
})
t.Run("slice with negative start and positive end", func(t *testing.T) {
sel, err := Parse(".[0:-2]")
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.False(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Equal(t, sel[1].Slice(), []int64{0, -2})
require.Empty(t, sel[1].Field())
require.Empty(t, sel[1].Index())
})
t.Run("slice with start greater than end", func(t *testing.T) {
sel, err := Parse(".[5:2]")
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.False(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Equal(t, sel[1].Slice(), []int64{5, 2})
require.Empty(t, sel[1].Field())
require.Empty(t, sel[1].Index())
})
t.Run("slice on string", func(t *testing.T) {
sel, err := Parse(`.["foo"].[1:3]`)
require.NoError(t, err)
require.Equal(t, 4, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.False(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Empty(t, sel[1].Slice())
require.Equal(t, sel[1].Field(), "foo")
require.Empty(t, sel[1].Index())
require.True(t, sel[2].Identity())
require.False(t, sel[2].Optional())
require.False(t, sel[2].Iterator())
require.Empty(t, sel[2].Slice())
require.Empty(t, sel[2].Field())
require.Empty(t, sel[2].Index())
require.False(t, sel[3].Identity())
require.False(t, sel[3].Optional())
require.False(t, sel[3].Iterator())
require.Equal(t, sel[3].Slice(), []int64{1, 3})
require.Empty(t, sel[3].Field())
require.Empty(t, sel[3].Index())
})
t.Run("slice on array", func(t *testing.T) {
sel, err := Parse(`.[1:3]`)
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.False(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Equal(t, sel[1].Slice(), []int64{1, 3})
require.Empty(t, sel[1].Field())
require.Empty(t, sel[1].Index())
})
t.Run("index on array", func(t *testing.T) {
sel, err := Parse(`.[1]`)
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.False(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Empty(t, sel[1].Slice())
require.Empty(t, sel[1].Field())
require.Equal(t, sel[1].Index(), 1)
})
t.Run("invalid slice on object", func(t *testing.T) {
_, err := Parse(`.["foo":"bar"]`)
require.Error(t, err)
require.Contains(t, err.Error(), "invalid segment")
})
t.Run("index on object", func(t *testing.T) {
sel, err := Parse(`.["foo"]`)
require.NoError(t, err)
require.Equal(t, 2, len(sel))
require.True(t, sel[0].Identity())
require.False(t, sel[0].Optional())
require.False(t, sel[0].Iterator())
require.Empty(t, sel[0].Slice())
require.Empty(t, sel[0].Field())
require.Empty(t, sel[0].Index())
require.False(t, sel[1].Identity())
require.False(t, sel[1].Optional())
require.False(t, sel[1].Iterator())
require.Empty(t, sel[1].Slice())
require.Equal(t, sel[1].Field(), "foo")
require.Empty(t, sel[1].Index())
})
t.Run("slice with non-integer start", func(t *testing.T) {
_, err := Parse(".[foo:3]")
require.Error(t, err)
})
t.Run("slice with non-integer end", func(t *testing.T) {
_, err := Parse(".[1:bar]")
require.Error(t, err)
})
t.Run("index with non-integer", func(t *testing.T) {
_, err := Parse(".[foo]")
require.Error(t, err)
})
t.Run("extended field names", func(t *testing.T) {
validFields := []string{
".basic",
".user_name",
".user-name",
".userName$special",
".αβγ", // Greek letters
".użytkownik", // Polish characters
".用户", // Chinese characters
".사용자", // Korean characters
"._private",
".number123",
".camelCase",
".snake_case",
".kebab-case",
".mixed_kebab-case",
".with$dollar",
".MIXED_Case_123",
".unicodeø",
}
for _, field := range validFields {
sel, err := Parse(field)
require.NoError(t, err, "field: %s", field)
require.NotNil(t, sel)
}
invalidFields := []string{
".123number", // Can't start with digit
".@special", // @ not allowed
".space name", // No spaces
".#hashtag", // No #
".name!", // No !
".{brackets}", // No brackets
".name/with/slashes", // No slashes
}
for _, field := range invalidFields {
sel, err := Parse(field)
require.Error(t, err, "field: %s", field)
require.Nil(t, sel)
}
})
t.Run("integer overflow", func(t *testing.T) {
sel, err := Parse(fmt.Sprintf(".[%d]", limits.MaxInt53+1))
require.Error(t, err)
require.Nil(t, sel)
sel, err = Parse(fmt.Sprintf(".[%d]", limits.MinInt53-1))
require.Error(t, err)
require.Nil(t, sel)
// Test slice overflow
sel, err = Parse(fmt.Sprintf(".[%d:42]", limits.MaxInt53+1))
require.Error(t, err)
require.Nil(t, sel)
sel, err = Parse(fmt.Sprintf(".[1:%d]", limits.MaxInt53+1))
require.Error(t, err)
require.Nil(t, sel)
})
}

View File

@@ -0,0 +1,342 @@
package selector
import (
"fmt"
"math"
"strconv"
"strings"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/fluent/qp"
"github.com/ipld/go-ipld-prime/node/basicnode"
)
// Selector describes a UCAN policy selector, as specified here:
// https://github.com/ucan-wg/delegation/blob/4094d5878b58f5d35055a3b93fccda0b8329ebae/README.md#selectors
type Selector []segment
// Select perform the selection described by the selector on the input IPLD DAG.
// Select can return:
// - exactly one matched IPLD node
// - a resolutionErr error if not being able to resolve to a node
// - nil and no errors, if the selector couldn't match on an optional segment (with ?).
func (s Selector) Select(subject ipld.Node) (ipld.Node, error) {
return resolve(s, subject, nil)
}
func (s Selector) String() string {
var res strings.Builder
for _, seg := range s {
res.WriteString(seg.String())
}
return res.String()
}
type segment struct {
str string
identity bool
optional bool
iterator bool
slice []int64 // either 0-length or 2-length
field string
index int
}
// String returns the segment's string representation.
func (s segment) String() string {
return s.str
}
// Identity flags that this selector is the identity selector.
func (s segment) Identity() bool {
return s.identity
}
// Optional flags that this selector is optional.
func (s segment) Optional() bool {
return s.optional
}
// Iterator flags that this selector is an iterator segment.
func (s segment) Iterator() bool {
return s.iterator
}
// Slice flags that this segment targets a range of a slice.
func (s segment) Slice() []int64 {
return s.slice
}
// Field is the name of a field in a struct/map.
func (s segment) Field() string {
return s.field
}
// Index is an index of a slice.
func (s segment) Index() int {
return s.index
}
func resolve(sel Selector, subject ipld.Node, at []string) (ipld.Node, error) {
errIfNotOptional := func(s segment, err error) error {
if !s.Optional() {
return err
}
return nil
}
cur := subject
for _, seg := range sel {
// 1st level: handle the different segment types (iterator, field, slice, index)
// 2nd level: handle different node kinds (list, map, string, bytes)
switch {
case seg.Identity():
continue
case seg.Iterator():
switch {
case cur == nil || cur.Kind() == datamodel.Kind_Null:
if seg.Optional() {
// build an empty list
n, _ := qp.BuildList(basicnode.Prototype.Any, 0, func(_ datamodel.ListAssembler) {})
return n, nil
}
return nil, newResolutionError(fmt.Sprintf("can not iterate over kind: %s", kindString(cur)), at)
case cur.Kind() == datamodel.Kind_List:
// iterators are no-op on list
continue
case cur.Kind() == datamodel.Kind_Map:
// iterators on maps collect the values
nd, err := qp.BuildList(basicnode.Prototype.Any, cur.Length(), func(l datamodel.ListAssembler) {
it := cur.MapIterator()
for !it.Done() {
_, v, err := it.Next()
if err != nil {
// recovered by BuildList
// Error is bubbled up, but should never occur as we already checked the type,
// and are using the iterator correctly.
// This is verified with fuzzing.
panic(err)
}
qp.ListEntry(l, qp.Node(v))
}
})
if err != nil {
panic("should never happen")
}
return nd, nil
default:
return nil, newResolutionError(fmt.Sprintf("can not iterate over kind: %s", kindString(cur)), at)
}
case seg.Field() != "":
at = append(at, seg.Field())
switch {
case cur == nil:
err := newResolutionError(fmt.Sprintf("can not access field: %s on kind: %s", seg.Field(), kindString(cur)), at)
return nil, errIfNotOptional(seg, err)
case cur.Kind() == datamodel.Kind_Map:
n, err := cur.LookupByString(seg.Field())
if err != nil {
// the only possible error is missing field as we already check the type
if seg.Optional() {
cur = nil
} else {
return nil, newResolutionError(fmt.Sprintf("object has no field named: %s", seg.Field()), at)
}
} else {
cur = n
}
default:
err := newResolutionError(fmt.Sprintf("can not access field: %s on kind: %s", seg.Field(), kindString(cur)), at)
return nil, errIfNotOptional(seg, err)
}
case len(seg.Slice()) > 0:
if cur == nil {
err := newResolutionError(fmt.Sprintf("can not slice on kind: %s", kindString(cur)), at)
return nil, errIfNotOptional(seg, err)
}
slice := seg.Slice()
switch cur.Kind() {
case datamodel.Kind_List:
start, end := resolveSliceIndices(slice, cur.Length())
sliced, err := qp.BuildList(basicnode.Prototype.Any, end-start, func(l datamodel.ListAssembler) {
for i := start; i < end; i++ {
item, err := cur.LookupByIndex(i)
if err != nil {
// recovered by BuildList
// Error is bubbled up, but should never occur as we already checked the type and boundaries
// This is verified with fuzzing.
panic(err)
}
qp.ListEntry(l, qp.Node(item))
}
})
if err != nil {
panic("should never happen")
}
cur = sliced
case datamodel.Kind_Bytes:
b, _ := cur.AsBytes()
start, end := resolveSliceIndices(slice, int64(len(b)))
cur = basicnode.NewBytes(b[start:end])
case datamodel.Kind_String:
str, _ := cur.AsString()
runes := []rune(str)
start, end := resolveSliceIndices(slice, int64(len(runes)))
cur = basicnode.NewString(string(runes[start:end]))
default:
return nil, newResolutionError(fmt.Sprintf("can not slice on kind: %s", kindString(cur)), at)
}
default: // Index()
at = append(at, strconv.Itoa(seg.Index()))
if cur == nil {
err := newResolutionError(fmt.Sprintf("can not access index: %d on kind: %s", seg.Index(), kindString(cur)), at)
return nil, errIfNotOptional(seg, err)
}
idx := seg.Index()
switch cur.Kind() {
case datamodel.Kind_List:
if idx < 0 {
idx = int(cur.Length()) + idx
}
if idx < 0 || idx >= int(cur.Length()) {
err := newResolutionError(fmt.Sprintf("index out of bounds: %d", seg.Index()), at)
return nil, errIfNotOptional(seg, err)
}
cur, _ = cur.LookupByIndex(int64(idx))
case datamodel.Kind_Bytes:
b, _ := cur.AsBytes()
if idx < 0 {
idx = len(b) + idx
}
if idx < 0 || idx >= len(b) {
err := newResolutionError(fmt.Sprintf("index %d out of bounds for bytes of length %d", seg.Index(), len(b)), at)
return nil, errIfNotOptional(seg, err)
}
cur = basicnode.NewInt(int64(b[idx]))
default:
return nil, newResolutionError(fmt.Sprintf("can not access index: %d on kind: %s", seg.Index(), kindString(cur)), at)
}
}
}
// segment exhausted, we return where we are
return cur, nil
}
// resolveSliceIndices resolves the start and end indices for slicing a list or byte array.
//
// It takes the slice indices from the selector segment and the length of the list or byte array,
// and returns the resolved start and end indices. Negative indices are supported.
//
// Parameters:
// - slice: The slice indices from the selector segment.
// - length: The length of the list or byte array being sliced.
//
// Returns:
// - start: The resolved start index for slicing.
// - end: The resolved **excluded** end index for slicing.
func resolveSliceIndices(slice []int64, length int64) (start int64, end int64) {
if len(slice) != 2 {
panic("should always be 2-length")
}
start, end = slice[0], slice[1]
// adjust boundaries
switch {
case slice[0] == math.MinInt:
start = 0
case slice[0] < 0:
// Check for potential overflow before adding
if -slice[0] > length {
start = 0
} else {
start = length + slice[0]
}
}
switch {
case slice[1] == math.MaxInt:
end = length
case slice[1] < 0:
// Check for potential overflow before adding
if -slice[1] > length {
end = 0
} else {
end = length + slice[1]
}
}
// backward iteration is not allowed, shortcut to an empty result
if start >= end {
start, end = 0, 0
return
}
// clamp out of bound
if start < 0 {
start = 0
}
if start > length {
start = length
}
if end < 0 {
end = 0
}
if end > length {
end = length
}
return
}
func kindString(n datamodel.Node) string {
if n == nil {
return "null"
}
return n.Kind().String()
}
type resolutionErr struct {
msg string
at []string
}
func (r resolutionErr) Name() string {
return "ResolutionError"
}
func (r resolutionErr) Message() string {
return fmt.Sprintf("can not resolve path: .%s", strings.Join(r.at, "."))
}
func (r resolutionErr) At() []string {
return r.at
}
func (r resolutionErr) Error() string {
return r.Message()
}
func newResolutionError(message string, at []string) error {
return resolutionErr{message, at}
}

View File

@@ -0,0 +1,413 @@
package selector
import (
"errors"
"math"
"strings"
"testing"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec/dagjson"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ipld/go-ipld-prime/fluent/qp"
"github.com/ipld/go-ipld-prime/must"
basicnode "github.com/ipld/go-ipld-prime/node/basic"
"github.com/ipld/go-ipld-prime/node/bindnode"
"github.com/stretchr/testify/require"
)
func TestSelect(t *testing.T) {
type name struct {
First string
Middle *string
Last string
}
type interest struct {
Name string
Outdoor bool
Experience int
}
type user struct {
Name name
Age int
Nationalities []string
Interests []interest
}
ts, err := ipld.LoadSchemaBytes([]byte(`
type User struct {
name Name
age Int
nationalities [String]
interests [Interest]
}
type Name struct {
first String
middle optional String
last String
}
type Interest struct {
name String
outdoor Bool
experience Int
}
`))
require.NoError(t, err)
typ := ts.TypeByName("User")
am := "Joan"
alice := user{
Name: name{First: "Alice", Middle: &am, Last: "Wonderland"},
Age: 24,
Nationalities: []string{"British"},
Interests: []interest{
{Name: "Cycling", Outdoor: true, Experience: 4},
{Name: "Chess", Outdoor: false, Experience: 2},
},
}
bob := user{
Name: name{First: "Bob", Last: "Builder"},
Age: 35,
Nationalities: []string{"Canadian", "South African"},
Interests: []interest{
{Name: "Snowboarding", Outdoor: true, Experience: 8},
{Name: "Reading", Outdoor: false, Experience: 25},
},
}
anode := bindnode.Wrap(&alice, typ)
bnode := bindnode.Wrap(&bob, typ)
t.Run("identity", func(t *testing.T) {
sel, err := Parse(".")
require.NoError(t, err)
res, err := sel.Select(anode)
require.NoError(t, err)
require.NotEmpty(t, res)
age := must.Int(must.Node(res.LookupByString("age")))
require.Equal(t, int64(alice.Age), age)
})
t.Run("nested property", func(t *testing.T) {
sel, err := Parse(".name.first")
require.NoError(t, err)
res, err := sel.Select(anode)
require.NoError(t, err)
require.NotEmpty(t, res)
name := must.String(res)
require.Equal(t, alice.Name.First, name)
res, err = sel.Select(bnode)
require.NoError(t, err)
require.NotEmpty(t, res)
name = must.String(res)
require.Equal(t, bob.Name.First, name)
})
t.Run("optional nested property", func(t *testing.T) {
sel, err := Parse(".name.middle?")
require.NoError(t, err)
res, err := sel.Select(anode)
require.NoError(t, err)
require.NotEmpty(t, res)
name := must.String(res)
require.Equal(t, *alice.Name.Middle, name)
res, err = sel.Select(bnode)
require.NoError(t, err)
require.Empty(t, res)
})
t.Run("not exists", func(t *testing.T) {
sel, err := Parse(".name.foo")
require.NoError(t, err)
res, err := sel.Select(anode)
require.Error(t, err)
require.Empty(t, res)
require.ErrorAs(t, err, &resolutionErr{}, "error should be a resolution error")
})
t.Run("optional not exists", func(t *testing.T) {
sel, err := Parse(".name.foo?")
require.NoError(t, err)
one, err := sel.Select(anode)
require.NoError(t, err)
require.Empty(t, one)
})
t.Run("iterator", func(t *testing.T) {
sel, err := Parse(".interests[]")
require.NoError(t, err)
res, err := sel.Select(anode)
require.NoError(t, err)
require.NotEmpty(t, res)
iname := must.String(must.Node(must.Node(res.LookupByIndex(0)).LookupByString("name")))
require.Equal(t, alice.Interests[0].Name, iname)
iname = must.String(must.Node(must.Node(res.LookupByIndex(1)).LookupByString("name")))
require.Equal(t, alice.Interests[1].Name, iname)
})
t.Run("slice on string", func(t *testing.T) {
sel, err := Parse(`.[1:3]`)
require.NoError(t, err)
node := basicnode.NewString("hello")
res, err := sel.Select(node)
require.NoError(t, err)
require.NotEmpty(t, res)
str, err := res.AsString()
require.NoError(t, err)
require.Equal(t, "el", str) // assert sliced substring
})
t.Run("out of bounds slicing", func(t *testing.T) {
node, err := qp.BuildList(basicnode.Prototype.Any, 3, func(la datamodel.ListAssembler) {
qp.ListEntry(la, qp.Int(1))
qp.ListEntry(la, qp.Int(2))
qp.ListEntry(la, qp.Int(3))
})
require.NoError(t, err)
sel, err := Parse(`.[10:20]`)
require.NoError(t, err)
res, err := sel.Select(node)
require.NoError(t, err)
require.NotEmpty(t, res)
require.Equal(t, int64(0), res.Length())
_, err = res.LookupByIndex(0)
require.ErrorIs(t, err, datamodel.ErrNotExists{}) // assert empty result for out of bounds slice
})
t.Run("backward slicing", func(t *testing.T) {
node, err := qp.BuildList(basicnode.Prototype.Any, 3, func(la datamodel.ListAssembler) {
qp.ListEntry(la, qp.Int(1))
qp.ListEntry(la, qp.Int(2))
qp.ListEntry(la, qp.Int(3))
})
require.NoError(t, err)
sel, err := Parse(`.[5:2]`)
require.NoError(t, err)
res, err := sel.Select(node)
require.NoError(t, err)
require.NotEmpty(t, res)
require.Equal(t, int64(0), res.Length())
_, err = res.LookupByIndex(0)
require.ErrorIs(t, err, datamodel.ErrNotExists{}) // assert empty result for backward slice
})
t.Run("slice with negative index", func(t *testing.T) {
node, err := qp.BuildList(basicnode.Prototype.Any, 3, func(la datamodel.ListAssembler) {
qp.ListEntry(la, qp.Int(1))
qp.ListEntry(la, qp.Int(2))
qp.ListEntry(la, qp.Int(3))
})
require.NoError(t, err)
sel, err := Parse(`.[0:-1]`)
require.NoError(t, err)
res, err := sel.Select(node)
require.NoError(t, err)
require.NotEmpty(t, res)
val, err := res.LookupByIndex(1)
require.NoError(t, err)
require.Equal(t, 2, int(must.Int(val))) // Assert sliced value at index 1
})
t.Run("slice on bytes", func(t *testing.T) {
sel, err := Parse(`.[1:3]`)
require.NoError(t, err)
node := basicnode.NewBytes([]byte{0x01, 0x02, 0x03, 0x04, 0x05})
res, err := sel.Select(node)
require.NoError(t, err)
require.NotEmpty(t, res)
bytes, err := res.AsBytes()
require.NoError(t, err)
require.Equal(t, []byte{0x02, 0x03}, bytes) // assert sliced bytes
})
t.Run("index on bytes", func(t *testing.T) {
sel, err := Parse(`.[2]`)
require.NoError(t, err)
node := basicnode.NewBytes([]byte{0x01, 0x02, 0x03, 0x04, 0x05})
res, err := sel.Select(node)
require.NoError(t, err)
require.NotEmpty(t, res)
val, err := res.AsInt()
require.NoError(t, err)
require.Equal(t, int64(0x03), val) // assert indexed byte value
})
t.Run("out of bounds slicing on bytes", func(t *testing.T) {
sel, err := Parse(`.[10:20]`)
require.NoError(t, err)
node := basicnode.NewBytes([]byte{0x01, 0x02, 0x03})
res, err := sel.Select(node)
require.NoError(t, err)
require.NotNil(t, res)
bytes, err := res.AsBytes()
require.NoError(t, err)
require.Empty(t, bytes) // assert empty result for out of bounds slice
})
t.Run("out of bounds indexing on bytes", func(t *testing.T) {
sel, err := Parse(`.[10]`)
require.NoError(t, err)
node := basicnode.NewBytes([]byte{0x01, 0x02, 0x03})
_, err = sel.Select(node)
require.Error(t, err)
require.Contains(t, err.Error(), "can not resolve path: .10") // assert error for out of bounds index
})
}
func FuzzParse(f *testing.F) {
selectorCorpus := []string{
`.`, `.[]`, `.[]?`, `.[][]?`, `.x`, `.["x"]`, `.[0]`, `.[-1]`, `.[0]`,
`.[0]`, `.[0:2]`, `.[1:]`, `.[:2]`, `.[0:2]`, `.[1:]`, `.x?`, `.x?`,
`.x?`, `.["x"]?`, `.length?`, `.[4]?`, `.[]`, `.[][]`, `.x`, `.x`, `.x`,
`.length`, `.[4]`,
}
for _, selector := range selectorCorpus {
f.Add(selector)
}
f.Fuzz(func(t *testing.T, selector string) {
// only look for panic()
_, _ = Parse(selector)
})
}
func FuzzParseAndSelect(f *testing.F) {
selectorCorpus := []string{
`.`, `.[]`, `.[]?`, `.[][]?`, `.x`, `.["x"]`, `.[0]`, `.[-1]`, `.[0]`,
`.[0]`, `.[0:2]`, `.[1:]`, `.[:2]`, `.[0:2]`, `.[1:]`, `.x?`, `.x?`,
`.x?`, `.["x"]?`, `.length?`, `.[4]?`, `.[]`, `.[][]`, `.x`, `.x`, `.x`,
`.length`, `.[4]`,
}
subjectCorpus := []string{
`{"x":1}`, `[1, 2]`, `null`, `[[1], 2, [3]]`, `{"x": 1 }`, `{"x": 1}`,
`[1, 2]`, `[1, 2]`, `"Hi"`, `{"/":{"bytes":"AAE"}`, `[0, 1, 2]`,
`[0, 1, 2]`, `[0, 1, 2]`, `"hello"`, `{"/":{"bytes":"AAEC"}}`, `{}`,
`null`, `[]`, `{}`, `[1, 2]`, `[0, 1]`, `null`, `[[1], 2, [3]]`, `{}`,
`null`, `[]`, `[1, 2]`, `[0, 1]`,
}
for i := 0; ; i++ {
switch {
case i < len(selectorCorpus) && i < len(subjectCorpus):
f.Add(selectorCorpus[i], subjectCorpus[i])
continue
case i > len(selectorCorpus):
f.Add("", subjectCorpus[i])
continue
case i > len(subjectCorpus):
f.Add(selectorCorpus[i], "")
continue
}
break
}
f.Fuzz(func(t *testing.T, selector, subject string) {
sel, err := Parse(selector)
if err != nil {
t.Skip()
}
np := basicnode.Prototype.Any
nb := np.NewBuilder()
err = dagjson.Decode(nb, strings.NewReader(subject))
if err != nil {
t.Skip()
}
node := nb.Build()
if node == nil {
t.Skip()
}
// look for panic()
_, err = sel.Select(node)
if err != nil && !errors.As(err, &resolutionErr{}) {
// not normal, we should only have resolution errors
t.Fatal(err)
}
})
}
func TestResolveSliceIndices(t *testing.T) {
tests := []struct {
name string
slice []int64
length int64
wantStart int64
wantEnd int64
}{
{
name: "normal case",
slice: []int64{1, 3},
length: 5,
wantStart: 1,
wantEnd: 3,
},
{
name: "negative indices",
slice: []int64{-2, -1},
length: 5,
wantStart: 3,
wantEnd: 4,
},
{
name: "overflow protection negative start",
slice: []int64{math.MinInt64, 3},
length: 5,
wantStart: 0,
wantEnd: 3,
},
{
name: "overflow protection negative end",
slice: []int64{0, math.MinInt64},
length: 5,
wantStart: 0,
wantEnd: 0,
},
{
name: "max bounds",
slice: []int64{0, math.MaxInt64},
length: 5,
wantStart: 0,
wantEnd: 5,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
start, end := resolveSliceIndices(tt.slice, tt.length)
require.Equal(t, tt.wantStart, start)
require.Equal(t, tt.wantEnd, end)
})
}
}

View File

@@ -1,18 +1,15 @@
package selector_test package selector_test
import ( import (
"bytes"
"strings" "strings"
"testing" "testing"
"github.com/ipld/go-ipld-prime" "github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec/dagjson" "github.com/ipld/go-ipld-prime/codec/dagjson"
"github.com/ipld/go-ipld-prime/datamodel"
basicnode "github.com/ipld/go-ipld-prime/node/basic" basicnode "github.com/ipld/go-ipld-prime/node/basic"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/v1/capability/policy/selector" "github.com/ucan-wg/go-ucan/pkg/policy/selector"
) )
// TestSupported Forms runs tests against the Selector according to the // TestSupported Forms runs tests against the Selector according to the
@@ -26,18 +23,16 @@ func TestSupportedForms(t *testing.T) {
Output string Output string
} }
// Pass // Pass and return a node
for _, testcase := range []Testcase{ for _, testcase := range []Testcase{
{Name: "Identity", Selector: `.`, Input: `{"x":1}`, Output: `{"x":1}`}, {Name: "Identity", Selector: `.`, Input: `{"x":1}`, Output: `{"x":1}`},
{Name: "Iterator", Selector: `.[]`, Input: `[1, 2]`, Output: `[1, 2]`}, {Name: "Iterator", Selector: `.[]`, Input: `[1, 2]`, Output: `[1, 2]`},
{Name: "Optional Null Iterator", Selector: `.[]?`, Input: `null`, Output: `()`}, {Name: "Optional Null Iterator", Selector: `.[]?`, Input: `null`, Output: `[]`},
{Name: "Optional Iterator", Selector: `.[][]?`, Input: `[[1], 2, [3]]`, Output: `[1, 3]`},
{Name: "Object Key", Selector: `.x`, Input: `{"x": 1 }`, Output: `1`}, {Name: "Object Key", Selector: `.x`, Input: `{"x": 1 }`, Output: `1`},
{Name: "Quoted Key", Selector: `.["x"]`, Input: `{"x": 1}`, Output: `1`}, {Name: "Quoted Key", Selector: `.["x"]`, Input: `{"x": 1}`, Output: `1`},
{Name: "Index", Selector: `.[0]`, Input: `[1, 2]`, Output: `1`}, {Name: "Index", Selector: `.[0]`, Input: `[1, 2]`, Output: `1`},
{Name: "Negative Index", Selector: `.[-1]`, Input: `[1, 2]`, Output: `2`}, {Name: "Negative Index", Selector: `.[-1]`, Input: `[1, 2]`, Output: `2`},
{Name: "String Index", Selector: `.[0]`, Input: `"Hi"`, Output: `"H"`}, {Name: "Bytes Index", Selector: `.[0]`, Input: `{"/":{"bytes":"AAE"}}`, Output: `0`},
{Name: "Bytes Index", Selector: `.[0]`, Input: `{"/":{"bytes":"AAE"}`, Output: `0`},
{Name: "Array Slice", Selector: `.[0:2]`, Input: `[0, 1, 2]`, Output: `[0, 1]`}, {Name: "Array Slice", Selector: `.[0:2]`, Input: `[0, 1, 2]`, Output: `[0, 1]`},
{Name: "Array Slice", Selector: `.[1:]`, Input: `[0, 1, 2]`, Output: `[1, 2]`}, {Name: "Array Slice", Selector: `.[1:]`, Input: `[0, 1, 2]`, Output: `[1, 2]`},
{Name: "Array Slice", Selector: `.[:2]`, Input: `[0, 1, 2]`, Output: `[0, 1]`}, {Name: "Array Slice", Selector: `.[:2]`, Input: `[0, 1, 2]`, Output: `[0, 1]`},
@@ -52,35 +47,16 @@ func TestSupportedForms(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// attempt to select // attempt to select
node, nodes, err := selector.Select(sel, makeNode(t, tc.Input)) res, err := sel.Select(makeNode(t, tc.Input))
require.NoError(t, err) require.NoError(t, err)
require.NotEqual(t, node != nil, len(nodes) > 0) // XOR (only one of node or nodes should be set) require.NotNil(t, res)
// make an IPLD List node from a []datamodel.Node
if node == nil {
nb := basicnode.Prototype.List.NewBuilder()
la, err := nb.BeginList(int64(len(nodes)))
require.NoError(t, err)
for _, n := range nodes {
// TODO: This code is probably not needed if the Select operation properly prunes nil values - e.g.: Optional Iterator
if n == nil {
n = datamodel.Null
}
require.NoError(t, la.AssembleValue().AssignNode(n))
}
require.NoError(t, la.Finish())
node = nb.Build()
}
exp := makeNode(t, tc.Output) exp := makeNode(t, tc.Output)
equalIPLD(t, exp, node) require.True(t, ipld.DeepEqual(exp, res))
}) })
} }
// null // No error and return null, as optional
for _, testcase := range []Testcase{ for _, testcase := range []Testcase{
{Name: "Optional Missing Key", Selector: `.x?`, Input: `{}`}, {Name: "Optional Missing Key", Selector: `.x?`, Input: `{}`},
{Name: "Optional Null Key", Selector: `.x?`, Input: `null`}, {Name: "Optional Null Key", Selector: `.x?`, Input: `null`},
@@ -97,19 +73,15 @@ func TestSupportedForms(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// attempt to select // attempt to select
node, nodes, err := selector.Select(sel, makeNode(t, tc.Input)) res, err := sel.Select(makeNode(t, tc.Input))
require.NoError(t, err) require.NoError(t, err)
// TODO: should Select return a single node which is sometimes a list or null? require.Nil(t, res)
// require.Equal(t, datamodel.Null, node)
assert.Nil(t, node)
assert.Empty(t, nodes)
}) })
} }
// error // fail to select and return an error
for _, testcase := range []Testcase{ for _, testcase := range []Testcase{
{Name: "Null Iterator", Selector: `.[]`, Input: `null`}, {Name: "Null Iterator", Selector: `.[]`, Input: `null`},
{Name: "Nested Iterator", Selector: `.[][]`, Input: `[[1], 2, [3]]`},
{Name: "Missing Key", Selector: `.x`, Input: `{}`}, {Name: "Missing Key", Selector: `.x`, Input: `{}`},
{Name: "Null Key", Selector: `.x`, Input: `null`}, {Name: "Null Key", Selector: `.x`, Input: `null`},
{Name: "Array Key", Selector: `.x`, Input: `[]`}, {Name: "Array Key", Selector: `.x`, Input: `[]`},
@@ -124,31 +96,13 @@ func TestSupportedForms(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
// attempt to select // attempt to select
node, nodes, err := selector.Select(sel, makeNode(t, tc.Input)) res, err := sel.Select(makeNode(t, tc.Input))
require.Error(t, err) require.Error(t, err)
assert.Nil(t, node) require.Nil(t, res)
assert.Empty(t, nodes)
}) })
} }
} }
func equalIPLD(t *testing.T, expected datamodel.Node, actual datamodel.Node) bool {
t.Helper()
exp, act := &bytes.Buffer{}, &bytes.Buffer{}
if err := dagjson.Encode(expected, exp); err != nil {
return assert.Fail(t, "Failed to encode json for expected IPLD node")
}
if err := dagjson.Encode(actual, act); err != nil {
return assert.Fail(t, "Failed to encode JSON for actual IPLD node")
}
require.JSONEq(t, exp.String(), act.String())
return true
}
func makeNode(t *testing.T, dagJsonInput string) ipld.Node { func makeNode(t *testing.T, dagJsonInput string) ipld.Node {
t.Helper() t.Helper()

View File

@@ -0,0 +1,90 @@
package secretbox
import (
"crypto/rand"
"errors"
"fmt"
"io"
"golang.org/x/crypto/nacl/secretbox"
)
const keySize = 32 // secretbox allows only 32-byte keys
var ErrShortCipherText = errors.New("ciphertext too short")
var ErrNoEncryptionKey = errors.New("encryption key is required")
var ErrInvalidKeySize = errors.New("invalid key size: must be 32 bytes")
var ErrZeroKey = errors.New("encryption key cannot be all zeros")
// GenerateKey generates a random 32-byte key to be used by EncryptWithKey and DecryptWithKey
func GenerateKey() ([]byte, error) {
key := make([]byte, keySize)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return nil, fmt.Errorf("failed to generate key: %w", err)
}
return key, nil
}
// EncryptWithKey encrypts data using NaCl's secretbox with the provided key.
// 40 bytes of overhead (24-byte nonce + 16-byte MAC) are added to the plaintext size.
func EncryptWithKey(data, key []byte) ([]byte, error) {
if err := validateKey(key); err != nil {
return nil, err
}
var secretKey [keySize]byte
copy(secretKey[:], key)
// Generate 24 bytes of random data as nonce
var nonce [24]byte
if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil {
return nil, err
}
// Encrypt and authenticate data
encrypted := secretbox.Seal(nonce[:], data, &nonce, &secretKey)
return encrypted, nil
}
// DecryptStringWithKey decrypts data using secretbox with the provided key
func DecryptStringWithKey(data, key []byte) ([]byte, error) {
if err := validateKey(key); err != nil {
return nil, err
}
if len(data) < 24 {
return nil, ErrShortCipherText
}
var secretKey [keySize]byte
copy(secretKey[:], key)
var nonce [24]byte
copy(nonce[:], data[:24])
decrypted, ok := secretbox.Open(nil, data[24:], &nonce, &secretKey)
if !ok {
return nil, errors.New("decryption failed")
}
return decrypted, nil
}
func validateKey(key []byte) error {
if key == nil {
return ErrNoEncryptionKey
}
if len(key) != keySize {
return ErrInvalidKeySize
}
// check if key is all zeros
for _, b := range key {
if b != 0 {
return nil
}
}
return ErrZeroKey
}

View File

@@ -0,0 +1,144 @@
package secretbox
import (
"bytes"
"crypto/rand"
"testing"
"github.com/stretchr/testify/require"
)
func TestSecretBoxEncryption(t *testing.T) {
t.Parallel()
key := make([]byte, keySize) // generate random 32-byte key
_, errKey := rand.Read(key)
require.NoError(t, errKey)
tests := []struct {
name string
data []byte
key []byte
wantErr error
}{
{
name: "valid encryption/decryption",
data: []byte("hello world"),
key: key,
},
{
name: "nil key returns error",
data: []byte("hello world"),
key: nil,
wantErr: ErrNoEncryptionKey,
},
{
name: "empty data",
data: []byte{},
key: key,
},
{
name: "invalid key size",
data: []byte("hello world"),
key: make([]byte, 16), // Only 32 bytes allowed now
wantErr: ErrInvalidKeySize,
},
{
name: "zero key returns error",
data: []byte("hello world"),
key: make([]byte, keySize),
wantErr: ErrZeroKey,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
encrypted, err := EncryptWithKey(tt.data, tt.key)
if tt.wantErr != nil {
require.ErrorIs(t, err, tt.wantErr)
return
}
require.NoError(t, err)
// Verify encrypted data is different and includes nonce
require.Greater(t, len(encrypted), 24) // At least nonce size
if len(tt.data) > 0 {
require.NotEqual(t, tt.data, encrypted[24:]) // Ignore nonce prefix
}
decrypted, err := DecryptStringWithKey(encrypted, tt.key)
require.NoError(t, err)
require.True(t, bytes.Equal(tt.data, decrypted))
})
}
}
func TestDecryptionErrors(t *testing.T) {
t.Parallel()
key := make([]byte, keySize)
_, err := rand.Read(key)
require.NoError(t, err)
// Create valid encrypted data for tampering tests
validData := []byte("test message")
encrypted, err := EncryptWithKey(validData, key)
require.NoError(t, err)
tests := []struct {
name string
data []byte
key []byte
errMsg string
}{
{
name: "short ciphertext",
data: make([]byte, 23), // Less than nonce size
key: key,
errMsg: "ciphertext too short",
},
{
name: "invalid ciphertext",
data: make([]byte, 24), // Just nonce size
key: key,
errMsg: "decryption failed",
},
{
name: "tampered ciphertext",
data: tamperWithBytes(encrypted),
key: key,
errMsg: "decryption failed",
},
{
name: "missing key",
data: encrypted,
key: nil,
errMsg: "encryption key is required",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := DecryptStringWithKey(tt.data, tt.key)
require.Error(t, err)
require.Contains(t, err.Error(), tt.errMsg)
})
}
}
// tamperWithBytes modifies a byte in the encrypted data to simulate tampering
func tamperWithBytes(data []byte) []byte {
if len(data) < 25 { // Need at least nonce + 1 byte
return data
}
tampered := make([]byte, len(data))
copy(tampered, data)
tampered[24] ^= 0x01 // Modify first byte after nonce
return tampered
}

View File

@@ -0,0 +1,210 @@
// Package attestation implements the UCAN [attestation] specification with
// an immutable Token type as well as methods to convert the Token to and
// from the [envelope]-enclosed, signed and DAG-CBOR-encoded form that
// should most commonly be used for transport and storage.
//
// [envelope]: https://github.com/ucan-wg/spec#envelope
// [attestation]: TBD
package attestation
import (
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"github.com/MetaMask/go-did-it"
"github.com/ucan-wg/go-ucan/pkg/claims"
"github.com/ucan-wg/go-ucan/pkg/meta"
"github.com/ucan-wg/go-ucan/token/internal/nonce"
"github.com/ucan-wg/go-ucan/token/internal/parse"
)
// Token is an immutable type that holds the fields of a UCAN attestation.
type Token struct {
// The DID of the Invoker
issuer did.DID
// TODO: should this exist?
// audience did.DID
// Arbitrary Claims
claims *claims.Claims
// Arbitrary Metadata
meta *meta.Meta
// A unique, random nonce
nonce []byte
// The timestamp at which the Invocation becomes invalid
expiration *time.Time
// The timestamp at which the Invocation was created
issuedAt *time.Time
}
// New creates an attestation Token with the provided options.
//
// If no nonce is provided, a random 12-byte nonce is generated. Use the
// WithNonce or WithEmptyNonce options to specify provide your own nonce
// or to leave the nonce empty respectively.
//
// If no IssuedAt is provided, the current time is used. Use the
// IssuedAt or WithIssuedAtIn Options to specify a different time
// or the WithoutIssuedAt Option to clear the Token's IssuedAt field.
//
// With the exception of the WithMeta option, all others will overwrite
// the previous contents of their target field.
//
// You can read it as "(Issuer - I) attest (arbitrary claim)".
func New(iss did.DID, opts ...Option) (*Token, error) {
iat := time.Now()
tkn := Token{
issuer: iss,
claims: claims.NewClaims(),
meta: meta.NewMeta(),
nonce: nil,
issuedAt: &iat,
}
for _, opt := range opts {
if err := opt(&tkn); err != nil {
return nil, err
}
}
var err error
if len(tkn.nonce) == 0 {
tkn.nonce, err = nonce.Generate()
if err != nil {
return nil, err
}
}
if err := tkn.validate(); err != nil {
return nil, err
}
return &tkn, nil
}
// Issuer returns the did.DID representing the Token's issuer.
func (t *Token) Issuer() did.DID {
return t.issuer
}
// Claims returns the Token's claims.
func (t *Token) Claims() claims.ReadOnly {
return t.claims.ReadOnly()
}
// Meta returns the Token's metadata.
func (t *Token) Meta() meta.ReadOnly {
return t.meta.ReadOnly()
}
// Nonce returns the random Nonce encapsulated in this Token.
func (t *Token) Nonce() []byte {
return t.nonce
}
// Expiration returns the time at which the Token expires.
func (t *Token) Expiration() *time.Time {
return t.expiration
}
// IssuedAt returns the time.Time at which the invocation token was
// created.
func (t *Token) IssuedAt() *time.Time {
return t.issuedAt
}
// IsValidNow verifies that the token can be used at the current time, based on expiration or "not before" fields.
// This does NOT do any other kind of verifications.
func (t *Token) IsValidNow() bool {
return t.IsValidAt(time.Now())
}
// IsValidAt verifies that the token can be used at the given time, based on expiration or "not before" fields.
// This does NOT do any other kind of verifications.
func (t *Token) IsValidAt(ti time.Time) bool {
if t.expiration != nil && ti.After(*t.expiration) {
return false
}
return true
}
func (t *Token) String() string {
var res strings.Builder
res.WriteString(fmt.Sprintf("Issuer: %s\n", t.Issuer()))
res.WriteString(fmt.Sprintf("Nonce: %s\n", base64.StdEncoding.EncodeToString(t.Nonce())))
res.WriteString(fmt.Sprintf("Meta: %s\n", t.Meta()))
res.WriteString(fmt.Sprintf("Expiration: %v\n", t.Expiration()))
res.WriteString(fmt.Sprintf("Issued At: %v\n", t.IssuedAt()))
return res.String()
}
func (t *Token) validate() error {
var errs error
requiredDID := func(id did.DID, fieldname string) {
if id == nil {
errs = errors.Join(errs, fmt.Errorf(`a valid did is required for %s: %s`, fieldname, id.String()))
}
}
requiredDID(t.issuer, "Issuer")
if len(t.nonce) < 12 {
errs = errors.Join(errs, fmt.Errorf("token nonce too small"))
}
return errs
}
// tokenFromModel build a decoded view of the raw IPLD data.
// This function also serves as validation.
func tokenFromModel(m tokenPayloadModel) (*Token, error) {
var (
tkn Token
err error
)
if tkn.issuer, err = did.Parse(m.Iss); err != nil {
return nil, fmt.Errorf("parse iss: %w", err)
}
tkn.claims = m.Claims
if tkn.claims == nil {
tkn.claims = claims.NewClaims()
}
tkn.meta = m.Meta
if tkn.meta == nil {
tkn.meta = meta.NewMeta()
}
if len(m.Nonce) == 0 {
return nil, fmt.Errorf("nonce is required")
}
tkn.nonce = m.Nonce
tkn.expiration, err = parse.OptionalTimestamp(m.Exp)
if err != nil {
return nil, fmt.Errorf("parse expiration: %w", err)
}
tkn.issuedAt, err = parse.OptionalTimestamp(m.Iat)
if err != nil {
return nil, fmt.Errorf("parse IssuedAt: %w", err)
}
if err := tkn.validate(); err != nil {
return nil, err
}
return &tkn, nil
}

View File

@@ -0,0 +1,22 @@
type DID string
# The Attestation payload
type Payload struct {
# Issuer DID (sender)
iss DID
# Audience DID (receiver) TODO: should that exist?
# aud DID
# Arbitrary claims
claims optional {String: Any}
# Arbitrary Metadata
meta optional {String : Any}
# A unique, random nonce
nonce Bytes
# The timestamp at which the Invocation becomes invalid
exp nullable Int
# The Timestamp at which the Invocation was created
iat optional Int
}

View File

@@ -0,0 +1,132 @@
package attestation_test
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/MetaMask/go-did-it"
didkeyctl "github.com/MetaMask/go-did-it/controller/did-key"
"github.com/MetaMask/go-did-it/crypto/ed25519"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec/dagcbor"
"github.com/ipld/go-ipld-prime/codec/dagjson"
"github.com/ipld/go-ipld-prime/node/basicnode"
"github.com/ucan-wg/go-ucan/token/attestation"
)
func ExampleNew() {
privKey, iss, _, _, err := setupExampleNew()
if err != nil {
fmt.Println("failed to create setup:", err.Error())
return
}
att, err := attestation.New(iss,
attestation.WithClaim("claim1", "UCAN is great"),
attestation.WithMeta("env", "development"),
attestation.WithExpirationIn(time.Minute),
attestation.WithoutIssuedAt())
if err != nil {
fmt.Println("failed to create attestation:", err.Error())
return
}
// foo, _ := att.ToDagJson(privKey)
// os.WriteFile("testdata/new.dagjson", foo, 0666)
// fmt.Println(base64.StdEncoding.EncodeToString(privKey.ToBytes()))
data, cid, err := att.ToSealed(privKey)
if err != nil {
fmt.Println("failed to seal attestation:", err.Error())
return
}
json, err := prettyDAGJSON(data)
if err != nil {
fmt.Println("failed to pretty DAG-JSON:", err.Error())
return
}
fmt.Println("CID:", cid)
fmt.Println("Token (pretty DAG-JSON):")
fmt.Println(json)
// Expected CID and DAG-JSON output:
// CID: bafyreibm5vo6gk75oreefkg6xkrrfb4d5dgkccgmutirjgtzi5j45svjm4
// Token (pretty DAG-JSON):
// [
// {
// "/": {
// "bytes": "ApuXUsUYhqostO2zfKZK50GW0gXYPtrlpoVA8EwGFdyYahQecOizVpl+9wy64aqk2rMP4Q0UEUKCTV0ONMdPAw"
// }
// },
// {
// "h": {
// "/": {
// "bytes": "NAHtAe0BE3E"
// }
// },
// "ucan/att@tbd": {
// "claims": {
// "claim1": "UCAN is great"
// },
// "exp": 1767790971,
// "iss": "did:key:z6Mkm4RzzBDfSHqmwV9dp5jFsLkVgKRYp1PhSj7VybCcLHC4",
// "meta": {
// "env": "development"
// },
// "nonce": {
// "/": {
// "bytes": "NjS8QPvft97jbtUG"
// }
// }
// }
// }
// ]
}
func prettyDAGJSON(data []byte) (string, error) {
var node ipld.Node
node, err := ipld.Decode(data, dagcbor.Decode)
if err != nil {
return "", err
}
jsonData, err := ipld.Encode(node, dagjson.Encode)
if err != nil {
return "", err
}
var out bytes.Buffer
if err := json.Indent(&out, jsonData, "", " "); err != nil {
return "", err
}
return out.String(), nil
}
func setupExampleNew() (privKey ed25519.PrivateKey, iss did.DID, claims map[string]any, meta map[string]any, errs error) {
var err error
_, privKey, err = ed25519.GenerateKeyPair()
if err != nil {
errs = errors.Join(errs, fmt.Errorf("failed to generate Ed25519 keypair: %w", err))
return
}
iss = didkeyctl.FromPrivateKey(privKey)
claims = map[string]any{
"claim1": "UCAN is great",
}
meta = map[string]any{
"env": basicnode.NewString("development"),
}
return // WARNING: named return values
}

227
token/attestation/ipld.go Normal file
View File

@@ -0,0 +1,227 @@
package attestation
import (
"io"
"github.com/MetaMask/go-did-it"
"github.com/MetaMask/go-did-it/crypto"
"github.com/ipfs/go-cid"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/codec"
"github.com/ipld/go-ipld-prime/codec/dagcbor"
"github.com/ipld/go-ipld-prime/codec/dagjson"
"github.com/ipld/go-ipld-prime/datamodel"
"github.com/ucan-wg/go-ucan/token/internal/envelope"
)
// ToSealed wraps the attestation token in an envelope, generates the
// signature, encodes the result to DAG-CBOR and calculates the CID of
// the resulting binary data.
func (t *Token) ToSealed(privKey crypto.PrivateKeySigningBytes) ([]byte, cid.Cid, error) {
data, err := t.ToDagCbor(privKey)
if err != nil {
return nil, cid.Undef, err
}
id, err := envelope.CIDFromBytes(data)
if err != nil {
return nil, cid.Undef, err
}
return data, id, nil
}
// ToSealedWriter is the same as ToSealed but accepts an io.Writer.
func (t *Token) ToSealedWriter(w io.Writer, privKey crypto.PrivateKeySigningBytes) (cid.Cid, error) {
cidWriter := envelope.NewCIDWriter(w)
if err := t.ToDagCborWriter(cidWriter, privKey); err != nil {
return cid.Undef, err
}
return cidWriter.CID()
}
// FromSealed decodes the provided binary data from the DAG-CBOR format,
// verifies that the envelope's signature is correct based on the public
// key taken from the issuer (iss) field and calculates the CID of the
// incoming data.
func FromSealed(data []byte, resolvOpts ...did.ResolutionOption) (*Token, cid.Cid, error) {
tkn, err := FromDagCbor(data, resolvOpts...)
if err != nil {
return nil, cid.Undef, err
}
id, err := envelope.CIDFromBytes(data)
if err != nil {
return nil, cid.Undef, err
}
return tkn, id, nil
}
// FromSealedReader is the same as Unseal but accepts an io.Reader.
func FromSealedReader(r io.Reader, resolvOpts ...did.ResolutionOption) (*Token, cid.Cid, error) {
cidReader := envelope.NewCIDReader(r)
tkn, err := FromDagCborReader(cidReader, resolvOpts...)
if err != nil {
return nil, cid.Undef, err
}
id, err := cidReader.CID()
if err != nil {
return nil, cid.Undef, err
}
return tkn, id, nil
}
// Encode marshals a Token to the format specified by the provided
// codec.Encoder.
func (t *Token) Encode(privKey crypto.PrivateKeySigningBytes, encFn codec.Encoder) ([]byte, error) {
node, err := t.toIPLD(privKey)
if err != nil {
return nil, err
}
return ipld.Encode(node, encFn)
}
// EncodeWriter is the same as Encode, but accepts an io.Writer.
func (t *Token) EncodeWriter(w io.Writer, privKey crypto.PrivateKeySigningBytes, encFn codec.Encoder) error {
node, err := t.toIPLD(privKey)
if err != nil {
return err
}
return ipld.EncodeStreaming(w, node, encFn)
}
// ToDagCbor marshals the Token to the DAG-CBOR format.
func (t *Token) ToDagCbor(privKey crypto.PrivateKeySigningBytes) ([]byte, error) {
return t.Encode(privKey, dagcbor.Encode)
}
// ToDagCborWriter is the same as ToDagCbor, but it accepts an io.Writer.
func (t *Token) ToDagCborWriter(w io.Writer, privKey crypto.PrivateKeySigningBytes) error {
return t.EncodeWriter(w, privKey, dagcbor.Encode)
}
// ToDagJson marshals the Token to the DAG-JSON format.
func (t *Token) ToDagJson(privKey crypto.PrivateKeySigningBytes) ([]byte, error) {
return t.Encode(privKey, dagjson.Encode)
}
// ToDagJsonWriter is the same as ToDagJson, but it accepts an io.Writer.
func (t *Token) ToDagJsonWriter(w io.Writer, privKey crypto.PrivateKeySigningBytes) error {
return t.EncodeWriter(w, privKey, dagjson.Encode)
}
// Decode unmarshals the input data using the format specified by the
// provided codec.Decoder into a Token.
//
// An error is returned if the conversion fails or if the resulting
// Token is invalid.
func Decode(b []byte, decFn codec.Decoder, resolvOpts ...did.ResolutionOption) (*Token, error) {
node, err := ipld.Decode(b, decFn)
if err != nil {
return nil, err
}
return FromIPLD(node, resolvOpts...)
}
// DecodeReader is the same as Decode, but accept an io.Reader.
func DecodeReader(r io.Reader, decFn codec.Decoder, resolvOpts ...did.ResolutionOption) (*Token, error) {
node, err := ipld.DecodeStreaming(r, decFn)
if err != nil {
return nil, err
}
return FromIPLD(node, resolvOpts...)
}
// FromDagCbor unmarshals the input data into a Token.
//
// An error is returned if the conversion fails or if the resulting
// Token is invalid.
func FromDagCbor(data []byte, resolvOpts ...did.ResolutionOption) (*Token, error) {
pay, err := envelope.FromDagCbor[*tokenPayloadModel](data, resolvOpts...)
if err != nil {
return nil, err
}
tkn, err := tokenFromModel(*pay)
if err != nil {
return nil, err
}
return tkn, err
}
// FromDagCborReader is the same as FromDagCbor, but accept an io.Reader.
func FromDagCborReader(r io.Reader, resolvOpts ...did.ResolutionOption) (*Token, error) {
return DecodeReader(r, dagcbor.Decode, resolvOpts...)
}
// FromDagJson unmarshals the input data into a Token.
//
// An error is returned if the conversion fails or if the resulting
// Token is invalid.
func FromDagJson(data []byte, resolvOpts ...did.ResolutionOption) (*Token, error) {
return Decode(data, dagjson.Decode, resolvOpts...)
}
// FromDagJsonReader is the same as FromDagJson, but accept an io.Reader.
func FromDagJsonReader(r io.Reader, resolvOpts ...did.ResolutionOption) (*Token, error) {
return DecodeReader(r, dagjson.Decode, resolvOpts...)
}
// FromIPLD decode the given IPLD representation into a Token.
func FromIPLD(node datamodel.Node, resolvOpts ...did.ResolutionOption) (*Token, error) {
pay, err := envelope.FromIPLD[*tokenPayloadModel](node, resolvOpts...)
if err != nil {
return nil, err
}
tkn, err := tokenFromModel(*pay)
if err != nil {
return nil, err
}
return tkn, err
}
func (t *Token) toIPLD(privKey crypto.PrivateKeySigningBytes) (datamodel.Node, error) {
var exp *int64
if t.expiration != nil {
u := t.expiration.Unix()
exp = &u
}
var iat *int64
if t.issuedAt != nil {
i := t.issuedAt.Unix()
iat = &i
}
model := &tokenPayloadModel{
Iss: t.issuer.String(),
Claims: t.claims,
Meta: t.meta,
Nonce: t.nonce,
Exp: exp,
Iat: iat,
}
if len(model.Claims.Keys) == 0 {
model.Claims = nil
}
// seems like it's a requirement to have a null meta if there are no values?
if len(model.Meta.Keys) == 0 {
model.Meta = nil
}
return envelope.ToIPLD(privKey, model)
}

View File

@@ -0,0 +1,35 @@
package attestation_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/token/attestation"
)
func TestSealUnsealRoundtrip(t *testing.T) {
t.Parallel()
privKey, iss, claims, meta, err := setupExampleNew()
require.NoError(t, err)
tkn1, err := attestation.New(iss,
attestation.WithClaimMap(claims),
attestation.WithMetaMap(meta),
attestation.WithExpirationIn(time.Minute),
attestation.WithoutIssuedAt(),
)
require.NoError(t, err)
data, cid1, err := tkn1.ToSealed(privKey)
require.NoError(t, err)
tkn2, cid2, err := attestation.FromSealed(data)
require.NoError(t, err)
assert.Equal(t, cid1, cid2)
assert.Equal(t, tkn1, tkn2)
}

View File

@@ -0,0 +1,168 @@
package attestation
import (
"time"
)
// Option is a type that allows optional fields to be set during the
// creation of a Token.
type Option func(*Token) error
// WithClaim adds a key/value pair in the "claims" field.
//
// WithClaims can be used multiple times in the same call.
// Accepted types for the value are: bool, string, int, int32, int64, []byte,
// and ipld.Node.
func WithClaim(key string, val any) Option {
return func(t *Token) error {
return t.claims.Add(key, val)
}
}
// WithClaimsMap adds all key/value pairs in the provided map to the
// Token's "claims" field.
//
// WithClaimsMap can be used multiple times in the same call.
// Accepted types for the value are: bool, string, int, int32, int64, []byte,
// and ipld.Node.
func WithClaimMap(m map[string]any) Option {
return func(t *Token) error {
for k, v := range m {
if err := t.claims.Add(k, v); err != nil {
return err
}
}
return nil
}
}
// WithEncryptedClaimsString adds a key/value pair in the "claims" field.
// The string value is encrypted with the given aesKey.
func WithEncryptedClaimsString(key, val string, encryptionKey []byte) Option {
return func(t *Token) error {
return t.claims.AddEncrypted(key, val, encryptionKey)
}
}
// WithEncryptedClaimsBytes adds a key/value pair in the "claims" field.
// The []byte value is encrypted with the given aesKey.
func WithEncryptedClaimsBytes(key string, val, encryptionKey []byte) Option {
return func(t *Token) error {
return t.claims.AddEncrypted(key, val, encryptionKey)
}
}
// WithMeta adds a key/value pair in the "meta" field.
//
// WithMeta can be used multiple times in the same call.
// Accepted types for the value are: bool, string, int, int32, int64, []byte,
// and ipld.Node.
func WithMeta(key string, val any) Option {
return func(t *Token) error {
return t.meta.Add(key, val)
}
}
// WithMetaMap adds all key/value pairs in the provided map to the
// Token's "meta" field.
//
// WithMetaMap can be used multiple times in the same call.
// Accepted types for the value are: bool, string, int, int32, int64, []byte,
// and ipld.Node.
func WithMetaMap(m map[string]any) Option {
return func(t *Token) error {
for k, v := range m {
if err := t.meta.Add(k, v); err != nil {
return err
}
}
return nil
}
}
// WithEncryptedMetaString adds a key/value pair in the "meta" field.
// The string value is encrypted with the given aesKey.
func WithEncryptedMetaString(key, val string, encryptionKey []byte) Option {
return func(t *Token) error {
return t.meta.AddEncrypted(key, val, encryptionKey)
}
}
// WithEncryptedMetaBytes adds a key/value pair in the "meta" field.
// The []byte value is encrypted with the given aesKey.
func WithEncryptedMetaBytes(key string, val, encryptionKey []byte) Option {
return func(t *Token) error {
return t.meta.AddEncrypted(key, val, encryptionKey)
}
}
// WithNonce sets the Token's nonce with the given value.
//
// If this option is not used, a random 12-byte nonce is generated for
// this required field. If you truly want to create an invocation Token
// without a nonce, use the WithEmptyNonce Option which will set the
// nonce to an empty byte array.
func WithNonce(nonce []byte) Option {
return func(t *Token) error {
t.nonce = nonce
return nil
}
}
// WithEmptyNonce sets the Token's nonce to an empty byte slice as
// suggested by the UCAN spec for invocation tokens that represent
// idempotent operations.
func WithEmptyNonce() Option {
return func(t *Token) error {
t.nonce = []byte{}
return nil
}
}
// WithExpiration set's the Token's optional "expiration" field to the
// value of the provided time.Time.
func WithExpiration(exp time.Time) Option {
return func(t *Token) error {
exp = exp.Round(time.Second)
t.expiration = &exp
return nil
}
}
// WithExpirationIn set's the Token's optional "expiration" field to
// Now() plus the given duration.
func WithExpirationIn(after time.Duration) Option {
return WithExpiration(time.Now().Add(after))
}
// WithIssuedAt sets the Token's IssuedAt field to the provided
// time.Time.
//
// If this Option is not provided, the invocation Token's iat field will
// be set to the value of time.Now(). If you want to create an invocation
// Token without this field being set, use the WithoutIssuedAt Option.
func WithIssuedAt(iat time.Time) Option {
return func(t *Token) error {
t.issuedAt = &iat
return nil
}
}
// WithIssuedAtIn sets the Token's IssuedAt field to Now() plus the
// given duration.
func WithIssuedAtIn(after time.Duration) Option {
return WithIssuedAt(time.Now().Add(after))
}
// WithoutIssuedAt clears the Token's IssuedAt field.
func WithoutIssuedAt() Option {
return func(t *Token) error {
t.issuedAt = nil
return nil
}
}

View File

@@ -0,0 +1,73 @@
package attestation
import (
_ "embed"
"fmt"
"sync"
"github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/node/bindnode"
"github.com/ipld/go-ipld-prime/schema"
"github.com/ucan-wg/go-ucan/pkg/claims"
"github.com/ucan-wg/go-ucan/pkg/meta"
"github.com/ucan-wg/go-ucan/token/internal/envelope"
)
// [Tag] is the string used as a key within the SigPayload that identifies
// that the TokenPayload is an attestation.
//
// [Tag]: TODO: TBD
const Tag = "ucan/att@tbd" // TODO: TBD
//go:embed attestation.ipldsch
var schemaBytes []byte
var (
once sync.Once
ts *schema.TypeSystem
errSchema error
)
func mustLoadSchema() *schema.TypeSystem {
once.Do(func() {
ts, errSchema = ipld.LoadSchemaBytes(schemaBytes)
})
if errSchema != nil {
panic(fmt.Errorf("failed to load IPLD schema: %s", errSchema))
}
return ts
}
func payloadType() schema.Type {
return mustLoadSchema().TypeByName("Payload")
}
var _ envelope.Tokener = (*tokenPayloadModel)(nil)
type tokenPayloadModel struct {
// The DID of the Invoker
Iss string
// Arbitrary claims
Claims *claims.Claims
// Arbitrary Metadata
Meta *meta.Meta
// A unique, random nonce
Nonce []byte
// The timestamp at which the Invocation becomes invalid
// optional: can be nil
Exp *int64
// The timestamp at which the Invocation was created
Iat *int64
}
func (e *tokenPayloadModel) Prototype() schema.TypedPrototype {
return bindnode.Prototype((*tokenPayloadModel)(nil), payloadType())
}
func (*tokenPayloadModel) Tag() string {
return Tag
}

View File

@@ -0,0 +1,89 @@
package attestation_test
import (
"bytes"
_ "embed"
"encoding/base64"
"testing"
"github.com/MetaMask/go-did-it/crypto"
"github.com/MetaMask/go-did-it/crypto/ed25519"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/token/attestation"
"github.com/ucan-wg/go-ucan/token/internal/envelope"
)
//go:embed testdata/new.dagjson
var newDagJson []byte
const (
issuerPrivKeyCfg = "8bX3+HJxxlIGgNZ8yFG+t48oMGygEGyWD5Cy8ugeCIRksEIVyCabkuLVXbMZYj1lpXgL22Fok8nv52clGfEMXA=="
newCID = "zdpuAyWCG3GWfebFME3e4oG926tzpJodw4WTa9VjBwqPNiVWF"
)
func TestSchemaRoundTrip(t *testing.T) {
t.Parallel()
privKey := privKey(t, issuerPrivKeyCfg)
t.Run("via buffers", func(t *testing.T) {
t.Parallel()
// format: dagJson --> PayloadModel --> dagCbor --> PayloadModel --> dagJson
// function: DecodeDagJson() Seal() Unseal() EncodeDagJson()
p1, err := attestation.FromDagJson(newDagJson)
require.NoError(t, err)
cborBytes, id, err := p1.ToSealed(privKey)
require.NoError(t, err)
assert.Equal(t, newCID, envelope.CIDToBase58BTC(id))
p2, c2, err := attestation.FromSealed(cborBytes)
require.NoError(t, err)
assert.Equal(t, id, c2)
readJson, err := p2.ToDagJson(privKey)
require.NoError(t, err)
assert.JSONEq(t, string(newDagJson), string(readJson))
})
t.Run("via streaming", func(t *testing.T) {
t.Parallel()
buf := bytes.NewBuffer(newDagJson)
// format: dagJson --> PayloadModel --> dagCbor --> PayloadModel --> dagJson
// function: DecodeDagJson() Seal() Unseal() EncodeDagJson()
p1, err := attestation.FromDagJsonReader(buf)
require.NoError(t, err)
cborBytes := &bytes.Buffer{}
id, err := p1.ToSealedWriter(cborBytes, privKey)
require.NoError(t, err)
assert.Equal(t, newCID, envelope.CIDToBase58BTC(id))
p2, c2, err := attestation.FromSealedReader(cborBytes)
require.NoError(t, err)
assert.Equal(t, envelope.CIDToBase58BTC(id), envelope.CIDToBase58BTC(c2))
readJson := &bytes.Buffer{}
require.NoError(t, p2.ToDagJsonWriter(readJson, privKey))
assert.JSONEq(t, string(newDagJson), readJson.String())
})
}
func privKey(t require.TestingT, privKeyCfg string) crypto.PrivateKeySigningBytes {
privBytes, err := base64.StdEncoding.DecodeString(privKeyCfg)
require.NoError(t, err)
privKey, err := ed25519.PrivateKeyFromBytes(privBytes)
require.NoError(t, err)
return privKey
}

View File

@@ -0,0 +1 @@
[{"/":{"bytes":"9lfCwLn+HqFGPNMbD9mIuMjhZarhZk1mOSq2eGLIBfRM6B5dtIftDh25TOG3qJrWRvZtvupd0az/PiVv/8zMCg"}},{"h":{"/":{"bytes":"NAHtAe0BE3E"}},"ucan/att@tbd":{"claims":{"claim1":"UCAN is great"},"exp":1767790946,"iss":"did:key:z6MkmEJhVC9xHMREKTw1HpPrwVh6fcUbJ8hoVEa3UQdP9sNs","meta":{"env":"development"},"nonce":{"/":{"bytes":"jPnfQhL20Eoq/8fu"}}}}]

View File

@@ -0,0 +1,285 @@
// Package delegation implements the UCAN [delegation] specification with
// an immutable Token type as well as methods to convert the Token to and
// from the [envelope]-enclosed, signed and DAG-CBOR-encoded form that
// should most commonly be used for transport and storage.
//
// [delegation]: https://github.com/ucan-wg/delegation/tree/v1_ipld
// [envelope]: https://github.com/ucan-wg/spec#envelope
package delegation
// TODO: change the "delegation" link above when the specification is merged
import (
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"github.com/MetaMask/go-did-it"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/pkg/meta"
"github.com/ucan-wg/go-ucan/pkg/policy"
"github.com/ucan-wg/go-ucan/token/internal/nonce"
"github.com/ucan-wg/go-ucan/token/internal/parse"
)
// Token is an immutable type that holds the fields of a UCAN delegation.
type Token struct {
// Issuer DID (sender)
issuer did.DID
// Audience DID (receiver)
audience did.DID
// Principal that the chain is about (the Subject)
subject did.DID
// The Command to eventually invoke
command command.Command
// The delegation policy
policy policy.Policy
// A unique, random nonce
nonce []byte
// Arbitrary Metadata
meta *meta.Meta
// "Not before" UTC Unix Timestamp in seconds (valid from), 53-bits integer
notBefore *time.Time
// The timestamp at which the Invocation becomes invalid
expiration *time.Time
}
// New creates a validated delegation Token from the provided parameters and options.
// This is typically used to delegate a given power to another agent.
//
// You can read it as "(issuer) allows (audience) to perform (cmd+pol) on (subject)".
func New(iss did.DID, aud did.DID, cmd command.Command, pol policy.Policy, sub did.DID, opts ...Option) (*Token, error) {
tkn := &Token{
issuer: iss,
audience: aud,
subject: sub,
command: cmd,
policy: pol,
meta: meta.NewMeta(),
nonce: nil,
}
for _, opt := range opts {
if err := opt(tkn); err != nil {
return nil, err
}
}
var err error
if len(tkn.nonce) == 0 {
tkn.nonce, err = nonce.Generate()
if err != nil {
return nil, err
}
}
if err := tkn.validate(); err != nil {
return nil, err
}
return tkn, nil
}
// Root creates a validated UCAN delegation Token from the provided parameters and options.
// This is typically used to create and give power to an agent.
//
// You can read it as "(issuer) allows (audience) to perform (cmd+pol) on itself".
func Root(iss did.DID, aud did.DID, cmd command.Command, pol policy.Policy, opts ...Option) (*Token, error) {
return New(iss, aud, cmd, pol, iss, opts...)
}
// Powerline creates a validated UCAN delegation Token from the provided parameters and options.
//
// Powerline is a pattern for automatically delegating all future delegations to another agent regardless of Subject.
// This is a very powerful pattern, use it only if you understand it.
// Powerline delegations MUST NOT be used as the root delegation to a resource
//
// A very common use case for Powerline is providing a stable DID across multiple agents (e.g. representing a user with
// multiple devices). This enables the automatic sharing of authority across their devices without needing to share keys
// or set up a threshold scheme. It is also flexible, since a Powerline delegation MAY be revoked.
//
// You can read it as "(issuer) allows (audience) to perform (cmd+pol) on anything".
func Powerline(iss did.DID, aud did.DID, cmd command.Command, pol policy.Policy, opts ...Option) (*Token, error) {
return New(iss, aud, cmd, pol, nil, opts...)
}
// Issuer returns the did.DID representing the Token's issuer.
func (t *Token) Issuer() did.DID {
return t.issuer
}
// Audience returns the did.DID representing the Token's audience.
func (t *Token) Audience() did.DID {
return t.audience
}
// Subject returns the did.DID representing the Token's subject.
//
// This field may be did.Undef for delegations that are [Powerlined] but
// must be equal to the value returned by the Issuer method for root
// tokens.
func (t *Token) Subject() did.DID {
return t.subject
}
// Command returns the capability's command.Command.
func (t *Token) Command() command.Command {
return t.command
}
// Policy returns the capability's policy.Policy.
func (t *Token) Policy() policy.Policy {
return t.policy
}
// Nonce returns the random Nonce encapsulated in this Token.
func (t *Token) Nonce() []byte {
return t.nonce
}
// Meta returns the Token's metadata.
func (t *Token) Meta() meta.ReadOnly {
return t.meta.ReadOnly()
}
// NotBefore returns the time at which the Token becomes "active".
func (t *Token) NotBefore() *time.Time {
return t.notBefore
}
// Expiration returns the time at which the Token expires.
func (t *Token) Expiration() *time.Time {
return t.expiration
}
// IsRoot tells if the token is a root delegation.
func (t *Token) IsRoot() bool {
return t.issuer.Equal(t.subject)
}
// IsPowerline tells if the token is a powerline delegation.
func (t *Token) IsPowerline() bool {
return t.subject == nil
}
// IsValidNow verifies that the token can be used at the current time, based on expiration or "not before" fields.
// This does NOT do any other kind of verifications.
func (t *Token) IsValidNow() bool {
return t.IsValidAt(time.Now())
}
// IsValidNow verifies that the token can be used at the given time, based on expiration or "not before" fields.
// This does NOT do any other kind of verifications.
func (t *Token) IsValidAt(ti time.Time) bool {
if t.expiration != nil && ti.After(*t.expiration) {
return false
}
if t.notBefore != nil && ti.Before(*t.notBefore) {
return false
}
return true
}
func (t *Token) String() string {
var res strings.Builder
var kind string
switch {
case t.issuer == t.subject:
kind = " (root delegation)"
case t.subject == nil:
kind = " (powerline delegation)"
default:
kind = " (normal delegation)"
}
res.WriteString(fmt.Sprintf("Issuer: %s\n", t.Issuer()))
res.WriteString(fmt.Sprintf("Audience: %s\n", t.Audience()))
res.WriteString(fmt.Sprintf("Subject: %s%s\n", t.Subject(), kind))
res.WriteString(fmt.Sprintf("Command: %s\n", t.Command()))
res.WriteString(fmt.Sprintf("Policy: %s\n", t.Policy()))
res.WriteString(fmt.Sprintf("Nonce: %s\n", base64.StdEncoding.EncodeToString(t.Nonce())))
res.WriteString(fmt.Sprintf("Meta: %s\n", t.Meta()))
res.WriteString(fmt.Sprintf("NotBefore: %v\n", t.NotBefore()))
res.WriteString(fmt.Sprintf("Expiration: %v", t.Expiration()))
return res.String()
}
func (t *Token) validate() error {
var errs error
requiredDID := func(id did.DID, fieldname string) {
if id == nil {
errs = errors.Join(errs, fmt.Errorf(`a valid did is required for %s: %s`, fieldname, id.String()))
}
}
requiredDID(t.issuer, "Issuer")
requiredDID(t.audience, "Audience")
if len(t.nonce) < 12 {
errs = errors.Join(errs, fmt.Errorf("token nonce too small"))
}
return errs
}
// tokenFromModel build a decoded view of the raw IPLD data.
// This function also serves as validation.
func tokenFromModel(m tokenPayloadModel) (*Token, error) {
var (
tkn Token
err error
)
tkn.issuer, err = did.Parse(m.Iss)
if err != nil {
return nil, fmt.Errorf("parse iss: %w", err)
}
if tkn.audience, err = did.Parse(m.Aud); err != nil {
return nil, fmt.Errorf("parse audience: %w", err)
}
if tkn.subject, err = parse.OptionalDID(m.Sub); err != nil {
return nil, fmt.Errorf("parse subject: %w", err)
}
if tkn.command, err = command.Parse(m.Cmd); err != nil {
return nil, fmt.Errorf("parse command: %w", err)
}
if tkn.policy, err = policy.FromIPLD(m.Pol); err != nil {
return nil, fmt.Errorf("parse policy: %w", err)
}
if len(m.Nonce) == 0 {
return nil, fmt.Errorf("nonce is required")
}
tkn.nonce = m.Nonce
tkn.meta = m.Meta
if tkn.meta == nil {
tkn.meta = meta.NewMeta()
}
tkn.notBefore, err = parse.OptionalTimestamp(m.Nbf)
if err != nil {
return nil, fmt.Errorf("parse notBefore: %w", err)
}
tkn.expiration, err = parse.OptionalTimestamp(m.Exp)
if err != nil {
return nil, fmt.Errorf("parse expiration: %w", err)
}
if err := tkn.validate(); err != nil {
return nil, err
}
return &tkn, nil
}

View File

@@ -20,7 +20,7 @@ type Payload struct {
nonce Bytes nonce Bytes
# Arbitrary Metadata # Arbitrary Metadata
meta {String : Any} meta optional {String : Any}
# "Not before" UTC Unix Timestamp in seconds (valid from), 53-bits integer # "Not before" UTC Unix Timestamp in seconds (valid from), 53-bits integer
nbf optional Int nbf optional Int

View File

@@ -0,0 +1,213 @@
package delegation_test
import (
_ "embed"
"encoding/base64"
"testing"
"time"
"github.com/MetaMask/go-did-it/didtest"
"github.com/stretchr/testify/require"
"github.com/ucan-wg/go-ucan/pkg/command"
"github.com/ucan-wg/go-ucan/pkg/policy"
"github.com/ucan-wg/go-ucan/token/delegation"
)
//go:embed testdata/new.dagjson
var newDagJson []byte
//go:embed testdata/powerline.dagjson
var powerlineDagJson []byte
//go:embed testdata/root.dagjson
var rootDagJson []byte
const (
nonce = "6roDhGi0kiNriQAz7J3d+bOeoI/tj8ENikmQNbtjnD0"
subJectCmd = "/foo/bar"
subjectPol = `
[
["==", ".status", "draft"],
["all", ".reviewer",
["like", ".email", "*@example.com"]
],
["any", ".tags",
["or", [
["==", ".", "news"],
["==", ".", "press"]
]]
]
]
`
aesKey = "xQklMmNTnVrmaPBq/0pwV5fEwuv/iClF5HWak9MsgI8="
)
func TestConstructors(t *testing.T) {
t.Parallel()
cmd, err := command.Parse(subJectCmd)
require.NoError(t, err)
pol, err := policy.FromDagJson(subjectPol)
require.NoError(t, err)
exp, err := time.Parse(time.RFC3339, "2200-01-01T00:00:00Z")
require.NoError(t, err)
t.Run("New", func(t *testing.T) {
tkn, err := delegation.New(didtest.PersonaAlice.DID(), didtest.PersonaBob.DID(), cmd, pol, didtest.PersonaCarol.DID(),
delegation.WithNonce([]byte(nonce)),
delegation.WithExpiration(exp),
delegation.WithMeta("foo", "fooo"),
delegation.WithMeta("bar", "barr"),
)
require.NoError(t, err)
require.False(t, tkn.IsRoot())
require.False(t, tkn.IsPowerline())
data, err := tkn.ToDagJson(didtest.PersonaAlice.PrivKey())
require.NoError(t, err)
require.Equal(t, newDagJson, data)
})
t.Run("Root", func(t *testing.T) {
tkn, err := delegation.Root(didtest.PersonaAlice.DID(), didtest.PersonaBob.DID(), cmd, pol,
delegation.WithNonce([]byte(nonce)),
delegation.WithExpiration(exp),
delegation.WithMeta("foo", "fooo"),
delegation.WithMeta("bar", "barr"),
)
require.NoError(t, err)
require.True(t, tkn.IsRoot())
require.False(t, tkn.IsPowerline())
data, err := tkn.ToDagJson(didtest.PersonaAlice.PrivKey())
require.NoError(t, err)
require.Equal(t, rootDagJson, data)
})
t.Run("Powerline", func(t *testing.T) {
tkn, err := delegation.Powerline(didtest.PersonaAlice.DID(), didtest.PersonaBob.DID(), cmd, pol,
delegation.WithNonce([]byte(nonce)),
delegation.WithExpiration(exp),
delegation.WithMeta("foo", "fooo"),
delegation.WithMeta("bar", "barr"),
)
require.NoError(t, err)
require.False(t, tkn.IsRoot())
require.True(t, tkn.IsPowerline())
data, err := tkn.ToDagJson(didtest.PersonaAlice.PrivKey())
require.NoError(t, err)
require.Equal(t, powerlineDagJson, data)
})
}
func TestEncryptedMeta(t *testing.T) {
t.Parallel()
cmd, err := command.Parse(subJectCmd)
require.NoError(t, err)
pol, err := policy.FromDagJson(subjectPol)
require.NoError(t, err)
encryptionKey, err := base64.StdEncoding.DecodeString(aesKey)
require.NoError(t, err)
require.Len(t, encryptionKey, 32)
tests := []struct {
name string
key string
value string
expectError bool
}{
{
name: "simple string",
key: "secret1",
value: "hello world",
},
{
name: "empty string",
key: "secret2",
value: "",
},
{
name: "special characters",
key: "secret3",
value: "!@#$%^&*()_+-=[]{}|;:,.<>?",
},
{
name: "unicode characters",
key: "secret4",
value: "Hello, 世界! 👋",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
tkn, err := delegation.Root(didtest.PersonaAlice.DID(), didtest.PersonaBob.DID(), cmd, pol,
delegation.WithEncryptedMetaString(tt.key, tt.value, encryptionKey),
)
require.NoError(t, err)
data, err := tkn.ToDagCbor(didtest.PersonaAlice.PrivKey())
require.NoError(t, err)
decodedTkn, _, err := delegation.FromSealed(data)
require.NoError(t, err)
_, err = decodedTkn.Meta().GetString(tt.key)
require.Error(t, err)
decrypted, err := decodedTkn.Meta().GetEncryptedString(tt.key, encryptionKey)
require.NoError(t, err)
// Verify the decrypted value is equal to the original
require.Equal(t, tt.value, decrypted)
// Try to decrypt with wrong key
wrongKey := make([]byte, 32)
_, err = decodedTkn.Meta().GetEncryptedString(tt.key, wrongKey)
require.Error(t, err)
})
}
t.Run("multiple encrypted values in the same token", func(t *testing.T) {
values := map[string]string{
"secret1": "value1",
"secret2": "value2",
"secret3": "value3",
}
var opts []delegation.Option
for k, v := range values {
opts = append(opts, delegation.WithEncryptedMetaString(k, v, encryptionKey))
}
// Create token with multiple encrypted values
tkn, err := delegation.Root(didtest.PersonaAlice.DID(), didtest.PersonaBob.DID(), cmd, pol, opts...)
require.NoError(t, err)
data, err := tkn.ToDagCbor(didtest.PersonaAlice.PrivKey())
require.NoError(t, err)
decodedTkn, _, err := delegation.FromSealed(data)
require.NoError(t, err)
for k, v := range values {
decrypted, err := decodedTkn.Meta().GetEncryptedString(k, encryptionKey)
require.NoError(t, err)
require.Equal(t, v, decrypted)
}
})
}

View File

@@ -0,0 +1,5 @@
# delegationtest
See the package documentation for instructions on how to use the generated
tokens as well as information on how to regenerate the code if changes have
been made.

Some files were not shown because too many files have changed in this diff Show More