diff --git a/cmd/pdfops/run.go b/cmd/pdfops/run.go index 05f1fb0..40bda93 100644 --- a/cmd/pdfops/run.go +++ b/cmd/pdfops/run.go @@ -51,6 +51,10 @@ var commands = []command{ {"strip", "[-annotations] [-bookmarks] ", "write the file without its metadata", runStrip}, {"sanitize", " ", "remove what runs rather than shows: scripts, launching, embedded files", runSanitize}, {"flatten", " ", "draw the annotations into the page and drop them", runFlatten}, + {"compress", " ", "pack the objects into compressed streams", runCompress}, + {"encrypt", "-user [-owner ] [-allow ] [-aes128] ", "protect the file with a password", runEncrypt}, + {"decrypt", " ", "write the file without its protection", runDecrypt}, + {"permissions", "", "say how the file is protected and what it allows", runPermissions}, } // run is the whole program, so that the tests can drive it. @@ -625,3 +629,152 @@ func runStamp(c *context, args []string) error { } return save(d, fs.Arg(1)) } + +// runCompress packs the file's objects into compressed streams. +func runCompress(c *context, args []string) error { + fs := flags("compress") + if err := fs.Parse(args); err != nil { + return err + } + if err := wantArgs(fs, 2, " "); err != nil { + return err + } + d, err := c.open(fs.Arg(0)) + if err != nil { + return err + } + d.Compress() + return save(d, fs.Arg(1)) +} + +// permissionFlags names each permission the way a person would ask for it. +var permissionFlags = []struct { + name string + bit reader.Permissions +}{ + {"print", reader.PermPrint}, + {"print-faithful", reader.PermPrintFaithful}, + {"modify", reader.PermModify}, + {"assemble", reader.PermAssemble}, + {"copy", reader.PermCopy}, + {"extract", reader.PermExtract}, + {"annotate", reader.PermAnnotate}, + {"fill-forms", reader.PermFillForms}, + {"all", reader.AllPermissions}, + {"none", 0}, +} + +// parsePermissions reads a comma-separated list of what a reader may do. +func parsePermissions(spec string) (reader.Permissions, error) { + if spec == "" { + return reader.AllPermissions, nil + } + var out reader.Permissions + for _, word := range strings.Split(spec, ",") { + word = strings.TrimSpace(word) + found := false + for _, p := range permissionFlags { + if p.name == word { + out |= p.bit + found = true + break + } + } + if !found { + return 0, fmt.Errorf("no such permission %q; there is %s", word, permissionList()) + } + } + return out, nil +} + +// permissionList is every permission name, for an error message. +func permissionList() string { + names := make([]string, 0, len(permissionFlags)) + for _, p := range permissionFlags { + names = append(names, p.name) + } + return strings.Join(names, ", ") +} + +// runEncrypt protects a file with a password. +func runEncrypt(c *context, args []string) error { + fs := flags("encrypt") + user := fs.String("user", "", "the password that opens the file, subject to the permissions") + owner := fs.String("owner", "", "the password that opens it subject to nothing") + allow := fs.String("allow", "", "what a reader may do: "+permissionList()) + aes128 := fs.Bool("aes128", false, "use the older 128-bit method, for readers from before 2008") + if err := fs.Parse(args); err != nil { + return err + } + if err := wantArgs(fs, 2, " "); err != nil { + return err + } + if *user == "" && *owner == "" { + return fmt.Errorf("encrypt needs a -user or an -owner password") + } + perms, err := parsePermissions(*allow) + if err != nil { + return err + } + d, err := c.open(fs.Arg(0)) + if err != nil { + return err + } + d.Encrypt(reader.Encryption{ + UserPassword: *user, + OwnerPassword: *owner, + Permissions: perms, + AES128: *aes128, + }) + return save(d, fs.Arg(1)) +} + +// runDecrypt writes the file without its protection. The password to open it +// with is the global -password. +func runDecrypt(c *context, args []string) error { + fs := flags("decrypt") + if err := fs.Parse(args); err != nil { + return err + } + if err := wantArgs(fs, 2, " "); err != nil { + return err + } + d, err := c.open(fs.Arg(0)) + if err != nil { + return err + } + d.Decrypt() + return save(d, fs.Arg(1)) +} + +// runPermissions says how a file is protected and what it allows. +func runPermissions(c *context, args []string) error { + fs := flags("permissions") + if err := fs.Parse(args); err != nil { + return err + } + if err := wantArgs(fs, 1, ""); err != nil { + return err + } + d, err := c.open(fs.Arg(0)) + if err != nil { + return err + } + p, ok := d.Protection() + if !ok { + fmt.Fprintln(c.out, "protection none") + return nil + } + fmt.Fprintf(c.out, "protection %s, revision %d\n", p.Method, p.Revision) + fmt.Fprintf(c.out, "opened as %s\n", openedAs(p.Owner)) + fmt.Fprintf(c.out, "allows %s\n", p.Permissions) + return nil +} + +// openedAs says which password the file was opened with. +func openedAs(owner bool) string { + if owner { + return "the owner, so the permissions do not apply" + } + return "the user" +} diff --git a/cmd/pdfops/run_test.go b/cmd/pdfops/run_test.go index 111cf2c..5789082 100644 --- a/cmd/pdfops/run_test.go +++ b/cmd/pdfops/run_test.go @@ -529,3 +529,110 @@ func TestSanitizeFlattenAndStripFlags(t *testing.T) { } } } + +func TestCompressVerb(t *testing.T) { + in := fixture(t, 30) + out := filepath.Join(t.TempDir(), "packed.pdf") + if code, _, errOut := exec("compress", in, out); code != 0 { + t.Fatalf("compress said %d: %s", code, errOut) + } + before, _ := os.Stat(in) + after, _ := os.Stat(out) + if after.Size() >= before.Size() { + t.Errorf("packed is %d bytes and plain is %d", after.Size(), before.Size()) + } + if got := contentsOf(t, out); len(got) != 30 || got[0] != "page 1" { + t.Errorf("the packed file reads back as %v", got) + } + if code, _, _ := exec("compress", in); code == 0 { + t.Error("compress with one path was accepted") + } + if code, _, _ := exec("compress", "nowhere.pdf", out); code == 0 { + t.Error("compress read a file that is not there") + } + if code, _, _ := exec("compress", "-nope", in, out); code == 0 { + t.Error("compress took a flag it has never heard of") + } +} + +func TestEncryptDecryptAndPermissionsVerbs(t *testing.T) { + in := fixture(t, 3) + dir := t.TempDir() + locked := filepath.Join(dir, "locked.pdf") + if code, _, errOut := exec("encrypt", "-user", "u", "-owner", "o", "-allow", "print,copy", in, locked); code != 0 { + t.Fatalf("encrypt said %d: %s", code, errOut) + } + b, err := os.ReadFile(locked) + if err != nil { + t.Fatal(err) + } + if _, err := reader.Open(b); err == nil { + t.Error("the encrypted file opened with no password") + } + + code, out, errOut := exec("-password", "u", "permissions", locked) + if code != 0 { + t.Fatalf("permissions said %d: %s", code, errOut) + } + for _, want := range []string{"AES-256", "the user", "print, copy"} { + if !strings.Contains(out, want) { + t.Errorf("permissions did not mention %q:\n%s", want, out) + } + } + if _, out, _ = exec("-password", "o", "permissions", locked); !strings.Contains(out, "the owner") { + t.Errorf("the owner password was not recognised:\n%s", out) + } + if _, out, _ = exec("permissions", in); !strings.Contains(out, "protection none") { + t.Errorf("an unprotected file reported:\n%s", out) + } + + plain := filepath.Join(dir, "plain.pdf") + if code, _, errOut := exec("-password", "u", "decrypt", locked, plain); code != 0 { + t.Fatalf("decrypt said %d: %s", code, errOut) + } + if got := contentsOf(t, plain); len(got) != 3 || got[2] != "page 3" { + t.Errorf("the decrypted file reads back as %v", got) + } + + // The older method, for readers from before 2008. + old := filepath.Join(dir, "old.pdf") + if code, _, errOut := exec("encrypt", "-user", "u", "-aes128", in, old); code != 0 { + t.Fatalf("encrypt -aes128 said %d: %s", code, errOut) + } + if _, out, _ = exec("-password", "u", "permissions", old); !strings.Contains(out, "AES-128") { + t.Errorf("-aes128 produced:\n%s", out) + } +} + +func TestTheVerbsThatProtectRefuseWhatTheyShould(t *testing.T) { + in := fixture(t, 2) + out := filepath.Join(t.TempDir(), "out.pdf") + cases := []struct { + name string + args []string + }{ + {"encrypt with no password at all", []string{"encrypt", in, out}}, + {"encrypt with a permission nobody has heard of", []string{"encrypt", "-user", "u", "-allow", "fly", in, out}}, + {"encrypt with one path", []string{"encrypt", "-user", "u", in}}, + {"encrypt a file that is not there", []string{"encrypt", "-user", "u", "nowhere.pdf", out}}, + {"encrypt with an unknown flag", []string{"encrypt", "-nope", in, out}}, + {"decrypt with one path", []string{"decrypt", in}}, + {"decrypt a file that is not there", []string{"decrypt", "nowhere.pdf", out}}, + {"decrypt with an unknown flag", []string{"decrypt", "-nope", in, out}}, + {"permissions with no path", []string{"permissions"}}, + {"permissions on a file that is not there", []string{"permissions", "nowhere.pdf"}}, + {"permissions with an unknown flag", []string{"permissions", "-nope", in}}, + } + for _, c := range cases { + if code, _, _ := exec(c.args...); code == 0 { + t.Errorf("%s was accepted", c.name) + } + } + // Asking for everything is what saying nothing means, and "none" is a + // way of saying it out loud. + for _, allow := range []string{"", "all", "none", "print, copy"} { + if code, _, errOut := exec("encrypt", "-user", "u", "-allow", allow, in, out); code != 0 { + t.Errorf("-allow %q said %d: %s", allow, code, errOut) + } + } +} diff --git a/doc.go b/doc.go index 697298b..ddae8ae 100644 --- a/doc.go +++ b/doc.go @@ -27,6 +27,11 @@ type Doc struct { flatten bool dropAnnots bool dropOutlines bool + + // How the file is written: packed into compressed object streams, and + // protected or not. + packed bool + protect *reader.Encryption } // A Page is one page of a document, borrowed from the file it came from. The diff --git a/go.mod b/go.mod index 6901277..5224f40 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/go-pdfkit/ops go 1.26.4 -require github.com/go-pdfkit/reader v0.2.0 // indirect +require github.com/go-pdfkit/reader v0.4.0 diff --git a/go.sum b/go.sum index 38a6b2f..3ff081a 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,2 @@ -github.com/go-pdfkit/reader v0.1.0 h1:DbkTAXYWdhd6C5yl7+Ki68QWlg+h7vfLQw79q94qerw= -github.com/go-pdfkit/reader v0.1.0/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8= -github.com/go-pdfkit/reader v0.2.0 h1:jY8BZkfnHw/ImyRANapU/lQrTM1d1hR960Xb9nGC+nY= -github.com/go-pdfkit/reader v0.2.0/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8= +github.com/go-pdfkit/reader v0.4.0 h1:qPbNZSO+Xl+4NBvQoV1PYt7HlqHaEAQ1tUc6/IL8JAU= +github.com/go-pdfkit/reader v0.4.0/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8= diff --git a/protect.go b/protect.go new file mode 100644 index 0000000..597c1a4 --- /dev/null +++ b/protect.go @@ -0,0 +1,38 @@ +package ops + +import "github.com/go-pdfkit/reader" + +// Compress asks for the file to be written with its objects packed into +// compressed streams and a cross-reference stream, which is what every writer +// since PDF 1.5 does and what makes a file a good deal smaller. It costs +// nothing but a version of 1.5, which every reader in use has understood for +// twenty years. +func (d *Doc) Compress() { d.packed = true } + +// Encrypt protects the file that will be written. Two people can open it: +// whoever knows the user password, subject to the permissions, and whoever +// knows the owner password, subject to nothing. +// +// An encrypted file is not byte-for-byte reproducible — encryption needs +// randomness, by design — so a document written twice with the same call comes +// out different both times, and neither can be compared with the other. +func (d *Doc) Encrypt(e reader.Encryption) { d.protect = &e } + +// Decrypt writes the file without protection. A document opened with the right +// password is already decrypted, so this only undoes an earlier call to +// [Doc.Encrypt]; a file read with [OpenWithPassword] and written out is +// unprotected either way. +func (d *Doc) Decrypt() { d.protect = nil } + +// Protection reports how the file this document was read from was protected, +// and false when it was not protected at all — or when the document was not +// read from a file. It says nothing about how the document will be written: +// that is what was passed to [Doc.Encrypt]. +func (d *Doc) Protection() (reader.Protection, bool) { + for _, p := range d.pages { + if p.src != nil { + return p.src.Protection() + } + } + return reader.Protection{}, false +} diff --git a/protect_test.go b/protect_test.go new file mode 100644 index 0000000..f90734f --- /dev/null +++ b/protect_test.go @@ -0,0 +1,171 @@ +package ops + +import ( + "errors" + "testing" + + "github.com/go-pdfkit/reader" +) + +// bulky is a document whose pages carry enough repetition to be worth +// compressing, which a handful of bytes would not be. +func bulky(t *testing.T, n int) []byte { + t.Helper() + return buildPDF(t, n, func(i int, d reader.Dict) { + d["Keywords"] = reader.String("the same long string on every single page, over and over") + }) +} + +// opened reads a file the test has built, or fails. +func opened(t *testing.T, b []byte) *Doc { + t.Helper() + d, err := Open(b) + if err != nil { + t.Fatal(err) + } + return d +} + +func TestCompressingAFile(t *testing.T) { + // Packing the objects into compressed streams has to leave the pages + // exactly as they were and the file smaller than it was. + src := bulky(t, 40) + plain, err := opened(t, src).Bytes() + if err != nil { + t.Fatal(err) + } + d := opened(t, src) + d.Compress() + packed, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + if len(packed) >= len(plain) { + t.Errorf("packed is %d bytes and plain is %d", len(packed), len(plain)) + } + if got, want := contentsOf(t, packed), contentsOf(t, plain); !equal(got, want) { + t.Error("packing changed what is on the pages") + } + // A packed file needs a version that says so. + back, err := reader.Open(packed) + if err != nil { + t.Fatal(err) + } + if back.Version() < "1.5" { + t.Errorf("a packed file says it is version %s", back.Version()) + } +} + +func TestEncryptingAFile(t *testing.T) { + // Both passwords open it and nothing else does; what the pages say is + // unchanged; and the permissions written are the ones asked for. + for _, packed := range []bool{false, true} { + d := opened(t, simple(t, 3)) + if packed { + d.Compress() + } + d.Encrypt(reader.Encryption{ + UserPassword: "letmein", OwnerPassword: "iownit", + Permissions: reader.PermPrint | reader.PermExtract, + }) + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + if _, err := reader.Open(out); !errors.Is(err, reader.ErrWrongPassword) { + t.Errorf("packed=%v: an encrypted file opened with no password: %v", packed, err) + } + if _, err := reader.OpenWithPassword(out, "guess"); !errors.Is(err, reader.ErrWrongPassword) { + t.Errorf("packed=%v: a wrong password opened it", packed) + } + for _, pw := range []string{"letmein", "iownit"} { + back, err := reader.OpenWithPassword(out, pw) + if err != nil { + t.Fatalf("packed=%v: %q did not open it: %v", packed, pw, err) + } + if back.PageCount() != 3 { + t.Errorf("packed=%v: %q sees %d pages", packed, pw, back.PageCount()) + } + p, ok := back.Protection() + if !ok { + t.Fatalf("packed=%v: it says it is not protected", packed) + } + if p.Permissions != reader.PermPrint|reader.PermExtract { + t.Errorf("packed=%v: it allows %v", packed, p.Permissions) + } + if want := pw == "iownit"; p.Owner != want { + t.Errorf("packed=%v: %q opened as owner = %v", packed, pw, p.Owner) + } + } + } +} + +func TestDecryptingAFile(t *testing.T) { + // A file read with its password and written out again is not protected, + // and Decrypt takes back an Encrypt that has not happened yet. + d := opened(t, simple(t, 2)) + d.Encrypt(reader.Encryption{UserPassword: "shh"}) + locked, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + back, err := OpenWithPassword(locked, "shh") + if err != nil { + t.Fatal(err) + } + plain, err := back.Bytes() + if err != nil { + t.Fatal(err) + } + if _, err := reader.Open(plain); err != nil { + t.Errorf("a file written from a decrypted document still needs a password: %v", err) + } + if got := contentsOf(t, plain); !equal(got, pages(1, 2)) { + t.Errorf("the pages came out as %v", got) + } + + d2 := opened(t, simple(t, 1)) + d2.Encrypt(reader.Encryption{UserPassword: "shh"}) + d2.Decrypt() + out, err := d2.Bytes() + if err != nil { + t.Fatal(err) + } + if _, err := reader.Open(out); err != nil { + t.Errorf("Decrypt did not take back Encrypt: %v", err) + } +} + +func TestWhatADocumentSaysItWasProtectedWith(t *testing.T) { + // It reports the file it was read from, not what it will be written as. + d := opened(t, simple(t, 1)) + d.Encrypt(reader.Encryption{UserPassword: "shh", Permissions: reader.PermPrint}) + locked, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + if p, ok := d.Protection(); ok { + t.Errorf("a document read from an unprotected file reported %+v", p) + } + + back, err := OpenWithPassword(locked, "shh") + if err != nil { + t.Fatal(err) + } + p, ok := back.Protection() + if !ok { + t.Fatal("a document read from a protected file says it was not") + } + if p.Method != "AES-256" || p.Permissions != reader.PermPrint { + t.Errorf("it reports %+v", p) + } + + // A document with no page borrowed from anywhere has nothing to report. + blank := &Doc{version: "1.7"} + if err := blank.InsertBlank(1); err != nil { + t.Fatal(err) + } + if p, ok := blank.Protection(); ok { + t.Errorf("a document made from nothing reported %+v", p) + } +} diff --git a/write.go b/write.go index db00b3f..5c53579 100644 --- a/write.go +++ b/write.go @@ -26,6 +26,14 @@ func (d *Doc) Bytes() ([]byte, error) { return nil, fmt.Errorf("ops: a document with no pages cannot be written") } w := reader.NewWriter(d.version) + if d.packed { + w = reader.NewPackedWriter(d.version) + } + if d.protect != nil { + // Before anything is written: a file cannot be protected after the + // fact, because the key is what everything in it is written through. + w.Encrypt(*d.protect) + } pagesRef := w.Reserve() // Pages are numbered first and written last: what goes on one of them may