-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Prevent opts()
from unsafely exposing a private object and expose a proxy instead
#1921
Conversation
opts()
from exposing private object
Bit of a mind dump, take with salt. Leaning towards towards PR and giving this a go in next major version, although no issues reported so low priority. I did wonder about making a shallow clone at the time the code was added (not mentioned): #1102 I have a vague performance concern about code like:
But:
Thinking about traps, a possible trap is people writing mixed code like: .action((options, cmd) => {
if (options)
cmd.setOptionValueWithSource('foo', 'bar', 'my-source');
doSomething(options); // does this include 'bar'? Change in behaviour.
}) |
I see two solutions:
|
A shallow clone does not protect against modification to non-trivial properties. I knew that was the case, and wondering how relevant. Yup! The quick check I did was an array, like for a variadic option. A more complex example is some object created by a custom parser, like say an URL. |
Another idea: make It seems like all difficult problems can be solved by using proxies :) |
I don't think this is relevant, I think the user should expect to receive an object with actual option values and act accordingly (make deep clones if option value mutations are undesired). Also, it would mean too much work to do deep cloning every time |
A proxy is returned only when options-as-properties are disabled.
For consistency, the object returned from
|
…constructor Borrowed from tj#1919. Makes _optionValues the only true storage for option values. Has the added benefit of supporting option names conflicting with instance's properties even when options-as-properties are enabled.
When using options-as-properties: - Added getter and setter for value of version option - Limit setOptionValueWithSource() to registered options
I did some light experimentation, and noticed one slightly odd output: import { Command } from 'commander';
const program = new Command();
program.storeOptionsAsProperties();
program.option('--xyz', 'description');
program.parse();
console.log(program.opts()); 1921 % node index.mjs --xyz
{ undefined: [Getter/Setter], xyz: true } |
Historically the way options were stored on the command object itself caused many problems: #933 The new way of storing the options is in a separate object: #1102. I am not too concerned about exposing the underlying object via A fully private implementation would allow changing the underlying representation (say to a map) without externally visible changes, but having the API at all offers the major benefits. Good enough. |
Gave it a go! 😆 I find it both clever, and slightly alarming, to return the This approach does allow some guardrails and do more than a free-for-all object without an explicit API to make it possible. |
I fixed this in a3f0e28, and even made the property look like a data property despite using getters and setters in the console output.
It would be okay to expose the underlying object without a proxy if setting / deleting an option did not require a change to a different object, namely
With both proxies I've added, the options object could be made available as a property while still supporting both storage modes (
Yes, and it worked!
Yet, it is supported, and I think even subclassing works as expected. I am not sure why you closed the PR to be honest. Is preventing the unsafe exposure something you are not interested in? I would like to open a new PR for this branch since the essence has changed and is now not to change the return value of
Is it okay if I open it? |
I didn't think just making comments would send a clear enough message. I'll explain at some length and hopefully give you some insight into my reasoning. Commander is a widely used and long-lived library with many existing users. I am conservative about changes. I see the goal of the PR as being of low value and more about theoretical correctness than a practical concern. Adding a spread operator: maybe. Changing the actual object returned from the Command constructor, and introducing a subtle layer for every use of the command object, and multiple handler "traps", and potential maintenance complications, and risk of unanticipated breaking changes, and explicit breaking changes with only being able to store known options as properties in legacy programs: no no no no no no.
I don't think that is worthwhile based on my current understanding. The PR template includes in particular:
To expand on that. What problem affecting users are you solving? Is this blocking you, or preventing some reasonable or best practice author patterns? If this supported by your own experience with writing a real program, or been reported as an issue? Is this something that will affect many users and we could help them learn how to do it correctly? Should this be enforced in code or just change documentation? Is this something the maintainers of the library may be concerned about causing more problems in the future? To be clear: holes in the logical behaviour of the library are still of interest! (But making the program more opinionated and stricter will also break some existing users and usages. Are the benefits worthwhile?) |
I'll add a couple of examples of concerns about the depth of these changes. |
You think subclassing works as expected? I expect this was just a casual and honest comment, but this is a big red flag. Neither of us have experience or expertise with I did some reading about // add private property to Command class
class Command extends EventEmitter {
#sequenceNumber = 1;
incrementSequence(value) {
value = value ?? 1;
this.#sequenceNumber += value;
} import { program } from 'commander';
program.incrementSequence(); % node private.mjs
/Users/john/Documents/Sandpits/commander/my-fork/lib/command.js:20
this.#sequenceNumber += value;
^
TypeError: Cannot read private member #sequenceNumber from an object whose class did not declare it
at Proxy.incrementSequence (/Users/john/Documents/Sandpits/commander/my-fork/lib/command.js:20:9)
at file:///Users/john/Documents/Sandpits/commander/issues/1919/private.mjs:2:9
at ModuleJob.run (node:internal/modules/esm/module_job:194:25)
Node.js v18.16.1 NB: I know that this can potentially be worked around by yet more work in the My point is that adopting a sophisticated pattern has costs and unanticipated side-affects. Working with a plain Class is core JavaScript. I see |
Enough exposition! Hopefully you have a better idea of what I think about as a conservative library maintainer. |
A related issue for me to read later, and for you If you're interested: |
For interest, here is a solution I came up with even before reading into the issue: class Instance {
#secret = 'hedgehog';
constructor() {
return new Proxy(this, {
get(target, key) {
const value = Reflect.get(target, key);
return value instanceof Function ? value.bind(target) : value;
}
});
}
revealSecret() {
console.log(this.#secret);
}
}
const instance = new Instance();
instance.revealSecret(); Update: There are some complications, though.
Update: All the issues can be solved by returning a proxy further up the prototype chain! class InstanceBase {
constructor() {
return new Proxy(this, {});
}
}
class Instance extends InstanceBase {
#secret = 'hedgehog';
revealSecret() {
console.log(this.#secret);
}
}
const instance = new Instance();
instance.revealSecret(); // succeeds
console.log(instance.revealSecret === instance.revealSecret); // true
function someMethod() {}
instance.someMethod = someMethod;
console.log(instance.someMethod === someMethod); // true |
The change ensures the this object used in methods is the proxy from the very beginning. This solves pretty much all issues with returning the proxy from a constructor. For example, private fields can be used now. More details available at: tj#1921 (comment) Additionally, wrong spelling has been fixed in comments.
Now implemented on the branch I wanted this PR to merge, see eb142d8.
I admit my knowledge of proxies is not perfect, but I had already had some exposure to them before adding them here which I had got first and foremost from giving this answer on Stack Overflow 2 years ago, not even hoping it would ever be read by anyone because of how old and specific the question was, but just because I had fun experimenting with this stuff. Now, as I keep experimenting here, my knowledge gets better by the hour. For example, since now the
As you may have noticed by now, I am used to the "code first" approach and let myself indulge in experiments before having a clear idea about what they should end in and unit tests ready. But that doesn't mean I am taking all of this lightly and don't understand the responsibility that comes with making changes to a project of such scale. It is great you pointed me in the right direction by finding the issue with private fields, and that is something that should happen when changes adopting sophisticated tools like this one are discussed. It is definitely worth it to keep looking thoroughly for further problems / unanticipated side effects, but I am honestly beginning to doubt there will be any now when the problem with private fields is fixed, because the solution with a constructor returning the proxy further up the prototype chain seems to be a silver bullet in a way. I will be happy if you prove me wrong, though, and I challenge you to do so :) That would show me I am being a little too careless and would be a good lesson for me. As for me, I personally don't see any way the users could tell they are dealing with a proxy except for the obvious
I am not sure I agree with you on that. I find the way the following code works quite problematic, and not only from the theoretical point of view: program.setOptionValueWithSource('foo', 'bar', 'config');
const optionValues = program.opts();
console.log(program.getOptionValue('foo')); // bar
console.log(program.getOptionValueSource('foo')); // config
optionValues.foo = 'baz';
console.log(program.getOptionValue('foo')); // baz
console.log(program.getOptionValueSource('foo')); // still config, but should be undefined
delete optionValues.foo;
console.log(program.getOptionValue('foo')); // undefined
console.log(program.getOptionValueSource('foo')); // still config, but should be undefined Note that only public methods are used here and, despite that, inconsistent states are reached. That should definitely not be allowed, but the only way to prevent it while keeping the object returned from The first proxy it leads us to is
That is what this PR is all about. No, the issue does not affect many users and has probably never been reported, and deleting an option value like in the example above is not something I have thought of doing in a real application. However, I do think the library should not allow inconsistent states like the ones in the example to be reached, even if it almost never happens in practice.
Setting or deleting an option value without caring about its saved source remaining unchanged is usage that should never have been made possible in the first place since it is simply wrong. To sum up, I still think you gave up on this PR too early. |
The consistency between the modes in the way the return value of If you say this discrepancy is desired because keeping the old approach as backwards compatible as possible is: weil, in that case, there is no need to return a proxy from a constructor as part of this PR, but I still think it's a reasonable change because of the advantages I've named. |
Very interesting and deep commentary on a controversial issue from multiple experts. Thanks. Some divergence between the envisaged uses of Proxy (i.e. membranes) and the way it has been described and used.
I apologise that I did assume you had recently discovered proxies, and were seeing them as solutions everywhere.
I recommend using
Effectively yes. The old approach is still available for backwards compatibility, not as an alternative mechanism for accessing options. (I suspect this may not be a direct response to your comment, but still relevant.)
Short version: I did that. I am still comfortable with it. |
Borrowed from eb142d8 that was supposed to land in the now-closed tj#1921. The change ensures the this object used in Command is the proxy from the very beginning, even in the constructor. This solves pretty much all issues with returning the proxy from a constructor. For example, private fields can be used now. More details available at: tj#1921 (comment) Additionally, the ownKeys() trap and wrong spelling in comments have been fixed (the former change being borrowed from fc927c8).
On the contrary, this is a very useful response. I now reverted 1b94efb (see aweebit@ef54142) to make Check develop...aweebit:feature/opts-return to see the file changes that would be suggested by this PR if it were reopened now. There would be nothing new but a thin layer on top of Do you think just this one proxy use is still problematic? I personally think it is highly unlikely to cause any trouble, including when changes to the language are made. After all, |
opts()
from exposing private objectopts()
from unsafely exposing a private object and expose a proxy instead
Everything is now prepared for a potential reopen, see the updated PR title and description. |
The _version and _versionOptionName properties are initialized to undefined in the constructor. The reasoning is - to make them visible when the Command instance is logged into the console before .version() is called, and not only appear after suddenly - to not break anything if the proxy use for consistent option value handling in the legacy setOptionValueWithSource mode suggested in tj#1919 (and previously in tj#1921) is adopted
This is a sensibly minor use of proxy.
However, still not something I am concerned about fixing at this time. But interesting to know that Copy of relevant code for possible future reference:
|
Problem
opts()
now returns the private_optionValues
object, making changes to it such as property deletion possible, but I don't think they are supposed to be possible. For example, deleting a property would lead to an inconsistent state in which a source is defined in_optionValueSources
for an option value that does not exist anymore.Solution
The PR originally fixed the problem by returning a shallow clone of
_optionValues
fromopts()
(the original #1920 did the same and has only been closed because of a branch rename).However, to still support allowed changes to option values on the object returned from
opts()
and make getting / setting the values through that object consistent with howgetOptionValue()
/setOptionValue()
behave by, for example, setting the source toundefined
when updating the value (but that could also be relevant after #1919 is merged since the way option values are set would differ depending on whether the command is currently being parsed), thus making the change as "non-breaking" as possible and at the same time preventing inconsistencies, a different solution using a proxy with theget()
,set()
,deleteProperty()
anddefineProperty()
traps as the return value ofopts()
has been adopted.It is worth mentioning that no private object is exposed in the current library code when the legacy
storeOptionsAsProperties
mode is enabled. To preserve backwards-compatibility, the return value ofopts()
in that mode is left unchanged.ChangeLog
Changed
opts()
and make getting / setting option values through that object whenstoreOptionsAsProperties
is disabled consistent with how.getOptionValue()
/.setOptionValue()
work